diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 3f6fd135..7f766737 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: https://legend.lnbits.com/tipjar/794 +custom: https://docs.cashu.space/contribute diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c50f6b2..fcae1ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,9 @@ jobs: poetry-version: ["1.7.1"] backend-wallet-class: ["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] + # mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"] + mint-database: ["./test_data/test_mint"] with: python-version: ${{ matrix.python-version }} backend-wallet-class: ${{ matrix.backend-wallet-class }} - mint-database: "./test_data/test_mint" + mint-database: ${{ matrix.mint-database }} diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index ed052a4f..8beb7d83 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -52,10 +52,6 @@ jobs: chmod -R 777 . bash ./start.sh - - name: Create fake admin - if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }} - run: docker exec cashu-lnbits-1 poetry run python tools/create_fake_admin.py - - name: Run Tests env: WALLET_NAME: test_wallet diff --git a/README.md b/README.md index 9a9a2b0f..73e89b1e 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,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.2 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.15.3 poetry run mint ``` ## From this repository diff --git a/cashu/core/base.py b/cashu/core/base.py index 072d2a8e..67326728 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -161,20 +161,13 @@ def htlcpreimage(self) -> Union[str, None]: return HTLCWitness.from_witness(self.witness).preimage -class Proofs(BaseModel): - # NOTE: not used in Pydantic validation - __root__: List[Proof] - - class BlindedMessage(BaseModel): """ Blinded message or blinded secret or "output" which is to be signed by the mint """ amount: int - id: Optional[ - str - ] # DEPRECATION: Only Optional for backwards compatibility with old clients < 0.15 for deprecated API route. + id: str B_: str # Hex-encoded blinded message witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) @@ -194,10 +187,14 @@ class BlindedSignature(BaseModel): C_: str # Hex-encoded signature dleq: Optional[DLEQ] = None # DLEQ proof - -class BlindedMessages(BaseModel): - # NOTE: not used in Pydantic validation - __root__: List[BlindedMessage] = [] + @classmethod + def from_row(cls, row: Row): + return cls( + id=row["id"], + amount=row["amount"], + C_=row["c_"], + dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]), + ) # ------- LIGHTNING INVOICE ------- @@ -332,6 +329,19 @@ class GetInfoResponse_deprecated(BaseModel): parameter: Optional[dict] = None +class BlindedMessage_Deprecated(BaseModel): + # Same as BlindedMessage, but without the id field + amount: int + B_: str # Hex-encoded blinded message + id: Optional[str] = None + witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) + + @property + def p2pksigs(self) -> List[str]: + assert self.witness, "Witness missing in output" + return P2PKWitness.from_witness(self.witness).signatures + + # ------- API: KEYS ------- @@ -398,7 +408,7 @@ class GetMintResponse_deprecated(BaseModel): class PostMintRequest_deprecated(BaseModel): - outputs: List[BlindedMessage] = Field( + outputs: List[BlindedMessage_Deprecated] = Field( ..., max_items=settings.mint_max_request_length ) @@ -445,7 +455,7 @@ class PostMeltResponse(BaseModel): class PostMeltRequest_deprecated(BaseModel): proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) pr: str = Field(..., max_length=settings.mint_max_request_length) - outputs: Union[List[BlindedMessage], None] = Field( + outputs: Union[List[BlindedMessage_Deprecated], None] = Field( None, max_items=settings.mint_max_request_length ) @@ -474,7 +484,7 @@ class PostSplitResponse(BaseModel): class PostSplitRequest_Deprecated(BaseModel): proofs: List[Proof] = Field(..., max_items=settings.mint_max_request_length) amount: Optional[int] = None - outputs: List[BlindedMessage] = Field( + outputs: List[BlindedMessage_Deprecated] = Field( ..., max_items=settings.mint_max_request_length ) @@ -646,7 +656,6 @@ def __init__( valid_to=None, first_seen=None, active=True, - use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0 ): self.valid_from = valid_from self.valid_to = valid_to @@ -661,19 +670,10 @@ def __init__( else: self.id = id - # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 - if use_deprecated_id: - logger.warning( - "Using deprecated keyset id derivation for backwards compatibility <" - " 0.15.0" - ) - self.id = derive_keyset_id_deprecated(self.public_keys) - # END BACKWARDS COMPATIBILITY < 0.15.0 - self.unit = Unit[unit] logger.trace(f"Derived keyset id {self.id} from public keys.") - if id and id != self.id and use_deprecated_id: + if id and id != self.id: logger.warning( f"WARNING: Keyset id {self.id} does not match the given id {id}." " Overwriting." @@ -728,8 +728,6 @@ class MintKeyset: first_seen: Optional[str] = None version: Optional[str] = None - duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0 - def __init__( self, *, @@ -810,6 +808,12 @@ def generate_keys(self): assert self.seed, "seed not set" assert self.derivation_path, "derivation path not set" + # we compute the keyset id from the public keys only if it is not + # loaded from the database. This is to allow for backwards compatibility + # with old keysets with new id's and vice versa. This code can be removed + # if there are only new keysets in the mint (> 0.15.0) + id_in_db = self.id + if self.version_tuple < (0, 12): # WARNING: Broken key derivation for backwards compatibility with < 0.12 self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12( @@ -820,7 +824,8 @@ def generate_keys(self): f"WARNING: Using weak key derivation for keyset {self.id} (backwards" " compatibility < 0.12)" ) - self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore + # load from db or derive + self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore elif self.version_tuple < (0, 15): self.private_keys = derive_keys_sha256(self.seed, self.derivation_path) logger.trace( @@ -828,11 +833,13 @@ def generate_keys(self): " compatibility < 0.15)" ) self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore + # load from db or derive + self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore else: self.private_keys = derive_keys(self.seed, self.derivation_path) self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - self.id = derive_keyset_id(self.public_keys) # type: ignore + # load from db or derive + self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore # ------- TOKEN ------- diff --git a/cashu/core/errors.py b/cashu/core/errors.py index d36614a4..96a9c263 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -63,7 +63,9 @@ class KeysetNotFoundError(KeysetError): detail = "keyset not found" code = 12001 - def __init__(self): + def __init__(self, keyset_id: Optional[str] = None): + if keyset_id: + self.detail = f"{self.detail}: {keyset_id}" super().__init__(self.detail, code=self.code) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index bc01df81..d010a76f 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.15.2" +VERSION = "0.15.3" def find_env_file(): @@ -58,14 +58,6 @@ class MintSettings(CashuSettings): mint_database: str = Field(default="data/mint") mint_test_database: str = Field(default="test_data/test_mint") - mint_duplicate_keysets: bool = Field( - default=True, - title="Duplicate keysets", - description=( - "Whether to duplicate keysets for backwards compatibility before v1 API" - " (Nutshell 0.15.0)." - ), - ) class MintBackends(MintSettings): @@ -125,6 +117,7 @@ class FakeWalletSettings(MintSettings): fakewallet_brr: bool = Field(default=True) fakewallet_delay_payment: bool = Field(default=False) fakewallet_stochastic_invoice: bool = Field(default=False) + fakewallet_payment_state: Optional[bool] = Field(default=None) mint_cache_secrets: bool = Field(default=True) @@ -133,7 +126,6 @@ class MintInformation(CashuSettings): mint_info_description: str = Field(default=None) mint_info_description_long: str = Field(default=None) mint_info_contact: List[List[str]] = Field(default=[["", ""]]) - mint_info_nuts: List[str] = Field(default=["NUT-07", "NUT-08", "NUT-09"]) mint_info_motd: str = Field(default=None) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 4503f6a0..6cbb7d10 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -247,17 +247,21 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: r.raise_for_status() data = r.json() - if r.is_error or "error" in data or not data.get("pays"): - raise Exception("error in corelightning-rest response") + if not data.get("pays"): + # payment not found + logger.error(f"payment not found: {data.get('pays')}") + raise Exception("payment not found") + + if r.is_error or "error" in data: + message = data.get("error") or data + raise Exception(f"error in corelightning-rest response: {message}") pay = data["pays"][0] fee_msat, preimage = None, None if self.statuses[pay["status"]]: # cut off "msat" and convert to int - fee_msat = -int(pay["amount_sent_msat"][:-4]) - int( - pay["amount_msat"][:-4] - ) + fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"]) preimage = pay["preimage"] return PaymentStatus( diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index f4c0f018..564c000d 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -139,7 +139,7 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus(paid=paid or None) async def get_payment_status(self, _: str) -> PaymentStatus: - return PaymentStatus(paid=None) + return PaymentStatus(paid=settings.fakewallet_payment_state) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 96dff6bb..174236ef 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -151,8 +151,18 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: if "paid" not in data and "details" not in data: return PaymentStatus(paid=None) + paid_value = None + if data["paid"]: + paid_value = True + elif not data["paid"] and data["details"]["pending"]: + paid_value = None + elif not data["paid"] and not data["details"]["pending"]: + paid_value = False + else: + raise ValueError(f"unexpected value for paid: {data['paid']}") + return PaymentStatus( - paid=data["paid"], + paid=paid_value, fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), preimage=data["preimage"], ) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 29197325..3c2e75ad 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -217,14 +217,20 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: async for json_line in r.aiter_lines(): try: line = json.loads(json_line) + + # check for errors if line.get("error"): - logger.error( + message = ( line["error"]["message"] if "message" in line["error"] else line["error"] ) + logger.error(f"LND get_payment_status error: {message}") return PaymentStatus(paid=None) + payment = line.get("result") + + # payment exists if payment is not None and payment.get("status"): return PaymentStatus( paid=statuses[payment["status"]], diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 051947c3..983b1935 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -255,9 +255,9 @@ def _verify_output_p2pk_spending_conditions( # check if all secrets are P2PK # NOTE: This is redundant, because P2PKSecret.from_secret() already checks for the kind # Leaving it in for explicitness - if not all([ - SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets - ]): + if not all( + [SecretKind(secret.kind) == SecretKind.P2PK for secret in p2pk_secrets] + ): # not all secrets are P2PK return True diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 75f4471c..30d30b1c 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -34,8 +34,7 @@ async def get_keyset( derivation_path: str = "", seed: str = "", conn: Optional[Connection] = None, - ) -> List[MintKeyset]: - ... + ) -> List[MintKeyset]: ... @abstractmethod async def get_spent_proofs( @@ -43,8 +42,7 @@ async def get_spent_proofs( *, db: Database, conn: Optional[Connection] = None, - ) -> List[Proof]: - ... + ) -> List[Proof]: ... async def get_proof_used( self, @@ -52,8 +50,7 @@ async def get_proof_used( Y: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[Proof]: - ... + ) -> Optional[Proof]: ... @abstractmethod async def invalidate_proof( @@ -61,9 +58,26 @@ async def invalidate_proof( *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... + + @abstractmethod + async def get_all_melt_quotes_from_pending_proofs( + self, + *, + db: Database, + conn: Optional[Connection] = None, + ) -> List[MeltQuote]: ... + + @abstractmethod + async def get_pending_proofs_for_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> List[Proof]: ... @abstractmethod async def get_proofs_pending( @@ -72,8 +86,7 @@ async def get_proofs_pending( Ys: List[str], db: Database, conn: Optional[Connection] = None, - ) -> List[Proof]: - ... + ) -> List[Proof]: ... @abstractmethod async def set_proof_pending( @@ -81,15 +94,18 @@ async def set_proof_pending( *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def unset_proof_pending( - self, *, proof: Proof, db: Database, conn: Optional[Connection] = None - ) -> None: - ... + self, + *, + proof: Proof, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... @abstractmethod async def store_keyset( @@ -98,16 +114,14 @@ async def store_keyset( db: Database, keyset: MintKeyset, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_balance( self, db: Database, conn: Optional[Connection] = None, - ) -> int: - ... + ) -> int: ... @abstractmethod async def store_promise( @@ -115,24 +129,22 @@ async def store_promise( *, db: Database, amount: int, - B_: str, - C_: str, + b_: str, + c_: str, id: str, e: str = "", s: str = "", conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_promise( self, *, db: Database, - B_: str, + b_: str, conn: Optional[Connection] = None, - ) -> Optional[BlindedSignature]: - ... + ) -> Optional[BlindedSignature]: ... @abstractmethod async def store_mint_quote( @@ -141,8 +153,7 @@ async def store_mint_quote( quote: MintQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_mint_quote( @@ -151,8 +162,7 @@ async def get_mint_quote( quote_id: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[MintQuote]: - ... + ) -> Optional[MintQuote]: ... @abstractmethod async def get_mint_quote_by_request( @@ -161,8 +171,7 @@ async def get_mint_quote_by_request( request: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[MintQuote]: - ... + ) -> Optional[MintQuote]: ... @abstractmethod async def update_mint_quote( @@ -171,8 +180,7 @@ async def update_mint_quote( quote: MintQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... # @abstractmethod # async def update_mint_quote_paid( @@ -191,8 +199,7 @@ async def store_melt_quote( quote: MeltQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_melt_quote( @@ -202,8 +209,7 @@ async def get_melt_quote( db: Database, checking_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> Optional[MeltQuote]: - ... + ) -> Optional[MeltQuote]: ... @abstractmethod async def update_melt_quote( @@ -212,8 +218,7 @@ async def update_melt_quote( quote: MeltQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... class LedgerCrudSqlite(LedgerCrud): @@ -228,8 +233,8 @@ async def store_promise( *, db: Database, amount: int, - B_: str, - C_: str, + b_: str, + c_: str, id: str, e: str = "", s: str = "", @@ -238,13 +243,13 @@ async def store_promise( await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'promises')} - (amount, B_b, C_b, e, s, id, created) + (amount, b_, c_, dleq_e, dleq_s, id, created) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( amount, - B_, - C_, + b_, + c_, e, s, id, @@ -256,17 +261,17 @@ async def get_promise( self, *, db: Database, - B_: str, + b_: str, conn: Optional[Connection] = None, ) -> Optional[BlindedSignature]: row = await (conn or db).fetchone( f""" SELECT * from {table_with_schema(db, 'promises')} - WHERE B_b = ? + WHERE b_ = ? """, - (str(B_),), + (str(b_),), ) - return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None + return BlindedSignature.from_row(row) if row else None async def get_spent_proofs( self, @@ -286,14 +291,15 @@ async def invalidate_proof( *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, ) -> None: # we add the proof and secret to the used list await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'proofs_used')} - (amount, C, secret, Y, id, witness, created) - VALUES (?, ?, ?, ?, ?, ?, ?) + (amount, c, secret, y, id, witness, created, melt_quote) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( proof.amount, @@ -303,9 +309,39 @@ async def invalidate_proof( proof.id, proof.witness, timestamp_now(db), + quote_id, ), ) + async def get_all_melt_quotes_from_pending_proofs( + self, + *, + db: Database, + conn: Optional[Connection] = None, + ) -> List[MeltQuote]: + rows = await (conn or db).fetchall( + f""" + SELECT * from {table_with_schema(db, 'melt_quotes')} WHERE quote in (SELECT DISTINCT melt_quote FROM {table_with_schema(db, 'proofs_pending')}) + """ + ) + return [MeltQuote.from_row(r) for r in rows] + + async def get_pending_proofs_for_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> List[Proof]: + rows = await (conn or db).fetchall( + f""" + SELECT * from {table_with_schema(db, 'proofs_pending')} + WHERE melt_quote = ? + """, + (quote_id,), + ) + return [Proof(**r) for r in rows] + async def get_proofs_pending( self, *, @@ -316,7 +352,7 @@ async def get_proofs_pending( rows = await (conn or db).fetchall( f""" SELECT * from {table_with_schema(db, 'proofs_pending')} - WHERE Y IN ({','.join(['?']*len(Ys))}) + WHERE y IN ({','.join(['?']*len(Ys))}) """, tuple(Ys), ) @@ -327,21 +363,25 @@ async def set_proof_pending( *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, ) -> None: # we add the proof and secret to the used list await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'proofs_pending')} - (amount, C, secret, Y, created) - VALUES (?, ?, ?, ?, ?) + (amount, c, secret, y, id, witness, created, melt_quote) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( proof.amount, proof.C, proof.secret, proof.Y, + proof.id, + proof.witness, timestamp_now(db), + quote_id, ), ) @@ -628,7 +668,7 @@ async def get_proof_used( row = await (conn or db).fetchone( f""" SELECT * from {table_with_schema(db, 'proofs_used')} - WHERE Y = ? + WHERE y = ? """, (Y,), ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 255a61bc..735a1442 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,5 +1,4 @@ import asyncio -import copy import time from typing import Dict, List, Mapping, Optional, Tuple @@ -26,14 +25,13 @@ from ..core.crypto import b_dhke from ..core.crypto.aes import AESCipher from ..core.crypto.keys import ( - derive_keyset_id, - derive_keyset_id_deprecated, derive_pubkey, random_hash, ) from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database, get_db_connection from ..core.errors import ( + CashuError, KeysetError, KeysetNotFoundError, LightningError, @@ -72,7 +70,8 @@ def __init__( derivation_path="", crud=LedgerCrudSqlite(), ): - assert seed, "seed not set" + if not seed: + raise Exception("seed not set") # decrypt seed if seed_decryption_key is set try: @@ -93,6 +92,83 @@ def __init__( self.pubkey = derive_pubkey(self.seed) self.spent_proofs: Dict[str, Proof] = {} + # ------- STARTUP ------- + + async def startup_ledger(self): + await self._startup_ledger() + await self._check_pending_proofs_and_melt_quotes() + + async def _startup_ledger(self): + if settings.mint_cache_secrets: + await self.load_used_proofs() + await self.init_keysets() + + for derivation_path in settings.mint_derivation_path_list: + await self.activate_keyset(derivation_path=derivation_path) + + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.warning( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'", + RuntimeWarning, + ) + logger.info(f"Backend balance: {status.balance} {unit.name}") + + logger.info(f"Data dir: {settings.cashu_dir}") + + async def _check_pending_proofs_and_melt_quotes(self): + """Startup routine that checks all pending proofs for their melt state and either invalidates + them for a successful melt or deletes them if the melt failed. + """ + # get all pending melt quotes + melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( + db=self.db + ) + if not melt_quotes: + return + for quote in melt_quotes: + # get pending proofs for quote + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote.quote, db=self.db + ) + # check with the backend whether the quote has been paid during downtime + payment = await self.backends[Method[quote.method]][ + Unit[quote.unit] + ].get_payment_status(quote.checking_id) + if payment.paid: + logger.info(f"Melt quote {quote.quote} state: paid") + quote.paid_time = int(time.time()) + quote.paid = True + if payment.fee: + quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount + quote.proof = payment.preimage or "" + await self.crud.update_melt_quote(quote=quote, db=self.db) + # invalidate proofs + await self._invalidate_proofs( + proofs=pending_proofs, quote_id=quote.quote + ) + # unset pending + await self._unset_proofs_pending(pending_proofs) + elif payment.failed: + logger.info(f"Melt quote {quote.quote} state: failed") + + # unset pending + await self._unset_proofs_pending(pending_proofs) + elif payment.pending: + logger.info(f"Melt quote {quote.quote} state: pending") + pass + else: + logger.error("Melt quote state unknown") + pass + # ------- KEYS ------- async def activate_keyset( @@ -112,7 +188,8 @@ async def activate_keyset( Returns: MintKeyset: Keyset """ - assert derivation_path, "derivation path not set" + if not derivation_path: + raise Exception("derivation path not set") seed = seed or self.seed tmp_keyset_local = MintKeyset( seed=seed, @@ -151,18 +228,10 @@ async def activate_keyset( # load the new keyset in self.keysets self.keysets[keyset.id] = keyset - # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 - # set the deprecated id - assert keyset.public_keys - keyset.duplicate_keyset_id = derive_keyset_id_deprecated(keyset.public_keys) - # END BACKWARDS COMPATIBILITY < 0.15.0 - logger.debug(f"Loaded keyset {keyset.id}") return keyset - async def init_keysets( - self, autosave: bool = True, duplicate_keysets: Optional[bool] = None - ) -> None: + async def init_keysets(self, autosave: bool = True) -> None: """Initializes all keysets of the mint from the db. Loads all past keysets from db and generate their keys. Then activate the current keyset set by self.derivation_path. @@ -170,9 +239,6 @@ async def init_keysets( autosave (bool, optional): Whether the current keyset should be saved if it is not in the database yet. Will be passed to `self.activate_keyset` where it is generated from `self.derivation_path`. Defaults to True. - duplicate_keysets (bool, optional): Whether to duplicate new keysets and compute - their old keyset id, and duplicate old keysets and compute their new keyset id. - Defaults to False. """ # load all past keysets from db, the keys will be generated at instantiation tmp_keysets: List[MintKeyset] = await self.crud.get_keyset(db=self.db) @@ -191,35 +257,16 @@ async def init_keysets( logger.info(f"Current keyset: {self.keyset.id}") # check that we have a least one active keyset - assert any([k.active for k in self.keysets.values()]), "No active keyset found." - - # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 - # we duplicate new keysets and compute their old keyset id, and - # we duplicate old keysets and compute their new keyset id - if ( - duplicate_keysets is None and settings.mint_duplicate_keysets - ) or duplicate_keysets: - for _, keyset in copy.copy(self.keysets).items(): - keyset_copy = copy.copy(keyset) - assert keyset_copy.public_keys - if keyset.version_tuple >= (0, 15): - keyset_copy.id = derive_keyset_id_deprecated( - keyset_copy.public_keys - ) - else: - keyset_copy.id = derive_keyset_id(keyset_copy.public_keys) - keyset_copy.duplicate_keyset_id = keyset.id - self.keysets[keyset_copy.id] = keyset_copy - # remember which keyset this keyset was duplicated from - logger.debug(f"Duplicated keyset id {keyset.id} -> {keyset_copy.id}") - # END BACKWARDS COMPATIBILITY < 0.15.0 + if not any([k.active for k in self.keysets.values()]): + raise KeysetError("No active keyset found.") def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" if keyset_id and keyset_id not in self.keysets: raise KeysetNotFoundError() keyset = self.keysets[keyset_id] if keyset_id else self.keyset - assert keyset.public_keys, KeysetError("no public keys for this keyset") + if not keyset.public_keys: + raise KeysetError("no public keys for this keyset") return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} async def get_balance(self) -> int: @@ -229,7 +276,11 @@ async def get_balance(self) -> int: # ------- ECASH ------- async def _invalidate_proofs( - self, proofs: List[Proof], conn: Optional[Connection] = None + self, + *, + proofs: List[Proof], + quote_id: Optional[str] = None, + conn: Optional[Connection] = None, ) -> None: """Adds proofs to the set of spent proofs and stores them in the db. @@ -241,7 +292,9 @@ async def _invalidate_proofs( async with get_db_connection(self.db, conn) as conn: # store in db for p in proofs: - await self.crud.invalidate_proof(proof=p, db=self.db, conn=conn) + await self.crud.invalidate_proof( + proof=p, db=self.db, quote_id=quote_id, conn=conn + ) async def _generate_change_promises( self, @@ -317,7 +370,8 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: MintQuote: Mint quote object. """ logger.trace("called request_mint") - assert quote_request.amount > 0, "amount must be positive" + if not quote_request.amount > 0: + raise TransactionError("amount must be positive") if settings.mint_max_peg_in and quote_request.amount > settings.mint_max_peg_in: raise NotAllowedError( f"Maximum mint amount is {settings.mint_max_peg_in} sat." @@ -343,9 +397,8 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: f" {invoice_response.checking_id}" ) - assert ( - invoice_response.payment_request and invoice_response.checking_id - ), LightningError("could not fetch bolt11 payment request from backend") + if not (invoice_response.payment_request and invoice_response.checking_id): + raise LightningError("could not fetch bolt11 payment request from backend") # get invoice expiry time invoice_obj = bolt11.decode(invoice_response.payment_request) @@ -395,7 +448,8 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) if not quote.paid: - assert quote.checking_id, "quote has no checking id" + if not quote.checking_id: + raise CashuError("quote has no checking id") logger.trace(f"Lightning: checking invoice {quote.checking_id}") status: PaymentStatus = await self.backends[method][ unit @@ -435,18 +489,27 @@ async def mint( await self._verify_outputs(outputs) sum_amount_outputs = sum([b.amount for b in outputs]) + output_units = set([k.unit for k in [self.keysets[o.id] for o in outputs]]) + if not len(output_units) == 1: + raise TransactionError("outputs have different units") + output_unit = list(output_units)[0] + self.locks[quote_id] = ( self.locks.get(quote_id) or asyncio.Lock() ) # create a new lock if it doesn't exist async with self.locks[quote_id]: quote = await self.get_mint_quote(quote_id=quote_id) - assert quote.paid, QuoteNotPaidError() - assert not quote.issued, "quote already issued" - assert ( - quote.amount == sum_amount_outputs - ), "amount to mint does not match quote amount" - if quote.expiry: - assert quote.expiry > int(time.time()), "quote expired" + + if not quote.paid: + raise QuoteNotPaidError() + if quote.issued: + raise TransactionError("quote already issued") + if not quote.unit == output_unit.name: + raise TransactionError("quote unit does not match output unit") + if not quote.amount == sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amount") + if quote.expiry and quote.expiry > int(time.time()): + raise TransactionError("quote expired") promises = await self._generate_promises(outputs) logger.trace("generated promises") @@ -488,12 +551,19 @@ async def melt_quote( request=request, db=self.db ) if mint_quote: - assert request == mint_quote.request, "bolt11 requests do not match" - assert mint_quote.unit == melt_quote.unit, "units do not match" - assert mint_quote.method == method.name, "methods do not match" - assert not mint_quote.paid, "mint quote already paid" - assert not mint_quote.issued, "mint quote already issued" - assert mint_quote.checking_id, "mint quote has no checking id" + if not request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.unit == melt_quote.unit: + raise TransactionError("units do not match") + if not mint_quote.method == method.name: + raise TransactionError("methods do not match") + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + if not mint_quote.checking_id: + raise TransactionError("mint quote has no checking id") + payment_quote = PaymentQuoteResponse( checking_id=mint_quote.checking_id, amount=Amount(unit, mint_quote.amount), @@ -506,20 +576,20 @@ async def melt_quote( else: # not internal, get payment quote by backend payment_quote = await self.backends[method][unit].get_payment_quote(request) - assert payment_quote.checking_id, "quote has no checking id" + if not payment_quote.checking_id: + raise TransactionError("quote has no checking id") # make sure the backend returned the amount with a correct unit - assert ( - payment_quote.amount.unit == unit - ), "payment quote amount units do not match" + if not payment_quote.amount.unit == unit: + raise TransactionError("payment quote amount units do not match") # fee from the backend must be in the same unit as the amount - assert ( - payment_quote.fee.unit == unit - ), "payment quote fee units do not match" + if not payment_quote.fee.unit == unit: + raise TransactionError("payment quote fee units do not match") # We assume that the request is a bolt11 invoice, this works since we # support only the bol11 method for now. invoice_obj = bolt11.decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") # we set the expiry of this quote to the expiry of the bolt11 invoice expiry = None if invoice_obj.expiry is not None: @@ -620,23 +690,28 @@ async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: if not mint_quote: return melt_quote # we settle the transaction internally - assert not melt_quote.paid, "melt quote already paid" + if melt_quote.paid: + raise TransactionError("melt quote already paid") # verify amounts from bolt11 invoice bolt11_request = melt_quote.request invoice_obj = bolt11.decode(bolt11_request) - assert invoice_obj.amount_msat, "invoice has no amount." - # invoice_amount_sat = math.ceil(invoice_obj.amount_msat / 1000) - # assert ( - # Amount(Unit[melt_quote.unit], mint_quote.amount).to(Unit.sat).amount - # == invoice_amount_sat - # ), "amounts do not match" - assert mint_quote.amount == melt_quote.amount, "amounts do not match" - assert bolt11_request == mint_quote.request, "bolt11 requests do not match" - assert mint_quote.unit == melt_quote.unit, "units do not match" - assert mint_quote.method == melt_quote.method, "methods do not match" - assert not mint_quote.paid, "mint quote already paid" - assert not mint_quote.issued, "mint quote already issued" + + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + if not mint_quote.amount == melt_quote.amount: + raise TransactionError("amounts do not match") + if not bolt11_request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.unit == melt_quote.unit: + raise TransactionError("units do not match") + if not mint_quote.method == melt_quote.method: + raise TransactionError("methods do not match") + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + logger.info( f"Settling bolt11 payment internally: {melt_quote.quote} ->" f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" @@ -681,25 +756,25 @@ async def melt( melt_quote.unit, melt_quote.method ) - assert not melt_quote.paid, "melt quote already paid" + if melt_quote.paid: + raise TransactionError("melt quote already paid") # make sure that the outputs (for fee return) are in the same unit as the quote if outputs: await self._verify_outputs(outputs, skip_amount_check=True) - assert outputs[0].id, "output id not set" outputs_unit = self.keysets[outputs[0].id].unit - assert melt_quote.unit == outputs_unit.name, ( - f"output unit {outputs_unit.name} does not match quote unit" - f" {melt_quote.unit}" - ) + if not melt_quote.unit == outputs_unit.name: + raise TransactionError( + f"output unit {outputs_unit.name} does not match quote unit {melt_quote.unit}" + ) # verify that the amount of the input proofs is equal to the amount of the quote total_provided = sum_proofs(proofs) total_needed = melt_quote.amount + (melt_quote.fee_reserve or 0) - assert total_provided >= total_needed, ( - f"not enough inputs provided for melt. Provided: {total_provided}, needed:" - f" {total_needed}" - ) + if not total_provided >= total_needed: + raise TransactionError( + f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" + ) # verify that the amount of the proofs is not larger than the maximum allowed if settings.mint_max_peg_out and total_provided > settings.mint_max_peg_out: @@ -708,14 +783,15 @@ async def melt( ) # verify inputs and their spending conditions + # note, we do not verify outputs here, as they are only used for returning overpaid fees + # we should have used _verify_outputs here already (see above) await self.verify_inputs_and_outputs(proofs=proofs) # set proofs to pending to avoid race conditions - await self._set_proofs_pending(proofs) + await self._set_proofs_pending(proofs, quote_id=melt_quote.quote) try: # settle the transaction internally if there is a mint quote with the same payment request melt_quote = await self.melt_mint_settle_internally(melt_quote) - # quote not paid yet (not internal), pay it with the backend if not melt_quote.paid: logger.debug(f"Lightning: pay invoice {melt_quote.request}") @@ -742,12 +818,11 @@ async def melt( await self.crud.update_melt_quote(quote=melt_quote, db=self.db) # melt successful, invalidate proofs - await self._invalidate_proofs(proofs) + await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote) # prepare change to compensate wallet for overpaid fees return_promises: List[BlindedSignature] = [] if outputs: - assert outputs[0].id, "output id not set" return_promises = await self._generate_change_promises( input_amount=total_provided, output_amount=melt_quote.amount, @@ -787,22 +862,21 @@ async def split( Tuple[List[BlindSignature],List[BlindSignature]]: Promises on both sides of the split. """ logger.trace("split called") + # explicitly check that amount of inputs is equal to amount of outputs + # note: we check this again in verify_inputs_and_outputs but only if any + # outputs are provided at all. To make sure of that before calling + # verify_inputs_and_outputs, we check it here. + self._verify_equation_balanced(proofs, outputs) + # verify spending inputs, outputs, and spending conditions + await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) await self._set_proofs_pending(proofs) try: - # explicitly check that amount of inputs is equal to amount of outputs - # note: we check this again in verify_inputs_and_outputs but only if any - # outputs are provided at all. To make sure of that before calling - # verify_inputs_and_outputs, we check it here. - self._verify_equation_balanced(proofs, outputs) - # verify spending inputs, outputs, and spending conditions - await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) - # Mark proofs as used and prepare new promises async with get_db_connection(self.db) as conn: # we do this in a single db transaction + await self._invalidate_proofs(proofs=proofs, conn=conn) promises = await self._generate_promises(outputs, keyset, conn) - await self._invalidate_proofs(proofs, conn) except Exception as e: logger.trace(f"split failed: {e}") @@ -823,7 +897,7 @@ async def restore( for output in outputs: logger.trace(f"looking for promise: {output}") promise = await self.crud.get_promise( - B_=output.B_, db=self.db, conn=conn + b_=output.B_, db=self.db, conn=conn ) if promise is not None: # BEGIN backwards compatibility mints pre `m007_proofs_and_promises_store_id` @@ -865,15 +939,13 @@ async def _generate_promises( ] = [] for output in outputs: B_ = PublicKey(bytes.fromhex(output.B_), raw=True) - assert output.id, "output id not set" keyset = keyset or self.keysets[output.id] - - assert output.id in self.keysets, f"keyset {output.id} not found" - assert output.id in [ - keyset.id, - keyset.duplicate_keyset_id, - ], "keyset id does not match output id" - assert keyset.active, "keyset is not active" + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") keyset_id = output.id logger.trace(f"Generating promise with keyset {keyset_id}.") private_key_amount = keyset.private_keys[output.amount] @@ -890,8 +962,8 @@ async def _generate_promises( await self.crud.store_promise( amount=amount, id=keyset_id, - B_=B_.serialize().hex(), - C_=C_.serialize().hex(), + b_=B_.serialize().hex(), + c_=C_.serialize().hex(), e=e.serialize(), s=s.serialize(), db=self.db, @@ -911,7 +983,8 @@ async def _generate_promises( async def load_used_proofs(self) -> None: """Load all used proofs from database.""" - assert settings.mint_cache_secrets, "MINT_CACHE_SECRETS must be set to TRUE" + if not settings.mint_cache_secrets: + raise Exception("MINT_CACHE_SECRETS must be set to TRUE") logger.debug("Loading used proofs into memory") spent_proofs_list = await self.crud.get_spent_proofs(db=self.db) or [] logger.debug(f"Loaded {len(spent_proofs_list)} used proofs") @@ -950,12 +1023,15 @@ async def check_proofs_state(self, Ys: List[str]) -> List[ProofState]: ) return states - async def _set_proofs_pending(self, proofs: List[Proof]) -> None: + async def _set_proofs_pending( + self, proofs: List[Proof], quote_id: Optional[str] = None + ) -> None: """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to the list of pending proofs or removes them. Used as a mutex for proofs. Args: proofs (List[Proof]): Proofs to add to pending table. + quote_id (Optional[str]): Melt quote ID. If it is not set, we assume the pending tokens to be from a swap. Raises: Exception: At least one proof already in pending table. @@ -967,9 +1043,10 @@ async def _set_proofs_pending(self, proofs: List[Proof]) -> None: try: for p in proofs: await self.crud.set_proof_pending( - proof=p, db=self.db, conn=conn + proof=p, db=self.db, quote_id=quote_id, conn=conn ) - except Exception: + except Exception as e: + logger.error(f"Failed to set proofs pending: {e}") raise TransactionError("Failed to set proofs pending.") async def _unset_proofs_pending(self, proofs: List[Proof]) -> None: @@ -994,11 +1071,12 @@ async def _validate_proofs_pending( Raises: Exception: At least one of the proofs is in the pending table. """ - assert ( + if not ( len( await self.crud.get_proofs_pending( Ys=[p.Y for p in proofs], db=self.db, conn=conn ) ) == 0 - ), TransactionError("proofs are pending.") + ): + raise TransactionError("proofs are pending.") diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 2291288c..664b80c4 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1,4 +1,7 @@ -from ..core.base import Proof +import copy + +from ..core.base import MintKeyset, Proof +from ..core.crypto.keys import derive_keyset_id, derive_keyset_id_deprecated from ..core.db import Connection, Database, table_with_schema, timestamp_now from ..core.settings import settings @@ -20,10 +23,10 @@ async def m001_initial(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} ( amount {db.big_int} NOT NULL, - B_b TEXT NOT NULL, - C_b TEXT NOT NULL, + b_b TEXT NOT NULL, + c_b TEXT NOT NULL, - UNIQUE (B_b) + UNIQUE (b_b) ); """ @@ -33,7 +36,7 @@ async def m001_initial(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( amount {db.big_int} NOT NULL, - C TEXT NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, UNIQUE (secret) @@ -129,7 +132,7 @@ async def m003_mint_keysets(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} ( id TEXT NOT NULL, - amount INTEGER NOT NULL, + amount {db.big_int} NOT NULL, pubkey TEXT NOT NULL, UNIQUE (id, pubkey) @@ -157,8 +160,8 @@ async def m005_pending_proofs_table(db: Database) -> None: await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, UNIQUE (secret) @@ -283,7 +286,7 @@ async def m011_add_quote_tables(db: Database): request TEXT NOT NULL, checking_id TEXT NOT NULL, unit TEXT NOT NULL, - amount INTEGER NOT NULL, + amount {db.big_int} NOT NULL, paid BOOL NOT NULL, issued BOOL NOT NULL, created_time TIMESTAMP, @@ -303,12 +306,12 @@ async def m011_add_quote_tables(db: Database): request TEXT NOT NULL, checking_id TEXT NOT NULL, unit TEXT NOT NULL, - amount INTEGER NOT NULL, - fee_reserve INTEGER, + amount {db.big_int} NOT NULL, + fee_reserve {db.big_int}, paid BOOL NOT NULL, created_time TIMESTAMP, paid_time TIMESTAMP, - fee_paid INTEGER, + fee_paid {db.big_int}, proof TEXT, UNIQUE (quote) @@ -440,11 +443,11 @@ async def m014_proofs_add_Y_column(db: Database): await drop_balance_views(db, conn) await conn.execute( - f"ALTER TABLE {table_with_schema(db, 'proofs_used')} ADD COLUMN Y TEXT" + f"ALTER TABLE {table_with_schema(db, 'proofs_used')} ADD COLUMN y TEXT" ) for proof in proofs_used: await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_used')} SET Y = '{proof.Y}'" + f"UPDATE {table_with_schema(db, 'proofs_used')} SET y = '{proof.Y}'" f" WHERE secret = '{proof.secret}'" ) # Copy proofs_used to proofs_used_old and create a new table proofs_used @@ -461,11 +464,11 @@ async def m014_proofs_add_Y_column(db: Database): await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, id TEXT, - Y TEXT, + y TEXT, created TIMESTAMP, witness TEXT, @@ -475,19 +478,19 @@ async def m014_proofs_add_Y_column(db: Database): """ ) await conn.execute( - f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, C, " - "secret, id, Y, created, witness) SELECT amount, C, secret, id, Y," + f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, c, " + "secret, id, y, created, witness) SELECT amount, c, secret, id, y," f" created, witness FROM {table_with_schema(db, 'proofs_used_old')}" ) await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_used_old')}") - # add column Y to proofs_pending + # add column y to proofs_pending await conn.execute( - f"ALTER TABLE {table_with_schema(db, 'proofs_pending')} ADD COLUMN Y TEXT" + f"ALTER TABLE {table_with_schema(db, 'proofs_pending')} ADD COLUMN y TEXT" ) for proof in proofs_pending: await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_pending')} SET Y = '{proof.Y}'" + f"UPDATE {table_with_schema(db, 'proofs_pending')} SET y = '{proof.Y}'" f" WHERE secret = '{proof.secret}'" ) @@ -507,10 +510,10 @@ async def m014_proofs_add_Y_column(db: Database): await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, - Y TEXT, + y TEXT, id TEXT, created TIMESTAMP, @@ -520,8 +523,8 @@ async def m014_proofs_add_Y_column(db: Database): """ ) await conn.execute( - f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, C, " - "secret, Y, id, created) SELECT amount, C, secret, Y, id, created" + f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, c, " + "secret, y, id, created) SELECT amount, c, secret, y, id, created" f" FROM {table_with_schema(db, 'proofs_pending_old')}" ) @@ -531,7 +534,7 @@ async def m014_proofs_add_Y_column(db: Database): await create_balance_views(db, conn) -async def m015_add_index_Y_to_proofs_used(db: Database): +async def m015_add_index_Y_to_proofs_used_and_pending(db: Database): # create index on proofs_used table for Y async with db.connect() as conn: await conn.execute( @@ -540,6 +543,12 @@ async def m015_add_index_Y_to_proofs_used(db: Database): f" {table_with_schema(db, 'proofs_used')} (Y)" ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS" + " proofs_pending_Y_idx ON" + f" {table_with_schema(db, 'proofs_pending')} (Y)" + ) + async def m016_recompute_Y_with_new_h2c(db: Database): # get all proofs_used and proofs_pending from the database and compute Y for each of them @@ -570,12 +579,12 @@ async def m016_recompute_Y_with_new_h2c(db: Database): f"('{y}', '{secret}')" for y, secret in proofs_used_data ) await conn.execute( - f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + f"INSERT INTO tmp_proofs_used (y, secret) VALUES {values_placeholder}", ) await conn.execute( f""" UPDATE {table_with_schema(db, 'proofs_used')} - SET Y = tmp_proofs_used.Y + SET y = tmp_proofs_used.y FROM tmp_proofs_used WHERE {table_with_schema(db, 'proofs_used')}.secret = tmp_proofs_used.secret """ @@ -590,12 +599,12 @@ async def m016_recompute_Y_with_new_h2c(db: Database): f"('{y}', '{secret}')" for y, secret in proofs_pending_data ) await conn.execute( - f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + f"INSERT INTO tmp_proofs_used (y, secret) VALUES {values_placeholder}", ) await conn.execute( f""" UPDATE {table_with_schema(db, 'proofs_pending')} - SET Y = tmp_proofs_pending.Y + SET y = tmp_proofs_pending.y FROM tmp_proofs_pending WHERE {table_with_schema(db, 'proofs_pending')}.secret = tmp_proofs_pending.secret """ @@ -606,3 +615,151 @@ async def m016_recompute_Y_with_new_h2c(db: Database): await conn.execute("DROP TABLE tmp_proofs_used") if len(proofs_pending_data): await conn.execute("DROP TABLE tmp_proofs_pending") + + +async def m017_foreign_keys_proof_tables(db: Database): + """ + Create a foreign key relationship between the keyset id in the proof tables and the keyset table. + + Create a foreign key relationship between the keyset id in the promises table and the keyset table. + + Create a foreign key relationship between the quote id in the melt_quotes + and the proofs_used and proofs_pending tables. + + NOTE: We do not use ALTER TABLE directly to add the new column with a foreign key relation because SQLIte does not support it. + """ + + async with db.connect() as conn: + # drop the balance views first + await drop_balance_views(db, conn) + + # add foreign key constraints to proofs_used table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + c TEXT NOT NULL, + secret TEXT NOT NULL, + y TEXT, + witness TEXT, + created TIMESTAMP, + melt_quote TEXT, + + FOREIGN KEY (melt_quote) REFERENCES {table_with_schema(db, 'melt_quotes')}(quote), + + UNIQUE (y) + ); + """ + ) + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'proofs_used_new')} (amount, id, c, secret, y, witness, created) SELECT amount, id, c, secret, y, witness, created FROM {table_with_schema(db, 'proofs_used')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_used')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'proofs_used_new')} RENAME TO {table_with_schema(db, 'proofs_used')}" + ) + + # add foreign key constraints to proofs_pending table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + c TEXT NOT NULL, + secret TEXT NOT NULL, + y TEXT, + witness TEXT, + created TIMESTAMP, + melt_quote TEXT, + + FOREIGN KEY (melt_quote) REFERENCES {table_with_schema(db, 'melt_quotes')}(quote), + + UNIQUE (y) + ); + """ + ) + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'proofs_pending_new')} (amount, id, c, secret, y, created) SELECT amount, id, c, secret, y, created FROM {table_with_schema(db, 'proofs_pending')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_pending')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'proofs_pending_new')} RENAME TO {table_with_schema(db, 'proofs_pending')}" + ) + + # add foreign key constraints to promises table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + b_ TEXT NOT NULL, + c_ TEXT NOT NULL, + dleq_e TEXT, + dleq_s TEXT, + created TIMESTAMP, + mint_quote TEXT, + swap_id TEXT, + + FOREIGN KEY (mint_quote) REFERENCES {table_with_schema(db, 'mint_quotes')}(quote), + + UNIQUE (b_) + ); + """ + ) + + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'promises_new')} (amount, id, b_, c_, dleq_e, dleq_s, created) SELECT amount, id, b_b, c_b, e, s, created FROM {table_with_schema(db, 'promises')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'promises')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'promises_new')} RENAME TO {table_with_schema(db, 'promises')}" + ) + + # recreate the balance views + await create_balance_views(db, conn) + + # recreate indices + await m015_add_index_Y_to_proofs_used_and_pending(db) + + +async def m018_duplicate_deprecated_keyset_ids(db: Database): + async with db.connect() as conn: + rows = await conn.fetchall( # type: ignore + f""" + SELECT * from {table_with_schema(db, 'keysets')} + """, + ) + keysets = [MintKeyset(**row) for row in rows] + duplicated_keysets: list[MintKeyset] = [] + for keyset in keysets: + keyset_copy = copy.copy(keyset) + if not keyset_copy.public_keys: + raise Exception(f"keyset {keyset_copy.id} has no public keys") + if keyset.version_tuple < (0, 15): + keyset_copy.id = derive_keyset_id(keyset_copy.public_keys) + else: + keyset_copy.id = derive_keyset_id_deprecated(keyset_copy.public_keys) + duplicated_keysets.append(keyset_copy) + + for keyset in duplicated_keysets: + await conn.execute( + f""" + INSERT INTO {table_with_schema(db, 'keysets')} + (id, derivation_path, valid_from, valid_to, first_seen, active, version, seed, unit, encrypted_seed, seed_encryption_method) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + keyset.id, + keyset.derivation_path, + keyset.valid_from, + keyset.valid_to, + keyset.first_seen, + keyset.active, + keyset.version, + keyset.seed, + keyset.unit.name, + keyset.encrypted_seed, + keyset.seed_encryption_method, + ), + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index cd7b6cf8..c055bf6f 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -25,7 +25,7 @@ PostSplitRequest, PostSplitResponse, ) -from ..core.errors import CashuError +from ..core.errors import KeysetNotFoundError from ..core.settings import settings from ..mint.startup import ledger from .limit import limiter @@ -142,7 +142,7 @@ async def keyset_keys(keyset_id: str) -> KeysResponse: keyset = ledger.keysets.get(keyset_id) if keyset is None: - raise CashuError(code=0, detail="keyset not found") + raise KeysetNotFoundError(keyset_id) keyset_for_response = KeysResponseKeyset( id=keyset.id, diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index 4a970c4f..5e5bdd50 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -4,6 +4,7 @@ from loguru import logger from ..core.base import ( + BlindedMessage, BlindedSignature, CheckFeesRequest_deprecated, CheckFeesResponse_deprecated, @@ -51,7 +52,7 @@ async def info() -> GetInfoResponse_deprecated: description=settings.mint_info_description, description_long=settings.mint_info_description_long, contact=settings.mint_info_contact, - nuts=settings.mint_info_nuts, + nuts=["NUT-07", "NUT-08", "NUT-09"], motd=settings.mint_info_motd, parameter={ "max_peg_in": settings.mint_max_peg_in, @@ -177,10 +178,10 @@ async def mint_deprecated( # BEGIN BACKWARDS COMPATIBILITY < 0.15 # Mint expects "id" in outputs to know which keyset to use to sign them. - for output in payload.outputs: - if not output.id: - # use the deprecated version of the current keyset - output.id = ledger.keyset.duplicate_keyset_id + outputs: list[BlindedMessage] = [ + BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + for o in payload.outputs + ] # END BACKWARDS COMPATIBILITY < 0.15 # BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash @@ -189,7 +190,7 @@ async def mint_deprecated( assert hash, "hash must be set." # END: backwards compatibility < 0.12 - promises = await ledger.mint(outputs=payload.outputs, quote_id=hash) + promises = await ledger.mint(outputs=outputs, quote_id=hash) blinded_signatures = PostMintResponse_deprecated(promises=promises) logger.trace(f"< POST /mint: {blinded_signatures}") @@ -221,15 +222,18 @@ async def melt_deprecated( logger.trace(f"> POST /melt: {payload}") # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs if payload.outputs: - for output in payload.outputs: - if not output.id: - output.id = ledger.keyset.id + outputs: list[BlindedMessage] = [ + BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + for o in payload.outputs + ] + else: + outputs = [] # END BACKWARDS COMPATIBILITY < 0.14 quote = await ledger.melt_quote( PostMeltQuoteRequest(request=payload.pr, unit="sat") ) preimage, change_promises = await ledger.melt( - proofs=payload.proofs, quote=quote.quote, outputs=payload.outputs + proofs=payload.proofs, quote=quote.quote, outputs=outputs ) resp = PostMeltResponse_deprecated( paid=True, preimage=preimage, change=change_promises @@ -290,12 +294,12 @@ async def split_deprecated( logger.trace(f"> POST /split: {payload}") assert payload.outputs, Exception("no outputs provided.") # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs - if payload.outputs: - for output in payload.outputs: - if not output.id: - output.id = ledger.keyset.id + outputs: list[BlindedMessage] = [ + BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + for o in payload.outputs + ] # END BACKWARDS COMPATIBILITY < 0.14 - promises = await ledger.split(proofs=payload.proofs, outputs=payload.outputs) + promises = await ledger.split(proofs=payload.proofs, outputs=outputs) if payload.amount: # BEGIN backwards compatibility < 0.13 diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 94d16e61..a158cabc 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -16,6 +16,11 @@ from ..mint.crud import LedgerCrudSqlite from ..mint.ledger import Ledger +# kill the program if python runs in non-__debug__ mode +# which could lead to asserts not being executed for optimized code +if not __debug__: + raise Exception("Nutshell cannot run in non-debug mode.") + logger.debug("Enviroment Settings:") for key, value in settings.dict().items(): if key in [ @@ -23,6 +28,7 @@ "mint_seed_decryption_key", "nostr_private_key", "mint_lnbits_key", + "mint_blink_key", "mint_strike_key", "mint_lnd_rest_macaroon", "mint_lnd_rest_admin_macaroon", @@ -79,29 +85,6 @@ async def rotate_keys(n_seconds=60): async def start_mint_init(): await migrate_databases(ledger.db, migrations) - if settings.mint_cache_secrets: - await ledger.load_used_proofs() - await ledger.init_keysets() - - for derivation_path in settings.mint_derivation_path_list: - await ledger.activate_keyset(derivation_path=derivation_path) - - for method in ledger.backends: - for unit in ledger.backends[method]: - logger.info( - f"Using {ledger.backends[method][unit].__class__.__name__} backend for" - f" method: '{method.name}' and unit: '{unit.name}'" - ) - status = await ledger.backends[method][unit].status() - if status.error_message: - logger.warning( - "The backend for" - f" {ledger.backends[method][unit].__class__.__name__} isn't" - f" working properly: '{status.error_message}'", - RuntimeWarning, - ) - logger.info(f"Backend balance: {status.balance} {unit.name}") - - logger.info(f"Data dir: {settings.cashu_dir}") + await ledger.startup_ledger() logger.info("Mint started.") # asyncio.create_task(rotate_keys()) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index de38dca3..c11fbe66 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -143,7 +143,7 @@ async def _check_outputs_issued_before(self, outputs: List[BlindedMessage]): async with self.db.connect() as conn: for output in outputs: promise = await self.crud.get_promise( - B_=output.B_, db=self.db, conn=conn + b_=output.B_, db=self.db, conn=conn ) result.append(False if promise is None else True) return result diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 6332d5da..4f602baf 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -3,24 +3,26 @@ import asyncio import os import time -from datetime import datetime +from datetime import datetime, timezone from functools import wraps from itertools import groupby, islice from operator import itemgetter from os import listdir from os.path import isdir, join +from typing import Optional import click from click import Context from loguru import logger -from ...core.base import TokenV3, Unit +from ...core.base import Invoice, TokenV3, Unit from ...core.helpers import sum_proofs from ...core.logging import configure_logger from ...core.settings import settings from ...nostr.client.client import NostrClient from ...tor.tor import TorProxy from ...wallet.crud import ( + get_lightning_invoice, get_lightning_invoices, get_reserved_proofs, get_seed_and_mnemonic, @@ -124,7 +126,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): env_path = settings.env_file else: error_str += ( - "Ceate a new Cashu config file here:" + "Create a new Cashu config file here:" f" {os.path.join(settings.cashu_dir, '.env')}" ) env_path = os.path.join(settings.cashu_dir, ".env") @@ -158,7 +160,6 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): assert wallet, "Wallet not found." ctx.obj["WALLET"] = wallet - # await init_wallet(ctx.obj["WALLET"], load_proofs=False) # only if a command is one of a subset that needs to specify a mint host # if a mint host is already specified as an argument `host`, use it @@ -166,7 +167,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): return # ------ MULTIUNIT ------- : Select a unit ctx.obj["WALLET"] = await get_unit_wallet(ctx) - # ------ MUTLIMINT ------- : Select a wallet + # ------ MULTIMINT ------- : Select a wallet # else: we ask the user to select one ctx.obj["WALLET"] = await get_mint_wallet( ctx @@ -247,7 +248,7 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo await wallet.load_mint() await print_balance(ctx) amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) - print(f"Requesting invoice for {wallet.unit.str(amount)} {wallet.unit}.") + print(f"Requesting invoice for {wallet.unit.str(amount)}.") # in case the user wants a specific split, we create a list of amounts optional_split = None if split: @@ -637,8 +638,8 @@ async def pending(ctx: Context, legacy, number: int, offset: int): mint = [t.mint for t in tokenObj.token][0] # token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) assert grouped_proofs[0].time_reserved - reserved_date = datetime.utcfromtimestamp( - int(grouped_proofs[0].time_reserved) + reserved_date = datetime.fromtimestamp( + int(grouped_proofs[0].time_reserved), timezone.utc ).strftime("%Y-%m-%d %H:%M:%S") print( f"#{i} Amount:" @@ -692,39 +693,120 @@ async def locks(ctx): return True -@cli.command("invoices", help="List of all pending invoices.") +@cli.command("invoices", help="List of all invoices.") +@click.option( + "-op", + "--only-paid", + "paid", + default=False, + is_flag=True, + help="Show only paid invoices.", + type=bool, +) +@click.option( + "-ou", + "--only-unpaid", + "unpaid", + default=False, + is_flag=True, + help="Show only unpaid invoices.", + type=bool, +) +@click.option( + "-p", + "--pending", + "pending", + default=False, + is_flag=True, + help="Show all pending invoices", + type=bool, +) +@click.option( + "--mint", + "-m", + is_flag=True, + default=False, + help="Try to mint pending invoices", +) @click.pass_context @coro -async def invoices(ctx): +async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool): wallet: Wallet = ctx.obj["WALLET"] - invoices = await get_lightning_invoices(db=wallet.db) - if len(invoices): - print("") - print("--------------------------\n") - for invoice in invoices: - print(f"Paid: {invoice.paid}") - print(f"Incoming: {invoice.amount > 0}") - print(f"Amount: {abs(invoice.amount)}") - if invoice.id: - print(f"ID: {invoice.id}") - if invoice.preimage: - print(f"Preimage: {invoice.preimage}") - if invoice.time_created: - d = datetime.utcfromtimestamp( - int(float(invoice.time_created)) - ).strftime("%Y-%m-%d %H:%M:%S") - print(f"Created: {d}") - if invoice.time_paid: - d = datetime.utcfromtimestamp(int(float(invoice.time_paid))).strftime( - "%Y-%m-%d %H:%M:%S" - ) - print(f"Paid: {d}") - print("") - print(f"Payment request: {invoice.bolt11}") - print("") - print("--------------------------\n") - else: + + if paid and unpaid: + print("You should only choose one option: either --only-paid or --only-unpaid") + return + + if mint: + await wallet.load_mint() + + paid_arg = None + if unpaid: + paid_arg = False + elif paid: + paid_arg = True + + invoices = await get_lightning_invoices( + db=wallet.db, + paid=paid_arg, + pending=pending or None, + ) + + if len(invoices) == 0: print("No invoices found.") + return + + async def _try_to_mint_pending_invoice(amount: int, id: str) -> Optional[Invoice]: + try: + await wallet.mint(amount, id) + return await get_lightning_invoice(db=wallet.db, id=id) + except Exception as e: + logger.error(f"Could not mint pending invoice [{id}]: {e}") + return None + + def _print_invoice_info(invoice: Invoice): + print("\n--------------------------\n") + print(f"Amount: {abs(invoice.amount)}") + print(f"ID: {invoice.id}") + print(f"Paid: {invoice.paid}") + print(f"Incoming: {invoice.amount > 0}") + + if invoice.preimage: + print(f"Preimage: {invoice.preimage}") + if invoice.time_created: + d = datetime.fromtimestamp( + int(float(invoice.time_created)), timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S") + print(f"Created at: {d}") + if invoice.time_paid: + d = datetime.fromtimestamp( + (int(float(invoice.time_paid))), timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S") + print(f"Paid at: {d}") + print(f"\nPayment request: {invoice.bolt11}") + + invoices_printed_count = 0 + for invoice in invoices: + is_pending_invoice = invoice.out is False and invoice.paid is False + if is_pending_invoice and mint: + # Tries to mint pending invoice + updated_invoice = await _try_to_mint_pending_invoice( + invoice.amount, invoice.id + ) + # If the mint ran successfully and we are querying for pending or unpaid invoices, do not print it + if pending or unpaid: + continue + # Otherwise, print the invoice with updated values + if updated_invoice: + invoice = updated_invoice + + _print_invoice_info(invoice) + invoices_printed_count += 1 + + if invoices_printed_count == 0: + print("No invoices found.") + else: + print("\n--------------------------\n") @cli.command("wallets", help="List of all available wallets.") diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index 0e9b9b45..5e658747 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -67,10 +67,12 @@ async def get_reserved_proofs( db: Database, conn: Optional[Connection] = None, ) -> List[Proof]: - rows = await (conn or db).fetchall(""" + rows = await (conn or db).fetchall( + """ SELECT * from proofs WHERE reserved - """) + """ + ) return [Proof.from_dict(dict(r)) for r in rows] @@ -279,15 +281,22 @@ async def get_lightning_invoice( async def get_lightning_invoices( db: Database, paid: Optional[bool] = None, + pending: Optional[bool] = None, conn: Optional[Connection] = None, ) -> List[Invoice]: clauses: List[Any] = [] values: List[Any] = [] - if paid is not None: + if paid is not None and not pending: clauses.append("paid = ?") values.append(paid) + if pending: + clauses.append("paid = ?") + values.append(False) + clauses.append("out = ?") + values.append(False) + where = "" if clauses: where = f"WHERE {' AND '.join(clauses)}" diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 813258e8..21e0e158 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -2,17 +2,20 @@ async def m000_create_migrations_table(conn: Connection): - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS dbversions ( db TEXT PRIMARY KEY, version INT NOT NULL ) - """) + """ + ) async def m001_initial(db: Database): async with db.connect() as conn: - await conn.execute(f""" + await conn.execute( + f""" CREATE TABLE IF NOT EXISTS proofs ( amount {db.big_int} NOT NULL, C TEXT NOT NULL, @@ -21,9 +24,11 @@ async def m001_initial(db: Database): UNIQUE (secret) ); - """) + """ + ) - await conn.execute(f""" + await conn.execute( + f""" CREATE TABLE IF NOT EXISTS proofs_used ( amount {db.big_int} NOT NULL, C TEXT NOT NULL, @@ -32,25 +37,30 @@ async def m001_initial(db: Database): UNIQUE (secret) ); - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE VIEW IF NOT EXISTS balance AS SELECT COALESCE(SUM(s), 0) AS balance FROM ( SELECT SUM(amount) AS s FROM proofs WHERE amount > 0 ); - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE VIEW IF NOT EXISTS balance_used AS SELECT COALESCE(SUM(s), 0) AS used FROM ( SELECT SUM(amount) AS s FROM proofs_used WHERE amount > 0 ); - """) + """ + ) async def m002_add_proofs_reserved(db: Database): @@ -96,7 +106,8 @@ async def m005_wallet_keysets(db: Database): Stores mint keysets from different mints and epochs. """ async with db.connect() as conn: - await conn.execute(f""" + await conn.execute( + f""" CREATE TABLE IF NOT EXISTS keysets ( id TEXT, mint_url TEXT, @@ -108,7 +119,8 @@ async def m005_wallet_keysets(db: Database): UNIQUE (id, mint_url) ); - """) + """ + ) await conn.execute("ALTER TABLE proofs ADD COLUMN id TEXT") await conn.execute("ALTER TABLE proofs_used ADD COLUMN id TEXT") @@ -119,7 +131,8 @@ async def m006_invoices(db: Database): Stores Lightning invoices. """ async with db.connect() as conn: - await conn.execute(f""" + await conn.execute( + f""" CREATE TABLE IF NOT EXISTS invoices ( amount INTEGER NOT NULL, pr TEXT NOT NULL, @@ -132,7 +145,8 @@ async def m006_invoices(db: Database): UNIQUE (hash) ); - """) + """ + ) async def m007_nostr(db: Database): @@ -140,12 +154,14 @@ async def m007_nostr(db: Database): Stores timestamps of nostr operations. """ async with db.connect() as conn: - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS nostr ( type TEXT NOT NULL, last TIMESTAMP DEFAULT NULL ) - """) + """ + ) await conn.execute( """ INSERT INTO nostr @@ -172,14 +188,16 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database): await conn.execute("ALTER TABLE keysets ADD COLUMN counter INTEGER DEFAULT 0") await conn.execute("ALTER TABLE proofs ADD COLUMN derivation_path TEXT") await conn.execute("ALTER TABLE proofs_used ADD COLUMN derivation_path TEXT") - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS seed ( seed TEXT NOT NULL, mnemonic TEXT NOT NULL, UNIQUE (seed, mnemonic) ); - """) + """ + ) # await conn.execute("INSERT INTO secret_derivation (counter) VALUES (0)") diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index aff057c0..e5d5a7fe 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -133,9 +133,12 @@ async def add_witnesses_to_outputs( return outputs # if any of the proofs provided require SIG_ALL, we must provide it - if any([ - P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL for p in proofs - ]): + if any( + [ + P2PKSecret.deserialize(p.secret).sigflag == SigFlags.SIG_ALL + for p in proofs + ] + ): outputs = await self.add_p2pk_witnesses_to_outputs(outputs) return outputs @@ -181,9 +184,9 @@ async def add_witnesses_to_proofs(self, proofs: List[Proof]) -> List[Proof]: return proofs logger.debug("Spending conditions detected.") # P2PK signatures - if all([ - Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs - ]): + if all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK.value for p in proofs] + ): logger.debug("P2PK redemption detected.") proofs = await self.add_p2pk_witnesses_to_proofs(proofs) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index d4a5479c..58ed544a 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -444,14 +444,14 @@ async def _get_info(self) -> GetInfoResponse: @async_set_httpx_client @async_ensure_mint_loaded - async def mint_quote(self, amount) -> Invoice: + async def mint_quote(self, amount) -> PostMintQuoteResponse: """Requests a mint quote from the server and returns a payment request. Args: amount (int): Amount of tokens to mint Returns: - Invoice: Lightning invoice + PostMintQuoteResponse: Mint Quote Response Raises: Exception: If the mint request fails @@ -469,16 +469,7 @@ async def mint_quote(self, amount) -> Invoice: # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) return_dict = resp.json() - mint_response = PostMintQuoteResponse.parse_obj(return_dict) - decoded_invoice = bolt11.decode(mint_response.request) - return Invoice( - amount=amount, - bolt11=mint_response.request, - payment_hash=decoded_invoice.payment_hash, - id=mint_response.quote, - out=False, - time_created=int(time.time()), - ) + return PostMintQuoteResponse.parse_obj(return_dict) @async_set_httpx_client @async_ensure_mint_loaded @@ -823,9 +814,18 @@ async def request_mint(self, amount: int) -> Invoice: amount (int): Amount for Lightning invoice in satoshis Returns: - Invoice: Lightning invoice + PostMintQuoteResponse: Mint Quote Response """ - invoice = await super().mint_quote(amount) + mint_quote_response = await super().mint_quote(amount) + decoded_invoice = bolt11.decode(mint_quote_response.request) + invoice = Invoice( + amount=amount, + bolt11=mint_quote_response.request, + payment_hash=decoded_invoice.payment_hash, + id=mint_quote_response.quote, + out=False, + time_created=int(time.time()), + ) await store_lightning_invoice(db=self.db, invoice=invoice) return invoice @@ -1014,7 +1014,7 @@ async def pay_lightning( n_change_outputs * [1], change_secrets, change_rs ) - # store the melt_id in proofs + # store the melt_id in proofs db async with self.db.connect() as conn: for p in proofs: p.melt_id = quote_id @@ -1488,7 +1488,7 @@ async def invalidate( Args: proofs (List[Proof]): Which proofs to delete - check_spendable (bool, optional): Asks the mint to check whether proofs are already spent before deleting them. Defaults to True. + check_spendable (bool, optional): Asks the mint to check whether proofs are already spent before deleting them. Defaults to False. Returns: List[Proof]: List of proofs that are still spendable. @@ -1617,28 +1617,25 @@ async def balance_per_minturl( balances_return[key]["unit"] = unit.name return dict(sorted(balances_return.items(), key=lambda item: item[0])) # type: ignore - async def restore_wallet_from_mnemonic( - self, mnemonic: Optional[str], to: int = 2, batch: int = 25 + async def restore_tokens_for_keyset( + self, keyset_id: str, to: int = 2, batch: int = 25 ) -> None: """ - Restores the wallet from a mnemonic. + Restores tokens for a given keyset_id. Args: - mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. + keyset_id (str): The keyset_id to restore tokens for. to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. """ - await self._init_private_key(mnemonic) - await self.load_mint() - print("Restoring tokens...") stop_counter = 0 # we get the current secret counter and restore from there on spendable_proofs = [] counter_before = await bump_secret_derivation( - db=self.db, keyset_id=self.keyset_id, by=0 + db=self.db, keyset_id=keyset_id, by=0 ) if counter_before != 0: - print("This wallet has already been used. Restoring from it's last state.") + print("Keyset has already been used. Restoring from it's last state.") i = counter_before n_last_restored_proofs = 0 while stop_counter < to: @@ -1659,16 +1656,34 @@ async def restore_wallet_from_mnemonic( logger.debug(f"Reverting secret counter by {revert_counter_by}") before = await bump_secret_derivation( db=self.db, - keyset_id=self.keyset_id, + keyset_id=keyset_id, by=-revert_counter_by, ) logger.debug( f"Secret counter reverted from {before} to {before - revert_counter_by}" ) if n_last_restored_proofs == 0: - print("No tokens restored.") + print("No tokens restored for keyset.") return + async def restore_wallet_from_mnemonic( + self, mnemonic: Optional[str], to: int = 2, batch: int = 25 + ) -> None: + """ + Restores the wallet from a mnemonic. + + Args: + mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. + to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. + batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. + """ + await self._init_private_key(mnemonic) + await self.load_mint() + print("Restoring tokens...") + keyset_ids = self.mint_keyset_ids + for keyset_id in keyset_ids: + await self.restore_tokens_for_keyset(keyset_id, to, batch) + async def restore_promises_from_to( self, from_counter: int, to_counter: int ) -> List[Proof]: diff --git a/cashu/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py index db5e927a..568538da 100644 --- a/cashu/wallet/wallet_deprecated.py +++ b/cashu/wallet/wallet_deprecated.py @@ -8,6 +8,7 @@ from ..core.base import ( BlindedMessage, + BlindedMessage_Deprecated, BlindedSignature, CheckFeesRequest_deprecated, CheckFeesResponse_deprecated, @@ -16,10 +17,10 @@ GetInfoResponse, GetInfoResponse_deprecated, GetMintResponse_deprecated, - Invoice, KeysetsResponse_deprecated, PostMeltRequest_deprecated, PostMeltResponse_deprecated, + PostMintQuoteResponse, PostMintRequest_deprecated, PostMintResponse_deprecated, PostRestoreResponse, @@ -159,9 +160,7 @@ async def _get_keys_deprecated(self, url: str) -> WalletKeyset: int(amt): PublicKey(bytes.fromhex(val), raw=True) for amt, val in keys.items() } - keyset = WalletKeyset( - unit="sat", public_keys=keyset_keys, mint_url=url, use_deprecated_id=True - ) + keyset = WalletKeyset(unit="sat", public_keys=keyset_keys, mint_url=url) return keyset @async_set_httpx_client @@ -198,7 +197,6 @@ async def _get_keys_of_keyset_deprecated( id=keyset_id, public_keys=keyset_keys, mint_url=url, - use_deprecated_id=True, ) return keyset @@ -228,7 +226,7 @@ async def _get_keyset_ids_deprecated(self, url: str) -> List[str]: @async_set_httpx_client @async_ensure_mint_loaded_deprecated - async def request_mint_deprecated(self, amount) -> Invoice: + async def request_mint_deprecated(self, amount) -> PostMintQuoteResponse: """Requests a mint from the server and returns Lightning invoice. Args: @@ -246,12 +244,11 @@ async def request_mint_deprecated(self, amount) -> Invoice: return_dict = resp.json() mint_response = GetMintResponse_deprecated.parse_obj(return_dict) decoded_invoice = bolt11.decode(mint_response.pr) - return Invoice( - amount=amount, - bolt11=mint_response.pr, - id=mint_response.hash, - payment_hash=decoded_invoice.payment_hash, - out=False, + return PostMintQuoteResponse( + quote=mint_response.hash, + request=mint_response.pr, + paid=False, + expiry=decoded_invoice.date + (decoded_invoice.expiry or 0), ) @async_set_httpx_client @@ -271,7 +268,8 @@ async def mint_deprecated( Raises: Exception: If the minting fails """ - outputs_payload = PostMintRequest_deprecated(outputs=outputs) + outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs] + outputs_payload = PostMintRequest_deprecated(outputs=outputs_deprecated) def _mintrequest_include_fields(outputs: List[BlindedMessage]): """strips away fields from the model that aren't necessary for the /mint""" @@ -307,7 +305,14 @@ async def pay_lightning_deprecated( Accepts proofs and a lightning invoice to pay in exchange. """ logger.warning("Using deprecated API call: POST /melt") - payload = PostMeltRequest_deprecated(proofs=proofs, pr=invoice, outputs=outputs) + outputs_deprecated = ( + [BlindedMessage_Deprecated(**o.dict()) for o in outputs] + if outputs + else None + ) + payload = PostMeltRequest_deprecated( + proofs=proofs, pr=invoice, outputs=outputs_deprecated + ) def _meltrequest_include_fields(proofs: List[Proof]): """strips away fields from the model that aren't necessary for the /melt""" @@ -336,7 +341,10 @@ async def split_deprecated( ) -> List[BlindedSignature]: """Consume proofs and create new promises based on amount split.""" logger.warning("Using deprecated API call: Calling split. POST /split") - split_payload = PostSplitRequest_Deprecated(proofs=proofs, outputs=outputs) + outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs] + split_payload = PostSplitRequest_Deprecated( + proofs=proofs, outputs=outputs_deprecated + ) # construct payload def _splitrequest_include_fields(proofs: List[Proof]): @@ -403,7 +411,8 @@ async def restore_promises_deprecated( Asks the mint to restore promises corresponding to outputs. """ logger.warning("Using deprecated API call: POST /restore") - payload = PostMintRequest_deprecated(outputs=outputs) + outputs_deprecated = [BlindedMessage_Deprecated(**o.dict()) for o in outputs] + payload = PostMintRequest_deprecated(outputs=outputs_deprecated) resp = await self.httpx.post(join(self.url, "/restore"), json=payload.dict()) self.raise_on_error(resp) response_dict = resp.json() diff --git a/pyproject.toml b/pyproject.toml index 4c02790c..d3e7d289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.15.2" +version = "0.15.3" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 501f1e1f..420b1673 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.15.2", + version="0.15.3", description="Ecash wallet and mint", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/conftest.py b/tests/conftest.py index af682cc1..e6f31cab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,30 +73,25 @@ def run(self, *args, **kwargs): self.server.run() -# # This fixture is used for tests that require API access to the mint -@pytest.fixture(autouse=True, scope="session") -def mint(): - config = uvicorn.Config( - "cashu.mint.app:app", - port=settings.mint_listen_port, - host=settings.mint_listen_host, - ) - - server = UvicornServer(config=config) - server.start() - time.sleep(1) - yield server - server.stop() - - # This fixture is used for all other tests @pytest_asyncio.fixture(scope="function") async def ledger(): - async def start_mint_init(ledger: Ledger): + async def start_mint_init(ledger: Ledger) -> Ledger: await migrate_databases(ledger.db, migrations_mint) - if settings.mint_cache_secrets: - await ledger.load_used_proofs() - await ledger.init_keysets() + # add a new keyset (with a new ID) which will be duplicated with a keyset with an + # old ID by mint migration m018_duplicate_deprecated_keyset_ids + # await ledger.activate_keyset(derivation_path=settings.mint_derivation_path, version="0.15.0") + # await migrations_mint.m018_duplicate_deprecated_keyset_ids(ledger.db) + + ledger = Ledger( + db=Database("mint", settings.mint_database), + seed=settings.mint_private_key, + derivation_path=settings.mint_derivation_path, + backends=backends, + crud=LedgerCrudSqlite(), + ) + await ledger.startup_ledger() + return ledger if not settings.mint_database.startswith("postgres"): # clear sqlite database @@ -122,6 +117,22 @@ async def start_mint_init(ledger: Ledger): backends=backends, crud=LedgerCrudSqlite(), ) - await start_mint_init(ledger) + ledger = await start_mint_init(ledger) yield ledger print("teardown") + + +# # This fixture is used for tests that require API access to the mint +@pytest.fixture(autouse=True, scope="session") +def mint(): + config = uvicorn.Config( + "cashu.mint.app:app", + port=settings.mint_listen_port, + host=settings.mint_listen_host, + ) + + server = UvicornServer(config=config) + server.start() + time.sleep(1) + yield server + server.stop() diff --git a/tests/helpers.py b/tests/helpers.py index 456ab21b..674952d9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -32,6 +32,7 @@ async def get_random_invoice_data(): is_deprecated_api_only = settings.debug_mint_only_deprecated is_github_actions = os.getenv("GITHUB_ACTIONS") == "true" is_postgres = settings.mint_database.startswith("postgres") +SLEEP_TIME = 1 if not is_github_actions else 2 docker_lightning_cli = [ "docker", @@ -156,31 +157,6 @@ def pay_onchain(address: str, sats: int) -> str: return run_cmd(cmd) -# def clean_database(settings): -# if DB_TYPE == POSTGRES: -# db_url = make_url(settings.lnbits_database_url) - -# conn = psycopg2.connect(settings.lnbits_database_url) -# conn.autocommit = True -# with conn.cursor() as cur: -# try: -# cur.execute("DROP DATABASE lnbits_test") -# except psycopg2.errors.InvalidCatalogName: -# pass -# cur.execute("CREATE DATABASE lnbits_test") - -# db_url.database = "lnbits_test" -# settings.lnbits_database_url = str(db_url) - -# core.db.__init__("database") - -# conn.close() -# else: -# # FIXME: do this once mock data is removed from test data folder -# # os.remove(settings.lnbits_data_folder + "/database.sqlite3") -# pass - - def pay_if_regtest(bolt11: str): if is_regtest: pay_real_invoice(bolt11) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 7a403a6b..5018e5cf 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,3 +1,4 @@ +from cashu.core.base import Proof from cashu.core.crypto.b_dhke import ( alice_verify_dleq, carol_verify_dleq, @@ -284,6 +285,10 @@ def test_dleq_carol_verify_from_bob(): ) A = a.pubkey assert A + assert ( + A.serialize().hex() + == "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ) secret_msg = "test_message" r = PrivateKey( privkey=bytes.fromhex( @@ -295,11 +300,42 @@ def test_dleq_carol_verify_from_bob(): C_, e, s = step2_bob(B_, a) assert alice_verify_dleq(B_, C_, e, s, A) C = step3_alice(C_, r, A) - # carol does not know B_ and C_, but she receives C and r from Alice assert carol_verify_dleq(secret_msg=secret_msg, C=C, r=r, e=e, s=s, A=A) +def test_dleq_carol_on_proof(): + A = PublicKey( + bytes.fromhex( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ), + raw=True, + ) + proof = Proof.parse_obj( + { + "amount": 1, + "id": "00882760bfa2eb41", + "secret": "daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9", + "C": "024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc", + "dleq": { + "e": "b31e58ac6527f34975ffab13e70a48b6d2b0d35abc4b03f0151f09ee1a9763d4", + "s": "8fbae004c59e754d71df67e392b6ae4e29293113ddc2ec86592a0431d16306d8", + "r": "a6d13fcd7a18442e6076f5e1e7c887ad5de40a019824bdfa9fe740d302e8d861", + }, + } + ) + assert proof.dleq + + assert carol_verify_dleq( + secret_msg=proof.secret, + r=PrivateKey(bytes.fromhex(proof.dleq.r), raw=True), + C=PublicKey(bytes.fromhex(proof.C), raw=True), + e=PrivateKey(bytes.fromhex(proof.dleq.e), raw=True), + s=PrivateKey(bytes.fromhex(proof.dleq.s), raw=True), + A=A, + ) + + # TESTS FOR DEPRECATED HASH TO CURVE diff --git a/tests/test_mint.py b/tests/test_mint.py index 534fc051..0a46b0f3 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -58,17 +58,6 @@ async def test_keysets(ledger: Ledger): assert ledger.keyset.id == "009a1f293253e41e" -@pytest.mark.asyncio -async def test_keysets_backwards_compatibility_pre_v0_15(ledger: Ledger): - """Backwards compatibility test for keysets pre v0.15.0 - We expect two instances of the same keyset but with different IDs. - First one is the new hex ID, second one is the old base64 ID. - """ - assert len(ledger.keysets) == 2 - assert list(ledger.keysets.keys()) == ["009a1f293253e41e", "eGnEWtdJ0PIM"] - assert ledger.keyset.id == "009a1f293253e41e" - - @pytest.mark.asyncio async def test_get_keyset(ledger: Ledger): keyset = ledger.get_keyset() @@ -136,55 +125,6 @@ async def test_generate_promises(ledger: Ledger): assert promises[0].dleq.e -@pytest.mark.asyncio -async def test_generate_promises_deprecated_keyset_id(ledger: Ledger): - blinded_messages_mock = [ - BlindedMessage( - amount=8, - B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", - id="eGnEWtdJ0PIM", - ) - ] - promises = await ledger._generate_promises(blinded_messages_mock) - assert ( - promises[0].C_ - == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" - ) - assert promises[0].amount == 8 - assert promises[0].id == "eGnEWtdJ0PIM" - - # DLEQ proof present - assert promises[0].dleq - assert promises[0].dleq.s - assert promises[0].dleq.e - - -@pytest.mark.asyncio -async def test_generate_promises_keyset_backwards_compatibility_pre_v0_15( - ledger: Ledger, -): - """Backwards compatibility test for keysets pre v0.15.0 - We want to generate promises using the old keyset ID. - We expect the promise to have the old base64 ID. - """ - blinded_messages_mock = [ - BlindedMessage( - amount=8, - B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239", - id="eGnEWtdJ0PIM", - ) - ] - promises = await ledger._generate_promises( - blinded_messages_mock, keyset=ledger.keysets["eGnEWtdJ0PIM"] - ) - assert ( - promises[0].C_ - == "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e" - ) - assert promises[0].amount == 8 - assert promises[0].id == "eGnEWtdJ0PIM" - - @pytest.mark.asyncio async def test_generate_change_promises(ledger: Ledger): # Example slightly adapted from NUT-08 because we want to ensure the dynamic change diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 3a7acbd3..5b105fcc 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -90,13 +90,6 @@ async def test_api_keysets(ledger: Ledger): "unit": "sat", "active": True, }, - # for backwards compatibility of the new keyset ID format, - # we also return the same keyset with the old base64 ID - { - "id": "eGnEWtdJ0PIM", - "unit": "sat", - "active": True, - }, ] } assert response.json() == expected @@ -132,17 +125,17 @@ async def test_api_keyset_keys(ledger: Ledger): reason="settings.debug_mint_only_deprecated is set", ) async def test_api_keyset_keys_old_keyset_id(ledger: Ledger): - response = httpx.get(f"{BASE_URL}/v1/keys/eGnEWtdJ0PIM") + response = httpx.get(f"{BASE_URL}/v1/keys/009a1f293253e41e") assert response.status_code == 200, f"{response.url} {response.status_code}" assert ledger.keyset.public_keys expected = { "keysets": [ { - "id": "eGnEWtdJ0PIM", + "id": "009a1f293253e41e", "unit": "sat", "keys": { str(k): v.serialize().hex() - for k, v in ledger.keysets["eGnEWtdJ0PIM"].public_keys.items() # type: ignore + for k, v in ledger.keysets["009a1f293253e41e"].public_keys.items() # type: ignore }, } ] diff --git a/tests/test_mint_api_deprecated.py b/tests/test_mint_api_deprecated.py index fc40589c..f59b9b84 100644 --- a/tests/test_mint_api_deprecated.py +++ b/tests/test_mint_api_deprecated.py @@ -9,7 +9,6 @@ PostRestoreResponse, Proof, ) -from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.crud import bump_secret_derivation from cashu.wallet.wallet import Wallet @@ -141,10 +140,7 @@ async def test_mint(ledger: Ledger, wallet: Wallet): assert len(result["promises"]) == 2 assert result["promises"][0]["amount"] == 32 assert result["promises"][1]["amount"] == 32 - if settings.debug_mint_only_deprecated: - assert result["promises"][0]["id"] == "eGnEWtdJ0PIM" - else: - assert result["promises"][0]["id"] == "009a1f293253e41e" + assert result["promises"][0]["id"] == "009a1f293253e41e" assert result["promises"][0]["dleq"] assert "e" in result["promises"][0]["dleq"] assert "s" in result["promises"][0]["dleq"] diff --git a/tests/test_mint_init.py b/tests/test_mint_init.py index 77f111b8..e7d23413 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -1,13 +1,27 @@ -from typing import List +import asyncio +from typing import List, Tuple +import bolt11 import pytest +import pytest_asyncio -from cashu.core.base import Proof +from cashu.core.base import MeltQuote, Proof, SpentState from cashu.core.crypto.aes import AESCipher from cashu.core.db import Database from cashu.core.settings import settings from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + cancel_invoice, + get_hold_invoice, + is_fake, + is_regtest, + pay_if_regtest, + settle_invoice, +) SEED = "TEST_PRIVATE_KEY" DERIVATION_PATH = "m/0'/0'/0'" @@ -30,32 +44,20 @@ def assert_amt(proofs: List[Proof], expected: int): assert [p.amount for p in proofs] == expected -@pytest.mark.asyncio -async def test_init_keysets_with_duplicates(ledger: Ledger): - ledger.keysets = {} - await ledger.init_keysets(duplicate_keysets=True) - assert len(ledger.keysets) == 2 - - -@pytest.mark.asyncio -async def test_init_keysets_with_duplicates_via_settings(ledger: Ledger): - ledger.keysets = {} - settings.mint_duplicate_keysets = True - await ledger.init_keysets() - assert len(ledger.keysets) == 2 - - -@pytest.mark.asyncio -async def test_init_keysets_without_duplicates(ledger: Ledger): - ledger.keysets = {} - await ledger.init_keysets(duplicate_keysets=False) - assert len(ledger.keysets) == 1 +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet_mint_api_deprecated", + name="wallet_mint_api_deprecated", + ) + await wallet1.load_mint() + yield wallet1 @pytest.mark.asyncio -async def test_init_keysets_without_duplicates_via_settings(ledger: Ledger): +async def test_init_keysets(ledger: Ledger): ledger.keysets = {} - settings.mint_duplicate_keysets = False await ledger.init_keysets() assert len(ledger.keysets) == 1 @@ -126,3 +128,251 @@ async def test_decrypt_seed(): pubkeys_encrypted[1].serialize().hex() == "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" ) + + +async def create_pending_melts( + ledger: Ledger, check_id: str = "checking_id" +) -> Tuple[Proof, MeltQuote]: + """Helper function for startup tests for fakewallet. Creates fake pending melt + quote and fake proofs that are in the pending table that look like they're being + used to pay the pending melt quote.""" + quote_id = "quote_id" + quote = MeltQuote( + quote=quote_id, + method="bolt11", + request="asdasd", + checking_id=check_id, + unit="sat", + paid=False, + amount=100, + fee_reserve=1, + ) + await ledger.crud.store_melt_quote( + quote=quote, + db=ledger.db, + ) + pending_proof = Proof(amount=123, C="asdasd", secret="asdasd", id=quote_id) + await ledger.crud.set_proof_pending( + db=ledger.db, + proof=pending_proof, + quote_id=quote_id, + ) + # expect a pending melt quote + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + return pending_proof, quote + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only fake wallet") +async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): + """Startup routine test. Expects that a pending proofs are removed form the pending db + after the startup routine determines that the associated melt quote was paid.""" + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = True + # run startup routinge + await ledger.startup_ledger() + + # expect that no pending tokens are in db anymore + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are spent + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.spent + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only fake wallet") +async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): + """Startup routine test. Expects that a pending proofs are removed form the pending db + after the startup routine determines that the associated melt quote failed to pay. + + The failure is simulated by setting the fakewallet_payment_state to False. + """ + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = False + # run startup routinge + await ledger.startup_ledger() + + # expect that no pending tokens are in db anymore + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are unspent + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.unspent + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only for fake wallet") +async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = None + # run startup routinge + await ledger.startup_ledger() + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + # settle_invoice(preimage=preimage) + + # run startup routinge + await ledger.startup_ledger() + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + # only now settle the invoice + settle_invoice(preimage=preimage) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + # expect that proofs are pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + settle_invoice(preimage=preimage) + await asyncio.sleep(SLEEP_TIME) + + # run startup routinge + await ledger.startup_ledger() + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are spent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.spent for s in states]) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Ledger): + """Simulate a failure to pay the hodl invoice by canceling it.""" + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + invoice_obj = bolt11.decode(invoice_payment_request) + preimage_hash = invoice_obj.payment_hash + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + # expect that proofs are pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + cancel_invoice(preimage_hash=preimage_hash) + await asyncio.sleep(SLEEP_TIME) + + # run startup routinge + await ledger.startup_ledger() + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are unspent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.unspent for s in states]) diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py index 85312e15..decabefa 100644 --- a/tests/test_mint_lightning_blink.py +++ b/tests/test_mint_lightning_blink.py @@ -4,7 +4,7 @@ from cashu.core.base import Amount, MeltQuote, Unit from cashu.core.settings import settings -from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet +from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore settings.mint_blink_key = "123" blink = BlinkWallet(unit=Unit.sat) diff --git a/tests/test_mint_regtest.py b/tests/test_mint_regtest.py new file mode 100644 index 00000000..043843c4 --- /dev/null +++ b/tests/test_mint_regtest.py @@ -0,0 +1,80 @@ +import asyncio + +import pytest +import pytest_asyncio + +from cashu.core.base import SpentState +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + get_hold_invoice, + is_fake, + pay_if_regtest, + settle_invoice, +) + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote)) + # asyncio.create_task( + # wallet.pay_lightning( + # proofs=send_proofs, + # invoice=invoice_payment_request, + # fee_reserve_sat=quote.fee_reserve, + # quote_id=quote.quote, + # ) + # ) + await asyncio.sleep(SLEEP_TIME) + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + # only now settle the invoice + settle_invoice(preimage=preimage) + await asyncio.sleep(SLEEP_TIME) + + # expect that proofs are now spent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.spent for s in states]) + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 78195f6d..8e66f948 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -82,10 +82,7 @@ async def test_get_keys(wallet1: Wallet): keyset = keysets[0] assert keyset.id is not None # assert keyset.id_deprecated == "eGnEWtdJ0PIM" - if settings.debug_mint_only_deprecated: - assert keyset.id == "eGnEWtdJ0PIM" - else: - assert keyset.id == "009a1f293253e41e" + assert keyset.id == "009a1f293253e41e" assert isinstance(keyset.id, str) assert len(keyset.id) > 0 @@ -355,7 +352,7 @@ async def test_duplicate_proofs_double_spent(wallet1: Wallet): doublespend = await wallet1.mint(64, id=invoice.id) await assert_err( wallet1.split(wallet1.proofs + doublespend, 20), - "Mint Error: Failed to set proofs pending.", + "Mint Error: duplicate proofs.", ) assert wallet1.balance == 64 assert wallet1.available_balance == 64 @@ -441,14 +438,10 @@ async def test_token_state(wallet1: Wallet): @pytest.mark.asyncio async def test_load_mint_keys_specific_keyset(wallet1: Wallet): await wallet1._load_mint_keys() - if settings.debug_mint_only_deprecated: - assert list(wallet1.keysets.keys()) == ["eGnEWtdJ0PIM"] - else: - assert list(wallet1.keysets.keys()) == ["009a1f293253e41e", "eGnEWtdJ0PIM"] + assert list(wallet1.keysets.keys()) == ["009a1f293253e41e"] await wallet1._load_mint_keys(keyset_id=wallet1.keyset_id) await wallet1._load_mint_keys(keyset_id="009a1f293253e41e") # expect deprecated keyset id to be present - await wallet1._load_mint_keys(keyset_id="eGnEWtdJ0PIM") await assert_err( wallet1._load_mint_keys(keyset_id="nonexistent"), KeysetNotFoundError(), diff --git a/tests/test_wallet_cli.py b/tests/test_wallet_cli.py index e36c950c..884a9059 100644 --- a/tests/test_wallet_cli.py +++ b/tests/test_wallet_cli.py @@ -28,6 +28,23 @@ def get_bolt11_and_invoice_id_from_invoice_command(output: str) -> Tuple[str, st return invoice, invoice_id +def get_invoice_from_invoices_command(output: str) -> dict[str, str]: + splitted = output.split("\n") + removed_empty_and_hiphens = [ + value for value in splitted if value and not value.startswith("-----") + ] + dict_output = { + f"{value.split(': ')[0]}": value.split(": ")[1] + for value in removed_empty_and_hiphens + } + + return dict_output + + +async def reset_invoices(wallet: Wallet): + await wallet.db.execute("DELETE FROM invoices") + + async def init_wallet(): settings.debug = False wallet = await Wallet.with_db( @@ -158,6 +175,197 @@ def test_invoice_with_split(mint, cli_prefix): wallet = asyncio.run(init_wallet()) assert wallet.proof_amounts.count(1) >= 10 +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +def test_invoices_with_minting(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + invoice = asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--mint"], + ) + + # assert + print("INVOICES --mint") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." not in result.output + assert "ID" in result.output + assert "Paid" in result.output + assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id + assert get_invoice_from_invoices_command(result.output)["Paid"] == "True" + + +def test_invoices_without_minting(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + invoice = asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices"], + ) + + # assert + print("INVOICES") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." not in result.output + assert "ID" in result.output + assert "Paid" in result.output + assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id + assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid) + +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +def test_invoices_with_onlypaid_option(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--only-paid", "--mint"], + ) + + # assert + print("INVOICES --only-paid --mint") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." in result.output + + +def test_invoices_with_onlypaid_option_without_minting(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--only-paid"], + ) + + # assert + print("INVOICES --only-paid") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." in result.output + +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +def test_invoices_with_onlyunpaid_option(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--only-unpaid", "--mint"], + ) + + # assert + print("INVOICES --only-unpaid --mint") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." in result.output + + +def test_invoices_with_onlyunpaid_option_without_minting(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + invoice = asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--only-unpaid"], + ) + + # assert + print("INVOICES --only-unpaid") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." not in result.output + assert "ID" in result.output + assert "Paid" in result.output + assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id + assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid) + + +def test_invoices_with_both_onlypaid_and_onlyunpaid_options(cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--only-paid", "--only-unpaid"], + ) + assert result.exception is None + print("INVOICES --only-paid --only-unpaid") + assert result.exit_code == 0 + assert ( + "You should only choose one option: either --only-paid or --only-unpaid" + in result.output + ) + +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +def test_invoices_with_pending_option(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--pending", "--mint"], + ) + + # assert + print("INVOICES --pending --mint") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." in result.output + + +def test_invoices_with_pending_option_without_minting(cli_prefix): + # arrange + wallet1 = asyncio.run(init_wallet()) + asyncio.run(reset_invoices(wallet=wallet1)) + invoice = asyncio.run(wallet1.request_mint(64)) + + # act + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "invoices", "--pending"], + ) + + # assert + print("INVOICES --pending") + assert result.exception is None + assert result.exit_code == 0 + assert "No invoices found." not in result.output + assert "ID" in result.output + assert "Paid" in result.output + assert get_invoice_from_invoices_command(result.output)["ID"] == invoice.id + assert get_invoice_from_invoices_command(result.output)["Paid"] == str(invoice.paid) + def test_wallets(cli_prefix): runner = CliRunner() diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index c52ac5d7..e8939676 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -231,9 +231,9 @@ async def test_p2pk_locktime_with_second_refund_pubkey( secret_lock = await wallet1.create_p2pk_lock( garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey locktime_seconds=2, # locktime - tags=Tags([ - ["refund", pubkey_wallet2, pubkey_wallet1] - ]), # multiple refund pubkeys + tags=Tags( + [["refund", pubkey_wallet2, pubkey_wallet1]] + ), # multiple refund pubkeys ) # sender side _, send_proofs = await wallet1.split_to_send( wallet1.proofs, 8, secret_lock=secret_lock @@ -388,9 +388,9 @@ async def test_p2pk_multisig_with_wrong_first_private_key( def test_tags(): - tags = Tags([ - ["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"] - ]) + tags = Tags( + [["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]] + ) assert tags.get_tag("key1") == "value1" assert tags["key1"] == "value1" assert tags.get_tag("key2") == "value2" diff --git a/tests/test_wallet_regtest.py b/tests/test_wallet_regtest.py new file mode 100644 index 00000000..7a8c61cb --- /dev/null +++ b/tests/test_wallet_regtest.py @@ -0,0 +1,107 @@ +import asyncio + +import bolt11 +import pytest +import pytest_asyncio + +from cashu.core.base import SpentState +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + cancel_invoice, + get_hold_invoice, + is_fake, + pay_if_regtest, + settle_invoice, +) + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.pending for s in states.states]) + + settle_invoice(preimage=preimage) + + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.spent for s in states.states]) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + invoice_obj = bolt11.decode(invoice_payment_request) + preimage_hash = invoice_obj.payment_hash + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.pending for s in states.states]) + + cancel_invoice(preimage_hash=preimage_hash) + + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.unspent for s in states.states]) diff --git a/tests/test_wallet_restore.py b/tests/test_wallet_restore.py index 7670b5c2..136425b3 100644 --- a/tests/test_wallet_restore.py +++ b/tests/test_wallet_restore.py @@ -8,7 +8,6 @@ from cashu.core.base import Proof from cashu.core.crypto.secp import PrivateKey from cashu.core.errors import CashuError -from cashu.core.settings import settings from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet as Wallet1 from cashu.wallet.wallet import Wallet as Wallet2 @@ -86,10 +85,6 @@ async def wallet3(): @pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) async def test_bump_secret_derivation(wallet3: Wallet): await wallet3._init_private_key( "half depart obvious quality work element tank gorilla view sugar picture" @@ -172,6 +167,11 @@ async def test_restore_wallet_after_mint(wallet3: Wallet): await wallet3.restore_promises_from_to(0, 20) assert wallet3.balance == 64 + # expect that DLEQ proofs are restored + assert all([p.dleq for p in wallet3.proofs]) + assert all([p.dleq.e for p in wallet3.proofs]) # type: ignore + assert all([p.dleq.s for p in wallet3.proofs]) # type: ignore + @pytest.mark.asyncio async def test_restore_wallet_with_invalid_mnemonic(wallet3: Wallet):