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 <lollerfirst@gmail.com>
This commit is contained in:
callebtc
2025-11-19 12:21:07 +01:00
committed by GitHub
parent 0041e3a6f2
commit 37446fbeea
8 changed files with 281 additions and 81 deletions

View File

@@ -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)

View File

@@ -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.",
)

View File

@@ -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,
)