From 37446fbeeaca7e51d30a5f8d244bb97ef46e4dd9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:21:07 +0100 Subject: [PATCH] Fix blind message already signed error (#828) * get unsigned blinded messages for output duplicate check * bm regression tests (#827) * fix last entry * test for error from error struct * rename tests, fix second regression test, add descriptive comments. * check for error message * one more test --------- Co-authored-by: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Co-authored-by: lollerfirst --- cashu/core/errors.py | 2 +- cashu/mint/auth/crud.py | 25 +-- cashu/mint/crud.py | 23 +-- cashu/mint/ledger.py | 2 +- cashu/mint/verification.py | 10 +- tests/mint/test_mint_db_operations.py | 20 +- tests/mint/test_mint_melt.py | 271 ++++++++++++++++++++++---- tests/mint/test_mint_operations.py | 9 +- 8 files changed, 281 insertions(+), 81 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 8b0c3cc..601b4d3 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -20,7 +20,7 @@ class NotAllowedError(CashuError): class OutputsAlreadySignedError(CashuError): - detail = "outputs have already been signed before." + detail = "outputs have already been signed before or are pending." code = 10002 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): diff --git a/cashu/mint/auth/crud.py b/cashu/mint/auth/crud.py index f3b650a..d12862a 100644 --- a/cashu/mint/auth/crud.py +++ b/cashu/mint/auth/crud.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional from ...core.base import ( + BlindedMessage, BlindedSignature, MeltQuote, MintKeyset, @@ -129,7 +130,7 @@ class AuthLedgerCrud(ABC): ) -> None: ... @abstractmethod - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -138,13 +139,13 @@ class AuthLedgerCrud(ABC): ) -> Optional[BlindedSignature]: ... @abstractmethod - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: ... + ) -> List[BlindedMessage]: ... class AuthLedgerCrudSqlite(AuthLedgerCrud): @@ -234,7 +235,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): }, ) - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -248,15 +249,15 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): """, {"b_": str(b_)}, ) - return BlindedSignature.from_row(row) if row else None + return BlindedSignature.from_row(row) if row else None # type: ignore - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: + ) -> List[BlindedMessage]: rows = await (conn or db).fetchall( f""" SELECT * from {db.table_with_schema('promises')} @@ -264,7 +265,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): """, {f"b_{i}": b_s[i] for i in range(len(b_s))}, ) - return [BlindedSignature.from_row(r) for r in rows] if rows else [] + return [BlindedMessage.from_row(r) for r in rows] if rows else [] async def invalidate_proof( self, @@ -303,7 +304,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): SELECT * from {db.table_with_schema('melt_quotes')} WHERE quote in (SELECT DISTINCT melt_quote FROM {db.table_with_schema('proofs_pending')}) """ ) - return [MeltQuote.from_row(r) for r in rows] + return [MeltQuote.from_row(r) for r in rows] if rows else [] # type: ignore async def get_pending_proofs_for_quote( self, @@ -441,7 +442,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): ) if row is None: return None - return MintQuote.from_row(row) if row else None + return MintQuote.from_row(row) if row else None # type: ignore async def get_mint_quote_by_request( self, @@ -457,7 +458,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): """, {"request": request}, ) - return MintQuote.from_row(row) if row else None + return MintQuote.from_row(row) if row else None # type: ignore async def update_mint_quote( self, @@ -549,7 +550,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud): ) if row is None: return None - return MeltQuote.from_row(row) if row else None + return MeltQuote.from_row(row) if row else None # type: ignore async def update_melt_quote( self, diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 5630584..0cee89c 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -206,7 +206,7 @@ class LedgerCrud(ABC): ) -> List[BlindedSignature]: ... @abstractmethod - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -215,13 +215,13 @@ class LedgerCrud(ABC): ) -> Optional[BlindedSignature]: ... @abstractmethod - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: ... + ) -> List[BlindedMessage]: ... @abstractmethod async def store_mint_quote( @@ -451,7 +451,7 @@ class LedgerCrudSqlite(LedgerCrud): }, ) - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -467,21 +467,22 @@ class LedgerCrudSqlite(LedgerCrud): ) return BlindedSignature.from_row(row) if row else None # type: ignore - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: + ) -> List[BlindedMessage]: rows = await (conn or db).fetchall( f""" SELECT * from {db.table_with_schema('promises')} - WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])}) AND c_ IS NOT NULL + WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])}) """, {f"b_{i}": b_s[i] for i in range(len(b_s))}, ) - return [BlindedSignature.from_row(r) for r in rows] if rows else [] # type: ignore + # could be unsigned (BlindedMessage) or signed (BlindedSignature), but BlindedMessage is a subclass of BlindedSignature + return [BlindedMessage.from_row(r) for r in rows] if rows else [] # type: ignore async def invalidate_proof( self, @@ -1037,7 +1038,7 @@ class LedgerCrudSqlite(LedgerCrud): ) return MintBalanceLogEntry.from_row(row) if row else None - + async def get_melt_quotes_by_checking_id( self, *, @@ -1050,6 +1051,6 @@ class LedgerCrudSqlite(LedgerCrud): SELECT * FROM {db.table_with_schema('melt_quotes')} WHERE checking_id = :checking_id """, - {"checking_id": checking_id} + {"checking_id": checking_id}, ) - return [MeltQuote.from_row(row) for row in results] # type: ignore \ No newline at end of file + return [MeltQuote.from_row(row) for row in results] # type: ignore diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c13e529..8701e53 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1086,7 +1086,7 @@ class Ledger( async with self.db.get_connection() as conn: for output in outputs: logger.trace(f"looking for promise: {output}") - promise = await self.crud.get_promise( + promise = await self.crud.get_blind_signature( b_=output.B_, db=self.db, conn=conn ) if promise is not None: diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 3fdc9df..acccb06 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -133,17 +133,19 @@ class LedgerVerification( if not self._verify_no_duplicate_outputs(outputs): raise TransactionDuplicateOutputsError() # verify that outputs have not been signed previously - signed_before = await self._check_outputs_issued_before(outputs, conn) + signed_before = await self._check_outputs_pending_or_issued_before( + outputs, conn + ) if any(signed_before): raise OutputsAlreadySignedError() logger.trace(f"Verified {len(outputs)} outputs.") - async def _check_outputs_issued_before( + async def _check_outputs_pending_or_issued_before( self, outputs: List[BlindedMessage], conn: Optional[Connection] = None, ) -> List[bool]: - """Checks whether the provided outputs have previously been signed by the mint + """Checks whether the provided outputs have previously stored (as blinded messages) been signed (as blind signatures) by the mint (which would lead to a duplication error later when trying to store these outputs again). Args: @@ -153,7 +155,7 @@ class LedgerVerification( result (List[bool]): Whether outputs are already present in the database. """ async with self.db.get_connection(conn) as conn: - promises = await self.crud.get_promises( + promises = await self.crud.get_outputs( b_s=[output.B_ for output in outputs], db=self.db, conn=conn ) return [True if promise else False for promise in promises] diff --git a/tests/mint/test_mint_db_operations.py b/tests/mint/test_mint_db_operations.py index b3d19ee..8719526 100644 --- a/tests/mint/test_mint_db_operations.py +++ b/tests/mint/test_mint_db_operations.py @@ -390,8 +390,8 @@ async def test_store_and_sign_blinded_message(ledger: Ledger): s=s.serialize(), ) - # Assert: row is now a full promise and can be read back via get_promise - promise = await ledger.crud.get_promise(db=ledger.db, b_=B_hex) + # Assert: row is now a full promise and can be read back via get_blind_signature + promise = await ledger.crud.get_blind_signature(db=ledger.db, b_=B_hex) assert promise is not None assert promise.amount == amount assert promise.C_ == C_point.serialize().hex() @@ -760,9 +760,9 @@ async def test_promises_fk_constraints_enforced(ledger: Ledger): async def test_concurrent_set_melt_quote_pending_same_checking_id(ledger: Ledger): """Test that concurrent attempts to set quotes with same checking_id as pending are handled correctly.""" from cashu.core.base import MeltQuote, MeltQuoteState - + checking_id = "test_checking_id_concurrent" - + # Create two quotes with the same checking_id quote1 = MeltQuote( quote="quote_id_conc_1", @@ -784,24 +784,24 @@ async def test_concurrent_set_melt_quote_pending_same_checking_id(ledger: Ledger fee_reserve=2, state=MeltQuoteState.unpaid, ) - + await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db) await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db) - + # Try to set both as pending concurrently results = await asyncio.gather( ledger.db_write._set_melt_quote_pending(quote=quote1), ledger.db_write._set_melt_quote_pending(quote=quote2), - return_exceptions=True + return_exceptions=True, ) - + # One should succeed, one should fail success_count = sum(1 for r in results if isinstance(r, MeltQuote)) error_count = sum(1 for r in results if isinstance(r, Exception)) - + assert success_count == 1, "Exactly one quote should be set as pending" assert error_count == 1, "Exactly one should fail" - + # The error should be about the quote already being pending error = next(r for r in results if isinstance(r, Exception)) assert "Melt quote already paid or pending." in str(error) diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 6ad4ce3..ef7b13f 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -4,7 +4,7 @@ import pytest import pytest_asyncio from cashu.core.base import MeltQuote, MeltQuoteState, Proof -from cashu.core.errors import LightningPaymentFailedError +from cashu.core.errors import LightningPaymentFailedError, OutputsAlreadySignedError from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.settings import settings from cashu.lightning.base import PaymentResult @@ -13,6 +13,7 @@ from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT from tests.helpers import ( get_real_invoice, + is_deprecated_api_only, is_fake, is_regtest, pay_if_regtest, @@ -85,6 +86,174 @@ async def create_pending_melts( return pending_proof, quote +@pytest.mark.asyncio +@pytest.mark.skipif( + not is_fake or is_deprecated_api_only, + reason="only fakewallet and non-deprecated api", +) +async def test_pending_melt_quote_outputs_registration_regression( + wallet, ledger: Ledger +): + """When paying a request results in a PENDING melt quote, + the change outputs should be registered properly + and further requests with the same outputs should result in an expected error. + """ + settings.fakewallet_payment_state = PaymentResult.PENDING.name + settings.fakewallet_pay_invoice_state = PaymentResult.PENDING.name + + mint_quote1 = await wallet.request_mint(100) + mint_quote2 = await wallet.request_mint(100) + # await pay_if_regtest(mint_quote1.request) + # await pay_if_regtest(mint_quote2.request) + + proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote) + proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote) + + invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" + invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8" + + # Get two melt quotes + melt_quote1 = await wallet.melt_quote(invoice_64_sat) + melt_quote2 = await wallet.melt_quote(invoice_62_sat) + + n_change_outputs = 7 + ( + change_secrets, + change_rs, + change_derivation_paths, + ) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True) + change_outputs, change_rs = wallet._construct_outputs( + n_change_outputs * [1], change_secrets, change_rs + ) + response1 = await ledger.melt( + proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs + ) + assert response1.state == "PENDING" + + await assert_err( + ledger.melt( + proofs=proofs2, + quote=melt_quote2.quote, + outputs=change_outputs, + ), + OutputsAlreadySignedError.detail, + ) + + # use get_melt_quote to verify that the quote state is updated + melt_quote1_updated = await ledger.get_melt_quote(melt_quote1.quote) + assert melt_quote1_updated.state == MeltQuoteState.pending + + melt_quote2_updated = await ledger.get_melt_quote(melt_quote2.quote) + assert melt_quote2_updated.state == MeltQuoteState.unpaid + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not is_fake or is_deprecated_api_only, + reason="only fakewallet and non-deprecated api", +) +async def test_settled_melt_quote_outputs_registration_regression( + wallet, ledger: Ledger +): + """Verify that if one melt request fails, we can still use the same outputs in another request""" + + settings.fakewallet_payment_state = PaymentResult.FAILED.name + settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name + + mint_quote1 = await wallet.request_mint(100) + mint_quote2 = await wallet.request_mint(100) + # await pay_if_regtest(mint_quote1.request) + # await pay_if_regtest(mint_quote2.request) + + proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote) + proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote) + + invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" + invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8" + + # Get two melt quotes + melt_quote1 = await wallet.melt_quote(invoice_64_sat) + melt_quote2 = await wallet.melt_quote(invoice_62_sat) + + n_change_outputs = 7 + ( + change_secrets, + change_rs, + change_derivation_paths, + ) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True) + change_outputs, change_rs = wallet._construct_outputs( + n_change_outputs * [1], change_secrets, change_rs + ) + await assert_err( + ledger.melt(proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs), + "Lightning payment failed.", + ) + + settings.fakewallet_payment_state = PaymentResult.SETTLED.name + settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name + + response2 = await ledger.melt( + proofs=proofs2, + quote=melt_quote2.quote, + outputs=change_outputs, + ) + + assert response2.state == "PAID" + + # use get_melt_quote to verify that the quote state is updated + melt_quote2_updated = await ledger.get_melt_quote(melt_quote2.quote) + assert melt_quote2_updated.state == MeltQuoteState.paid + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not is_fake or is_deprecated_api_only, + reason="only fakewallet and non-deprecated api", +) +async def test_melt_quote_reuse_same_outputs(wallet, ledger: Ledger): + """Verify that if the same outputs are used in two melt requests, + the second one fails. + """ + + settings.fakewallet_payment_state = PaymentResult.SETTLED.name + settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name + + mint_quote1 = await wallet.request_mint(100) + mint_quote2 = await wallet.request_mint(100) + # await pay_if_regtest(mint_quote1.request) + # await pay_if_regtest(mint_quote2.request) + + proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote) + proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote) + + invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" + invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8" + + # Get two melt quotes + melt_quote1 = await wallet.melt_quote(invoice_64_sat) + melt_quote2 = await wallet.melt_quote(invoice_62_sat) + + n_change_outputs = 7 + ( + change_secrets, + change_rs, + change_derivation_paths, + ) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True) + change_outputs, change_rs = wallet._construct_outputs( + n_change_outputs * [1], change_secrets, change_rs + ) + (ledger.melt(proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs),) + + await assert_err( + ledger.melt( + proofs=proofs2, + quote=melt_quote2.quote, + outputs=change_outputs, + ), + OutputsAlreadySignedError.detail, + ) + + @pytest.mark.asyncio @pytest.mark.skipif(is_regtest, reason="only fake wallet") async def test_fakewallet_pending_quote_get_melt_quote_success(ledger: Ledger): @@ -379,7 +548,7 @@ async def test_mint_melt_different_units(ledger: Ledger, wallet: Wallet): async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger): """Test that setting a melt quote as pending without a checking_id raises an error.""" from cashu.core.errors import TransactionError - + quote = MeltQuote( quote="quote_id_no_checking", method="bolt11", @@ -391,10 +560,10 @@ async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger): state=MeltQuoteState.unpaid, ) await ledger.crud.store_melt_quote(quote=quote, db=ledger.db) - + # Set checking_id to empty to simulate the error condition quote.checking_id = "" - + try: await ledger.db_write._set_melt_quote_pending(quote=quote) raise AssertionError("Expected TransactionError") @@ -406,9 +575,9 @@ async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger): async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Ledger): """Test that setting a melt quote as pending fails if another quote with same checking_id is already pending.""" from cashu.core.errors import TransactionError - + checking_id = "test_checking_id_duplicate" - + quote1 = MeltQuote( quote="quote_id_dup_first", method="bolt11", @@ -429,26 +598,30 @@ async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Led fee_reserve=2, state=MeltQuoteState.unpaid, ) - + await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db) await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db) - + # Set the first quote as pending await ledger.db_write._set_melt_quote_pending(quote=quote1) - + # Verify the first quote is pending - quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_dup_first", db=ledger.db) + quote1_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_dup_first", db=ledger.db + ) assert quote1_db.state == MeltQuoteState.pending - + # Attempt to set the second quote as pending should fail try: await ledger.db_write._set_melt_quote_pending(quote=quote2) raise AssertionError("Expected TransactionError") except TransactionError as e: assert "Melt quote already paid or pending." in str(e) - + # Verify the second quote is still unpaid - quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_dup_second", db=ledger.db) + quote2_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_dup_second", db=ledger.db + ) assert quote2_db.state == MeltQuoteState.unpaid @@ -457,7 +630,7 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge """Test that setting melt quotes as pending succeeds when they have different checking_ids.""" checking_id_1 = "test_checking_id_allow_1" checking_id_2 = "test_checking_id_allow_2" - + quote1 = MeltQuote( quote="quote_id_allow_1", method="bolt11", @@ -478,17 +651,21 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge fee_reserve=2, state=MeltQuoteState.unpaid, ) - + await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db) await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db) - + # Set both quotes as pending - should succeed await ledger.db_write._set_melt_quote_pending(quote=quote1) await ledger.db_write._set_melt_quote_pending(quote=quote2) - + # Verify both quotes are pending - quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_allow_1", db=ledger.db) - quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_allow_2", db=ledger.db) + quote1_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_allow_1", db=ledger.db + ) + quote2_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_allow_2", db=ledger.db + ) assert quote1_db.state == MeltQuoteState.pending assert quote2_db.state == MeltQuoteState.pending @@ -497,7 +674,7 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge async def test_set_melt_quote_pending_after_unset(ledger: Ledger): """Test that a quote can be set as pending again after being unset.""" checking_id = "test_checking_id_unset_test" - + quote1 = MeltQuote( quote="quote_id_unset_first", method="bolt11", @@ -518,28 +695,38 @@ async def test_set_melt_quote_pending_after_unset(ledger: Ledger): fee_reserve=2, state=MeltQuoteState.unpaid, ) - + await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db) await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db) - + # Set the first quote as pending quote1_pending = await ledger.db_write._set_melt_quote_pending(quote=quote1) assert quote1_pending.state == MeltQuoteState.pending - + # Unset the first quote (mark as paid) - await ledger.db_write._unset_melt_quote_pending(quote=quote1_pending, state=MeltQuoteState.paid) - + await ledger.db_write._unset_melt_quote_pending( + quote=quote1_pending, state=MeltQuoteState.paid + ) + # Verify the first quote is no longer pending - quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_unset_first", db=ledger.db) + quote1_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_unset_first", db=ledger.db + ) assert quote1_db.state == MeltQuoteState.paid - + # Now the second quote should still - assert_err(ledger.db_write._set_melt_quote_pending(quote=quote2), "Melt quote already paid or pending.") - + await assert_err( + ledger.db_write._set_melt_quote_pending(quote=quote2), + "Melt quote already paid or pending.", + ) + # Verify the second quote is unpaid - quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_unset_second", db=ledger.db) + quote2_db = await ledger.crud.get_melt_quote( + quote_id="quote_id_unset_second", db=ledger.db + ) assert quote2_db.state == MeltQuoteState.unpaid + @pytest.mark.asyncio @pytest.mark.skipif(is_fake, reason="only regtest") async def test_mint_pay_with_duplicate_checking_id(wallet): @@ -551,18 +738,26 @@ async def test_mint_pay_with_duplicate_checking_id(wallet): proofs1 = await wallet.mint(amount=1024, quote_id=mint_quote1.quote) proofs2 = await wallet.mint(amount=1024, quote_id=mint_quote2.quote) - invoice = get_real_invoice(64)['payment_request'] + invoice = get_real_invoice(64)["payment_request"] # Get two melt quotes for the same invoice melt_quote1 = await wallet.melt_quote(invoice) melt_quote2 = await wallet.melt_quote(invoice) response1 = await wallet.melt( - proofs=proofs1, invoice=invoice, fee_reserve_sat=melt_quote1.fee_reserve, quote_id=melt_quote1.quote - ) - assert response1.state == 'PAID' + proofs=proofs1, + invoice=invoice, + fee_reserve_sat=melt_quote1.fee_reserve, + quote_id=melt_quote1.quote, + ) + assert response1.state == "PAID" - assert_err(wallet.melt( - proofs=proofs2, invoice=invoice, fee_reserve_sat=melt_quote2.fee_reserve, quote_id=melt_quote2.quote - ), "Melt quote already paid or pending.") - + assert_err( + wallet.melt( + proofs=proofs2, + invoice=invoice, + fee_reserve_sat=melt_quote2.fee_reserve, + quote_id=melt_quote2.quote, + ), + "Melt quote already paid or pending.", + ) diff --git a/tests/mint/test_mint_operations.py b/tests/mint/test_mint_operations.py index 4312a0c..51bbca8 100644 --- a/tests/mint/test_mint_operations.py +++ b/tests/mint/test_mint_operations.py @@ -2,6 +2,7 @@ import pytest import pytest_asyncio from cashu.core.base import MeltQuoteState, MintQuoteState +from cashu.core.errors import OutputsAlreadySignedError from cashu.core.helpers import sum_proofs from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.nuts import nut20 @@ -145,7 +146,7 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger): await assert_err( ledger.mint(outputs=outputs, quote_id=mint_quote.quote), - "outputs have already been signed before.", + OutputsAlreadySignedError.detail, ) mint_quote_after_payment = await ledger.get_mint_quote(mint_quote.quote) @@ -294,7 +295,7 @@ async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger): # try to spend other proofs with the same outputs again await assert_err( ledger.swap(proofs=inputs2, outputs=outputs), - "outputs have already been signed before.", + OutputsAlreadySignedError.detail, ) # try to spend inputs2 again with new outputs @@ -328,7 +329,7 @@ async def test_mint_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey) await assert_err( ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature), - "outputs have already been signed before.", + OutputsAlreadySignedError.detail, ) @@ -358,7 +359,7 @@ async def test_melt_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger): ) await assert_err( ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs), - "outputs have already been signed before.", + OutputsAlreadySignedError.detail, )