From 4ff9e6de5d6f86a576f6b47c03582bc5dc2e82cd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 26 Mar 2024 22:01:09 +0100 Subject: [PATCH 1/6] bump version to 0.15.2 (#495) --- cashu/core/settings.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 1c0ee68b..bc01df81 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.15.1" +VERSION = "0.15.2" def find_env_file(): diff --git a/pyproject.toml b/pyproject.toml index 0448d665..4c02790c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.15.1" +version = "0.15.2" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index f8bac914..501f1e1f 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.15.1", + version="0.15.2", description="Ecash wallet and mint", long_description=long_description, long_description_content_type="text/markdown", From b41b485f050f845607456599e24961e156f4e2c6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 27 Mar 2024 20:29:48 +0100 Subject: [PATCH 2/6] change sponsor link (#496) --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 1f1daca232644e71f27cc528ae0a20ca85d69139 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 29 Mar 2024 22:34:51 +0100 Subject: [PATCH 3/6] Add test for NUT-12 (#498) --- tests/test_crypto.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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 From b8ad0e0a8f935c4f69a3bd8c5162423fc8e6943a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:14:21 +0200 Subject: [PATCH 4/6] Mint: Recover pending melts at startup (#499) * wip works with fakewallet * startup refactor * add tests * regtest tests for pending melts * wip CLN * remove db migration * remove foreign key relation to keyset id * fix: get_promise from db and restore DLEQs * test: check for keyset not found error * fix migrations * lower-case all db column names * add more tests for regtest * simlate failure for lightning * test wallet spent state with hodl invoices * retry * regtest with postgres * retry postgres * add sleeps * longer sleep on github * more sleep for github sigh * increase sleep ffs * add sleep loop * try something * do not pay with wallet but with ledger * fix lnbits pending state * fix pipeline to use fake admin from docker --- .github/workflows/ci.yml | 4 +- .github/workflows/regtest.yml | 4 - cashu/core/base.py | 21 +- cashu/core/errors.py | 4 +- cashu/core/settings.py | 1 + cashu/lightning/corelightningrest.py | 14 +- cashu/lightning/fake.py | 2 +- cashu/lightning/lnbits.py | 12 +- cashu/lightning/lndrest.py | 8 +- cashu/mint/crud.py | 150 +++++++++------ cashu/mint/ledger.py | 112 +++++++++-- cashu/mint/migrations.py | 174 ++++++++++++++--- cashu/mint/router.py | 4 +- cashu/mint/startup.py | 30 +-- cashu/mint/verification.py | 2 +- cashu/wallet/cli/cli.py | 2 +- cashu/wallet/wallet.py | 2 +- tests/conftest.py | 4 +- tests/helpers.py | 26 +-- tests/test_mint_init.py | 277 ++++++++++++++++++++++++++- tests/test_mint_regtest.py | 80 ++++++++ tests/test_wallet_regtest.py | 107 +++++++++++ tests/test_wallet_restore.py | 5 + 23 files changed, 868 insertions(+), 177 deletions(-) create mode 100644 tests/test_mint_regtest.py create mode 100644 tests/test_wallet_regtest.py 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/cashu/core/base.py b/cashu/core/base.py index 072d2a8e..f6375f9e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -101,12 +101,12 @@ class Proof(BaseModel): time_created: Union[None, str] = "" time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - mint_id: Union[ - None, str - ] = None # holds the id of the mint operation that created this proof - melt_id: Union[ - None, str - ] = None # holds the id of the melt operation that destroyed this proof + mint_id: Union[None, str] = ( + None # holds the id of the mint operation that created this proof + ) + melt_id: Union[None, str] = ( + None # holds the id of the melt operation that destroyed this proof + ) def __init__(self, **data): super().__init__(**data) @@ -194,6 +194,15 @@ class BlindedSignature(BaseModel): C_: str # Hex-encoded signature dleq: Optional[DLEQ] = None # DLEQ proof + @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"]), + ) + class BlindedMessages(BaseModel): # NOTE: not used in Pydantic validation 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..899ab9ae 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -125,6 +125,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) 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/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..c4f2ea02 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -93,6 +93,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( @@ -229,7 +306,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 +322,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, @@ -708,14 +791,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,7 +826,7 @@ 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] = [] @@ -802,7 +886,7 @@ async def split( async with get_db_connection(self.db) as conn: # we do this in a single db transaction promises = await self._generate_promises(outputs, keyset, conn) - await self._invalidate_proofs(proofs, conn) + await self._invalidate_proofs(proofs=proofs, conn=conn) except Exception as e: logger.trace(f"split failed: {e}") @@ -823,7 +907,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` @@ -890,8 +974,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, @@ -950,12 +1034,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 +1054,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: diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 2291288c..3f9c5377 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -20,10 +20,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 +33,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 +129,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 +157,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 +283,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 +303,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 +440,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 +461,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 +475,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 +507,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 +520,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 +531,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 +540,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 +576,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 +596,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 +612,109 @@ 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) 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/startup.py b/cashu/mint/startup.py index 94d16e61..35635258 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 [ @@ -79,29 +84,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..69aa6448 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -247,7 +247,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: diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index d4a5479c..986e5e42 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index af682cc1..6d1ac1c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,9 +94,7 @@ def mint(): async def ledger(): async def start_mint_init(ledger: Ledger): await migrate_databases(ledger.db, migrations_mint) - if settings.mint_cache_secrets: - await ledger.load_used_proofs() - await ledger.init_keysets() + await ledger.startup_ledger() if not settings.mint_database.startswith("postgres"): # clear sqlite database 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_mint_init.py b/tests/test_mint_init.py index 77f111b8..7bc45676 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,6 +44,17 @@ def assert_amt(proofs: List[Proof], expected: int): assert [p.amount for p in proofs] == expected +@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_with_duplicates(ledger: Ledger): ledger.keysets = {} @@ -126,3 +151,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_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_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..9064235d 100644 --- a/tests/test_wallet_restore.py +++ b/tests/test_wallet_restore.py @@ -172,6 +172,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): From 3b2f1aa6f42e77257e1ea8d7f10fe99d9466fdd1 Mon Sep 17 00:00:00 2001 From: Guilherme Pereira Date: Wed, 3 Apr 2024 16:51:15 +0100 Subject: [PATCH 5/6] Issue #313: allow checking pending invoices (#493) * Update deprecated datetime * Add options to Invoices cli With these options, we are able to return: 1) all invoices (this is the default); 2) pending invoices (paid False, out False); 3) paid invoices; 4) and unpaid invoices. * make format * Fix mypy error with datetime * sort imports * Remove unneeded unit when printing out info * Fix wrong method doc * Try to mint pending invoices * make pre-commit * Refactor --tests flag to --mint The default will be false, i.e., if the user does not pass in the --mint flag, it will not try to mint the pending invoice. --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- cashu/mint/conditions.py | 6 +- cashu/wallet/cli/cli.py | 154 ++++++++++++++++----- cashu/wallet/crud.py | 15 ++- cashu/wallet/migrations.py | 54 +++++--- cashu/wallet/p2pk.py | 15 ++- cashu/wallet/wallet.py | 2 +- tests/test_mint_lightning_blink.py | 2 +- tests/test_wallet_cli.py | 208 +++++++++++++++++++++++++++++ tests/test_wallet_p2pk.py | 12 +- 9 files changed, 394 insertions(+), 74 deletions(-) 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/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 69aa6448..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 @@ -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 986e5e42..acd41cfd 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -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. 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_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" From 19de10bfea160b2cdb0e726d4ca1b0bf980df8b7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:23:53 +0200 Subject: [PATCH 6/6] [Refactor] Mint: remove output.id optional (#504) * remove output.id optional * asserts * wip * wip * working --- README.md | 2 +- cashu/core/base.py | 45 +++---- cashu/core/settings.py | 4 +- cashu/mint/ledger.py | 187 ++++++++++++++++++------------ cashu/mint/router_deprecated.py | 36 +++--- cashu/wallet/wallet_deprecated.py | 21 +++- pyproject.toml | 2 +- setup.py | 2 +- tests/test_mint_init.py | 4 +- tests/test_wallet.py | 2 +- 10 files changed, 180 insertions(+), 125 deletions(-) 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 f6375f9e..1e198306 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -101,12 +101,12 @@ class Proof(BaseModel): time_created: Union[None, str] = "" time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - mint_id: Union[None, str] = ( - None # holds the id of the mint operation that created this proof - ) - melt_id: Union[None, str] = ( - None # holds the id of the melt operation that destroyed this proof - ) + mint_id: Union[ + None, str + ] = None # holds the id of the mint operation that created this proof + melt_id: Union[ + None, str + ] = None # holds the id of the melt operation that destroyed this proof def __init__(self, **data): super().__init__(**data) @@ -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) @@ -204,11 +197,6 @@ def from_row(cls, row: Row): ) -class BlindedMessages(BaseModel): - # NOTE: not used in Pydantic validation - __root__: List[BlindedMessage] = [] - - # ------- LIGHTNING INVOICE ------- @@ -341,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 ------- @@ -407,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 ) @@ -454,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 ) @@ -483,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 ) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 899ab9ae..0cf2e755 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,7 +58,7 @@ 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( + mint_duplicate_old_keysets: bool = Field( default=True, title="Duplicate keysets", description=( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c4f2ea02..b5b5743f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -34,6 +34,7 @@ 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 +73,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: @@ -189,7 +191,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, @@ -230,7 +233,8 @@ async def activate_keyset( # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 # set the deprecated id - assert keyset.public_keys + if not keyset.public_keys: + raise KeysetError("no public keys for this keyset") keyset.duplicate_keyset_id = derive_keyset_id_deprecated(keyset.public_keys) # END BACKWARDS COMPATIBILITY < 0.15.0 @@ -268,17 +272,22 @@ 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." + if not any([k.active for k in self.keysets.values()]): + raise KeysetError("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: + if duplicate_keysets is not False and ( + settings.mint_duplicate_old_keysets or duplicate_keysets + ): for _, keyset in copy.copy(self.keysets).items(): + # if keyset.version_tuple >= (0, 15, 3) and not duplicate_keysets: + # # we do not duplicate keysets from version 0.15.3 and above if not forced by duplicate_keysets + # continue keyset_copy = copy.copy(keyset) - assert keyset_copy.public_keys + if not keyset_copy.public_keys: + raise KeysetError("no public keys for this keyset") if keyset.version_tuple >= (0, 15): keyset_copy.id = derive_keyset_id_deprecated( keyset_copy.public_keys @@ -296,7 +305,8 @@ def get_keyset(self, keyset_id: Optional[str] = None) -> Dict[int, str]: 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: @@ -400,7 +410,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." @@ -426,9 +437,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) @@ -478,7 +488,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 @@ -518,18 +529,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") @@ -571,12 +591,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), @@ -589,20 +616,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: @@ -703,23 +730,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})" @@ -764,25 +796,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: @@ -831,7 +863,6 @@ async def melt( # 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, @@ -871,22 +902,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 - promises = await self._generate_promises(outputs, keyset, conn) await self._invalidate_proofs(proofs=proofs, conn=conn) + promises = await self._generate_promises(outputs, keyset, conn) except Exception as e: logger.trace(f"split failed: {e}") @@ -949,15 +979,16 @@ 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 [ + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + if output.id not in [ keyset.id, keyset.duplicate_keyset_id, - ], "keyset id does not match output id" - assert keyset.active, "keyset is not active" + ]: + 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] @@ -995,7 +1026,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") @@ -1082,11 +1114,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/router_deprecated.py b/cashu/mint/router_deprecated.py index 4a970c4f..dde54056 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, @@ -177,10 +178,14 @@ 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 + # use the deprecated version of the current keyset + assert ledger.keyset.duplicate_keyset_id + outputs: list[BlindedMessage] = [ + BlindedMessage( + id=o.id or ledger.keyset.duplicate_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 +194,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 +226,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 +298,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/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py index db5e927a..080a9925 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, @@ -271,7 +272,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 +309,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 +345,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 +415,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/test_mint_init.py b/tests/test_mint_init.py index 7bc45676..17263164 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -65,7 +65,7 @@ async def test_init_keysets_with_duplicates(ledger: Ledger): @pytest.mark.asyncio async def test_init_keysets_with_duplicates_via_settings(ledger: Ledger): ledger.keysets = {} - settings.mint_duplicate_keysets = True + settings.mint_duplicate_old_keysets = True await ledger.init_keysets() assert len(ledger.keysets) == 2 @@ -80,7 +80,7 @@ async def test_init_keysets_without_duplicates(ledger: Ledger): @pytest.mark.asyncio async def test_init_keysets_without_duplicates_via_settings(ledger: Ledger): ledger.keysets = {} - settings.mint_duplicate_keysets = False + settings.mint_duplicate_old_keysets = False await ledger.init_keysets() assert len(ledger.keysets) == 1 diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 78195f6d..16d59c8d 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -355,7 +355,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