From a89154b57993ed2b8bca3d5581000dac36859ba6 Mon Sep 17 00:00:00 2001 From: Egge Date: Sat, 15 Aug 2026 06:52:37 +0200 Subject: [PATCH] fix(mint): allow issuing paid quotes after expiry --- cashu/mint/ledger.py | 42 +++++++++++++++++------------------ tests/mint/test_mint.py | 26 ++++++++++++++++++++++ tests/mint/test_mint_batch.py | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index a04edf88..eb5994fc 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -496,8 +496,7 @@ async def mint( Exception: Validation of outputs failed. Exception: Quote not paid. Exception: Quote already issued. - Exception: Quote expired. - Exception: Amount to mint does not match quote amount. + Exception: Amount to mint does not match quote amount. Returns: List[BlindedSignature]: Signatures on the outputs. @@ -512,19 +511,20 @@ async def mint( raise TransactionError("Mint quote already pending.") if quote.issued: raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - previous_state = quote.state + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + # Quote expiry limits when the payment request can be paid. Once payment is + # confirmed, the quote remains mintable until its value has been issued. + + previous_state = quote.state await self.db_write._set_mint_quote_pending(quote_id=quote_id) try: 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") - if not self._verify_mint_quote_witness(quote, outputs, signature): + if not self._verify_mint_quote_witness(quote, outputs, signature): raise QuoteSignatureInvalidError() await self._store_blinded_messages(outputs, mint_id=quote_id) promises = await self._sign_blinded_messages(outputs) @@ -592,15 +592,17 @@ async def mint_batch( if units.pop() != output_unit.name: raise TransactionError("quote unit does not match output unit") - for quote in quotes: - if quote.pending: - raise TransactionError("mint quote already pending") + for quote in quotes: + if quote.pending: + raise TransactionError("mint quote already pending") if quote.issued: raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - # Check amount balance + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + # Quote expiry limits payment, not issuance of already-paid value. + + # Check amount balance if payload.quote_amounts: if len(payload.quote_amounts) != len(quotes): raise TransactionError("quote_amounts length must match quotes length") @@ -642,12 +644,8 @@ async def mint_batch( # Set all quotes to pending quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) - try: - for quote in quotes: - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - - # Store all blinded messages + try: + # Store all blinded messages await self._store_blinded_messages( payload.outputs, mint_id=payload.quotes[0] ) diff --git a/tests/mint/test_mint.py b/tests/mint/test_mint.py index e75aea40..3e0341c2 100644 --- a/tests/mint/test_mint.py +++ b/tests/mint/test_mint.py @@ -87,6 +87,32 @@ async def test_mint(ledger: Ledger): ) +@pytest.mark.asyncio +async def test_mint_paid_quote_after_expiry(ledger: Ledger): + quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat")) + await pay_if_regtest(quote.request) + quote = await ledger.get_mint_quote(quote.quote) + assert quote.paid + + quote.expiry = int(time.time()) - 1 + await ledger.crud.update_mint_quote(quote=quote, db=ledger.db) + + blinded_message, _ = step1_alice("paid_quote_after_expiry") + promises = await ledger.mint( + outputs=[ + BlindedMessage( + amount=8, + B_=blinded_message.format().hex(), + id=ledger.keyset.id, + ) + ], + quote_id=quote.quote, + ) + + assert len(promises) == 1 + assert promises[0].amount == 8 + + @pytest.mark.asyncio async def test_mint_invalid_quote(ledger: Ledger): await assert_err( diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 475efa47..ece1908f 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -1,5 +1,6 @@ import asyncio import os +import time import pytest import pytest_asyncio @@ -81,6 +82,47 @@ async def test_ledger_mint_batch_success(ledger: Ledger, wallet: Wallet): assert quote2.issued_time is not None +@pytest.mark.asyncio +async def test_ledger_mint_batch_paid_quotes_after_expiry( + ledger: Ledger, wallet: Wallet +): + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + quote1 = await ledger.get_mint_quote(mint_quote1.quote) + quote2 = await ledger.get_mint_quote(mint_quote2.quote) + assert quote1.paid + assert quote2.paid + + quote1.expiry = int(time.time()) - 1 + quote2.expiry = int(time.time()) - 1 + await ledger.crud.update_mint_quote(quote=quote1, db=ledger.db) + await ledger.crud.update_mint_quote(quote=quote2, db=ledger.db) + + secrets, rs, _ = await wallet.generate_secrets_from_to(10002, 10003) + outputs, _ = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + assert mint_quote2.privkey + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + promises = await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + ) + + assert [promise.amount for promise in promises] == [64, 32] + + @pytest.mark.asyncio async def test_ledger_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): await wallet.load_mint()