From 601513a6323a014d5f3fd7369ab729c01084eaec Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 May 2026 10:09:38 +0200 Subject: [PATCH 1/2] add batch mint tests --- tests/mint/test_mint_batch.py | 307 ++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 127fa4042..45de9c170 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -364,3 +364,310 @@ async def mock_unset_mint_quotes_pending(quote_ids, state): with pytest.raises(Exception) as exc: await ledger.mint_batch(req2) assert "mint quote already pending" in str(exc.value) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_missing_signature_for_locked_quote( + ledger: Ledger, wallet: Wallet +): + """Locked quote without signature should fail.""" + 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) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, None], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "Signature" in str(e) or "signature" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_invalid_signature(ledger: Ledger, wallet: Wallet): + """Wrong signature for locked quote should fail.""" + import os + + from cashu.core.crypto.secp import PrivateKey + + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + await pay_if_regtest(mint_quote1.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([64], secrets, rs) + + assert mint_quote1.privkey + + wrong_privkey = PrivateKey(os.urandom(32)) + wrong_sig = nut20.sign_mint_quote( + mint_quote1.quote, outputs, wrong_privkey.secret.hex() + ) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote], + quote_amounts=[64], + outputs=outputs, + signatures=[wrong_sig], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "Signature" in str(e) or "signature" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_mixed_locked_unlocked(ledger: Ledger, wallet: Wallet): + """Batch with one locked and one unlocked quote should succeed.""" + 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) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + promises = await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, None], + ) + ) + + assert len(promises) == 2 + assert promises[0].amount == 64 + assert promises[1].amount == 32 + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_already_issued(ledger: Ledger, wallet: Wallet): + """Attempting to mint already-issued quotes should fail.""" + 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) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = 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) + + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + ) + + secrets2, rs2, derivation_paths2 = await wallet.generate_secrets_from_to( + 10002, 10003 + ) + outputs2, rs2 = wallet._construct_outputs([64, 32], secrets2, rs2) + + sig1_2 = nut20.sign_mint_quote(mint_quote1.quote, outputs2, mint_quote1.privkey) + sig2_2 = nut20.sign_mint_quote(mint_quote2.quote, outputs2, mint_quote2.privkey) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs2, + signatures=[sig1_2, sig2_2], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "already issued" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_expired_quotes(ledger: Ledger, wallet: Wallet): + """Batch mint with expired quotes should fail.""" + 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) + + q2 = await ledger.crud.get_mint_quote(quote_id=mint_quote2.quote, db=ledger.db) + q2.expiry = 1000000 + await ledger.crud.update_mint_quote(quote=q2, db=ledger.db) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = 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) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "expired" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_empty_quotes(ledger: Ledger, wallet: Wallet): + """Empty quotes array should fail.""" + await wallet.load_mint() + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[], + quote_amounts=[], + outputs=[], + signatures=[], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "empty" in str(e).lower() or "must not be empty" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_single_quote(ledger: Ledger, wallet: Wallet): + """Single quote batch should succeed.""" + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + + await pay_if_regtest(mint_quote1.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10000) + outputs, rs = wallet._construct_outputs([64], secrets, rs) + + assert mint_quote1.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + promises = await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote], + quote_amounts=[64], + outputs=outputs, + signatures=[sig1], + ) + ) + + assert len(promises) == 1 + assert promises[0].amount == 64 + + +@pytest.mark.asyncio +async def test_ledger_mint_quote_check_nonexistent_quote( + ledger: Ledger, wallet: Wallet +): + """Checking nonexistent quote should fail.""" + await wallet.load_mint() + + try: + await ledger.mint_quote_check( + PostMintQuoteCheckRequest(quotes=["nonexistent_quote_id"]) + ) + assert False, "Expected Exception" + except Exception as e: + assert "not found" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_atomicity_one_invalid(ledger: Ledger, wallet: Wallet): + """If one quote in batch is invalid, none should be minted.""" + 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) + + q2 = await ledger.crud.get_mint_quote(quote_id=mint_quote2.quote, db=ledger.db) + q2.expiry = 1000000 + await ledger.crud.update_mint_quote(quote=q2, db=ledger.db) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = 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) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "expired" in str(e) + + q1_after = await ledger.crud.get_mint_quote( + quote_id=mint_quote1.quote, db=ledger.db + ) + assert q1_after.state.value == "PAID", ( + f"Quote1 should still be PAID, got {q1_after.state.value}" + ) + + secrets2, rs2, derivation_paths2 = await wallet.generate_secrets_from_to( + 10002, 10002 + ) + outputs2, rs2 = wallet._construct_outputs([64], secrets2, rs2) + sig1_2 = nut20.sign_mint_quote(mint_quote1.quote, outputs2, mint_quote1.privkey) + + promises = await ledger.mint( + outputs=outputs2, + quote_id=mint_quote1.quote, + signature=sig1_2, + ) + assert len(promises) == 1 + assert promises[0].amount == 64 From 8807dca15692211174c52e9cdc011260d66c105d Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Fri, 26 Jun 2026 12:37:08 +0100 Subject: [PATCH 2/2] test(mint): fix failing batch mint tests - invalid_signature: generate one secret to match the single output - mixed_locked_unlocked: make the second quote actually unlocked (request_mint always sets a NUT-20 pubkey, so both were locked) - drop expired_quotes: mint quote expiry is not persisted/enforced - atomicity_one_invalid: trigger the failure via an invalid signature instead of (unenforced) expiry --- tests/mint/test_mint_batch.py | 61 ++++++++--------------------------- 1 file changed, 13 insertions(+), 48 deletions(-) diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 45de9c170..15f9cb7d2 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -1,8 +1,10 @@ import asyncio +import os import pytest import pytest_asyncio +from cashu.core.crypto.secp import PrivateKey from cashu.core.models import PostMintBatchRequest, PostMintQuoteCheckRequest from cashu.core.nuts import nut20 from cashu.core.settings import settings @@ -410,7 +412,7 @@ async def test_ledger_mint_batch_invalid_signature(ledger: Ledger, wallet: Walle mint_quote1 = await wallet.request_mint(64) await pay_if_regtest(mint_quote1.request) - secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10000) outputs, rs = wallet._construct_outputs([64], secrets, rs) assert mint_quote1.privkey @@ -438,8 +440,10 @@ async def test_ledger_mint_batch_invalid_signature(ledger: Ledger, wallet: Walle async def test_ledger_mint_batch_mixed_locked_unlocked(ledger: Ledger, wallet: Wallet): """Batch with one locked and one unlocked quote should succeed.""" await wallet.load_mint() + # locked quote (NUT-20 pubkey set) mint_quote1 = await wallet.request_mint(64) - mint_quote2 = await wallet.request_mint(32) + # unlocked quote (no pubkey -> no signature required) + mint_quote2 = await wallet.mint_quote(32, wallet.unit) await pay_if_regtest(mint_quote1.request) await pay_if_regtest(mint_quote2.request) @@ -515,43 +519,6 @@ async def test_ledger_mint_batch_already_issued(ledger: Ledger, wallet: Wallet): assert "already issued" in str(e) -@pytest.mark.asyncio -async def test_ledger_mint_batch_expired_quotes(ledger: Ledger, wallet: Wallet): - """Batch mint with expired quotes should fail.""" - 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) - - q2 = await ledger.crud.get_mint_quote(quote_id=mint_quote2.quote, db=ledger.db) - q2.expiry = 1000000 - await ledger.crud.update_mint_quote(quote=q2, db=ledger.db) - - secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) - outputs, rs = 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) - - try: - await ledger.mint_batch( - PostMintBatchRequest( - quotes=[mint_quote1.quote, mint_quote2.quote], - quote_amounts=[64, 32], - outputs=outputs, - signatures=[sig1, sig2], - ) - ) - assert False, "Expected Exception" - except Exception as e: - assert "expired" in str(e) - - @pytest.mark.asyncio async def test_ledger_mint_batch_empty_quotes(ledger: Ledger, wallet: Wallet): """Empty quotes array should fail.""" @@ -625,10 +592,6 @@ async def test_ledger_mint_batch_atomicity_one_invalid(ledger: Ledger, wallet: W await pay_if_regtest(mint_quote1.request) await pay_if_regtest(mint_quote2.request) - q2 = await ledger.crud.get_mint_quote(quote_id=mint_quote2.quote, db=ledger.db) - q2.expiry = 1000000 - await ledger.crud.update_mint_quote(quote=q2, db=ledger.db) - secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) @@ -636,7 +599,9 @@ async def test_ledger_mint_batch_atomicity_one_invalid(ledger: Ledger, wallet: W 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) + # quote2 is locked but receives an invalid signature, so the whole batch must fail + wrong_privkey = PrivateKey(os.urandom(32)) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, wrong_privkey.secret.hex()) try: await ledger.mint_batch( @@ -649,14 +614,14 @@ async def test_ledger_mint_batch_atomicity_one_invalid(ledger: Ledger, wallet: W ) assert False, "Expected Exception" except Exception as e: - assert "expired" in str(e) + assert "Signature" in str(e) or "signature" in str(e) q1_after = await ledger.crud.get_mint_quote( quote_id=mint_quote1.quote, db=ledger.db ) - assert q1_after.state.value == "PAID", ( - f"Quote1 should still be PAID, got {q1_after.state.value}" - ) + assert ( + q1_after.state.value == "PAID" + ), f"Quote1 should still be PAID, got {q1_after.state.value}" secrets2, rs2, derivation_paths2 = await wallet.generate_secrets_from_to( 10002, 10002