diff --git a/.env.example b/.env.example index 5250c9d..51a358e 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" # In this example, we have 2 keysets for sat, 1 for msat and 1 for usd # MINT_DERIVATION_PATH_LIST=["m/0'/0'/0'", "m/0'/0'/1'", "m/0'/1'/0'", "m/0'/2'/0'"] +# Input fee per 1000 inputs (ppk = per kilo). +# e.g. for 100 ppk: up to 10 inputs = 1 sat / 1 cent fee, for up to 20 inputs = 2 sat / 2 cent fee +MINT_INPUT_FEE_PPK=100 + # To use SQLite, choose a directory to store the database MINT_DATABASE=data/mint # To use PostgreSQL, set the connection string @@ -122,10 +126,11 @@ LIGHTNING_FEE_PERCENT=1.0 LIGHTNING_RESERVE_FEE_MIN=2000 # Mint Management gRPC service configurations +# Run the script in cashu/mint/management_rpc/generate_certificates.sh to generate certificates for the server and client. +# Use `poetry run mint-cli get-info` to test the connection. MINT_RPC_SERVER_ENABLE=FALSE MINT_RPC_SERVER_ADDR=localhost MINT_RPC_SERVER_PORT=8086 -MINT_RPC_SERVER_MUTUAL_TLS=TRUE MINT_RPC_SERVER_KEY="./server_private.pem" MINT_RPC_SERVER_CERT="./server_cert.pem" MINT_RPC_SERVER_CA="./ca_cert.pem" diff --git a/README.md b/README.md index f6c974d..6c7ec23 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,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.18.1 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.18.2 poetry run mint ``` ## From this repository diff --git a/cashu/core/base.py b/cashu/core/base.py index f712359..4b76fd2 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -733,7 +733,7 @@ class WalletKeyset: self.valid_from = valid_from self.valid_to = valid_to self.first_seen = first_seen - self.active = active + self.active = bool(active) self.mint_url = mint_url self.input_fee_ppk = input_fee_ppk 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/core/settings.py b/cashu/core/settings.py index 3c8851e..e7a646d 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -13,7 +13,7 @@ except ImportError: env = Env() -VERSION = "0.18.1" +VERSION = "0.18.2" def find_env_file(): @@ -73,7 +73,7 @@ class MintSettings(CashuSettings): mint_test_database: str = Field(default="test_data/test_mint") mint_max_secret_length: int = Field(default=1024) - mint_input_fee_ppk: int = Field(default=0) + mint_input_fee_ppk: int = Field(default=100) mint_disable_melt_on_error: bool = Field(default=False) mint_regular_tasks_interval_seconds: int = Field( @@ -228,13 +228,27 @@ class MintInformation(CashuSettings): class MintManagementRPCSettings(MintSettings): - mint_rpc_server_enable: bool = Field(default=False) - mint_rpc_server_ca: str = Field(default=None) - mint_rpc_server_cert: str = Field(default=None) + mint_rpc_server_enable: bool = Field( + default=False, description="Enable the management RPC server." + ) + mint_rpc_server_ca: str = Field( + default=None, + description="CA certificate file path for the management RPC server.", + ) + mint_rpc_server_cert: str = Field( + default=None, + description="Server certificate file path for the management RPC server.", + ) mint_rpc_server_key: str = Field(default=None) - mint_rpc_server_addr: str = Field(default="localhost") - mint_rpc_server_port: int = Field(default=8086) - mint_rpc_server_mutual_tls: bool = Field(default=True) + mint_rpc_server_addr: str = Field( + default="localhost", description="Address for the management RPC server." + ) + mint_rpc_server_port: int = Field( + default=8086, gt=0, lt=65536, description="Port for the management RPC server." + ) + mint_rpc_server_mutual_tls: bool = Field( + default=True, description="Require client certificates." + ) class WalletSettings(CashuSettings): diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 3745e3e..72f91e8 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -99,6 +99,7 @@ class StrikeWallet(LightningBackend): unit: Unit, ) -> int: fee_str = strike_quote.totalFee.amount + fee: int = 0 if strike_quote.totalFee.currency == self.currency_map[Unit.sat]: if unit == Unit.sat: fee = int(float(fee_str) * 1e8) @@ -107,8 +108,13 @@ class StrikeWallet(LightningBackend): elif strike_quote.totalFee.currency in [ self.currency_map[Unit.usd], self.currency_map[Unit.eur], + USDT, ]: fee = int(float(fee_str) * 100) + else: + raise Exception( + f"Unexpected currency {strike_quote.totalFee.currency} in fee" + ) return fee def __init__(self, unit: Unit, **kwargs): @@ -154,7 +160,7 @@ class StrikeWallet(LightningBackend): balance=Amount.from_float(float(balance["total"]), self.unit), ) - # if no the unit is USD but no USD balance was found, we try USDT + # if the unit is USD but no USD balance was found, we try USDT if self.unit == Unit.usd: for balance in data: if balance["currency"] == USDT: 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..d2e5dba 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -224,8 +224,11 @@ class Ledger( proofs (List[Proof]): Proofs to add to known secret table. conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. """ - # sum_proofs = sum([p.amount for p in proofs]) - fees_proofs = self.get_fees_for_proofs(proofs) + # Group proofs by keyset_id to calculate fees per keyset + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + async with self.db.get_connection(conn) as conn: # store in db for p in proofs: @@ -241,9 +244,18 @@ class Ledger( Y=p.Y, state=ProofSpentState.spent, witness=p.witness or None ) ) - await self.crud.bump_keyset_fees_paid( - keyset=self.keyset, amount=fees_proofs, db=self.db, conn=conn - ) + + # Calculate and increment fees for each keyset separately + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees = self.get_fees_for_proofs(keyset_proofs) + if keyset_fees > 0: + logger.trace(f"Adding fees {keyset_fees} to keyset {keyset_id}") + await self.crud.bump_keyset_fees_paid( + keyset=self.keysets[keyset_id], + amount=keyset_fees, + db=self.db, + conn=conn, + ) async def _generate_change_promises( self, @@ -1086,7 +1098,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/cashu/wallet/crud.py b/cashu/wallet/crud.py index bc34644..63bdc59 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -243,12 +243,13 @@ async def update_keyset( await (conn or db).execute( """ UPDATE keysets - SET active = :active + SET active = :active, input_fee_ppk = :input_fee_ppk WHERE id = :id """, { "active": keyset.active, "id": keyset.id, + "input_fee_ppk": keyset.input_fee_ppk, }, ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 6ffeb97..fde770e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -343,6 +343,9 @@ class Wallet( ].input_fee_ppk = mint_keyset.input_fee_ppk changed = True if changed: + logger.debug( + f"Updating mint keyset: {mint_keyset.id} ({mint_keyset.unit}) fee: {mint_keyset.input_fee_ppk} ppk, active: {mint_keyset.active}" + ) await update_keyset( keyset=keysets_in_db_dict[mint_keyset.id], db=self.db ) diff --git a/pyproject.toml b/pyproject.toml index e5a7441..319621b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.18.1" +version = "0.18.2" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 4bd51f6..e5a3a24 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]} setuptools.setup( name="cashu", - version="0.18.1", + version="0.18.2", description="Ecash wallet and mint", long_description=long_description, long_description_content_type="text/markdown", 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, )