fix: unique ('PENDING', checking_ID) for melt quotes (#800)

* fix: only one melt_quote with shared checking_id is allowed to be in a pending state.

fix mypy

add comprehensive tests

remove SQL unique index

remove test db constraint

fix lock statement

remove `test_regtest_pay_mpp_incomplete_payment`

format

* remove `test_set_melt_quote_pending_with_outputs`

* client self-rug mitigation

* fix

* format

* DB level check: error if payment reference paid or pending

* fix test

* comments

* restore

* restore

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
lollerfirst
2025-10-28 11:47:23 +01:00
committed by GitHub
parent 184f8ba6ca
commit 4a4b7f79f7
6 changed files with 431 additions and 8 deletions

View File

@@ -281,6 +281,15 @@ class LedgerCrud(ABC):
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[MeltQuote]: ... ) -> Optional[MeltQuote]: ...
@abstractmethod
async def get_melt_quotes_by_checking_id(
self,
*,
checking_id: str,
db: Database,
conn: Optional[Connection] = None,
) -> List[MeltQuote]: ...
@abstractmethod @abstractmethod
async def get_melt_quote_by_request( async def get_melt_quote_by_request(
self, self,
@@ -1028,3 +1037,19 @@ class LedgerCrudSqlite(LedgerCrud):
) )
return MintBalanceLogEntry.from_row(row) if row else None return MintBalanceLogEntry.from_row(row) if row else None
async def get_melt_quotes_by_checking_id(
self,
*,
checking_id: str,
db: Database,
conn: Optional[Connection] = None,
) -> List[MeltQuote]:
results = await (conn or db).fetchall(
f"""
SELECT * FROM {db.table_with_schema('melt_quotes')}
WHERE checking_id = :checking_id
""",
{"checking_id": checking_id}
)
return [MeltQuote.from_row(row) for row in results] # type: ignore

View File

@@ -204,18 +204,26 @@ class DbWriteHelper:
quote (MeltQuote): Melt quote to set as pending. quote (MeltQuote): Melt quote to set as pending.
""" """
quote_copy = quote.copy() quote_copy = quote.copy()
if not quote.checking_id:
raise TransactionError("Melt quote doesn't have checking ID.")
async with self.db.get_connection( async with self.db.get_connection(
lock_table="melt_quotes", lock_table="melt_quotes",
lock_select_statement=f"quote='{quote.quote}'", lock_select_statement=f"checking_id='{quote.checking_id}'",
) as conn: ) as conn:
# get melt quote from db and check if it is already pending # get all melt quotes with same checking_id from db and check if there is one already pending or paid
quote_db = await self.crud.get_melt_quote( quotes_db = await self.crud.get_melt_quotes_by_checking_id(
quote_id=quote.quote, db=self.db, conn=conn checking_id=quote.checking_id, db=self.db, conn=conn
) )
if not quote_db: if len(quotes_db) == 0:
raise TransactionError("Melt quote not found.") raise TransactionError("Melt quote not found.")
if quote_db.pending: if any(
raise TransactionError("Melt quote already pending.") [
quote.state == MeltQuoteState.pending
or quote.state == MeltQuoteState.paid
for quote in quotes_db
]
):
raise TransactionError("Melt quote already paid or pending.")
# set the quote as pending # set the quote as pending
quote_copy.state = MeltQuoteState.pending quote_copy.state = MeltQuoteState.pending
await self.crud.update_melt_quote(quote=quote_copy, db=self.db, conn=conn) await self.crud.update_melt_quote(quote=quote_copy, db=self.db, conn=conn)

View File

@@ -341,3 +341,143 @@ async def test_db_update_melt_quote_state(wallet: Wallet, ledger: Ledger):
), ),
"Cannot change state of a paid melt quote.", "Cannot change state of a paid melt quote.",
) )
# Tests for get_melt_quotes_by_checking_id CRUD method
@pytest.mark.asyncio
async def test_get_melt_quotes_by_checking_id_empty(ledger: Ledger):
"""Test that get_melt_quotes_by_checking_id returns empty list for non-existent checking_id."""
quotes = await ledger.crud.get_melt_quotes_by_checking_id(
checking_id="non_existent_id",
db=ledger.db
)
assert quotes == []
@pytest.mark.asyncio
async def test_get_melt_quotes_by_checking_id_single(ledger: Ledger):
"""Test that get_melt_quotes_by_checking_id returns a single quote when only one exists."""
from cashu.core.base import MeltQuote
checking_id = "test_checking_id_single"
quote = MeltQuote(
quote="quote_id_1",
method="bolt11",
request="lnbc123",
checking_id=checking_id,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote, db=ledger.db)
quotes = await ledger.crud.get_melt_quotes_by_checking_id(
checking_id=checking_id,
db=ledger.db
)
assert len(quotes) == 1
assert quotes[0].quote == "quote_id_1"
assert quotes[0].checking_id == checking_id
@pytest.mark.asyncio
async def test_get_melt_quotes_by_checking_id_multiple(ledger: Ledger):
"""Test that get_melt_quotes_by_checking_id returns all quotes with the same checking_id."""
from cashu.core.base import MeltQuote
checking_id = "test_checking_id_multiple"
quote1 = MeltQuote(
quote="quote_id_m1",
method="bolt11",
request="lnbc123",
checking_id=checking_id,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_m2",
method="bolt11",
request="lnbc456",
checking_id=checking_id,
unit="sat",
amount=200,
fee_reserve=2,
state=MeltQuoteState.paid,
)
quote3 = MeltQuote(
quote="quote_id_m3",
method="bolt11",
request="lnbc789",
checking_id=checking_id,
unit="sat",
amount=300,
fee_reserve=3,
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)
await ledger.crud.store_melt_quote(quote=quote3, db=ledger.db)
quotes = await ledger.crud.get_melt_quotes_by_checking_id(
checking_id=checking_id,
db=ledger.db
)
assert len(quotes) == 3
quote_ids = {q.quote for q in quotes}
assert quote_ids == {"quote_id_m1", "quote_id_m2", "quote_id_m3"}
@pytest.mark.asyncio
async def test_get_melt_quotes_by_checking_id_different_checking_ids(ledger: Ledger):
"""Test that get_melt_quotes_by_checking_id only returns quotes with the specified checking_id."""
from cashu.core.base import MeltQuote
checking_id_1 = "test_checking_id_diff_1"
checking_id_2 = "test_checking_id_diff_2"
quote1 = MeltQuote(
quote="quote_id_diff_1",
method="bolt11",
request="lnbc123",
checking_id=checking_id_1,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_diff_2",
method="bolt11",
request="lnbc456",
checking_id=checking_id_2,
unit="sat",
amount=200,
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)
quotes_1 = await ledger.crud.get_melt_quotes_by_checking_id(
checking_id=checking_id_1,
db=ledger.db
)
assert len(quotes_1) == 1
assert quotes_1[0].quote == "quote_id_diff_1"
quotes_2 = await ledger.crud.get_melt_quotes_by_checking_id(
checking_id=checking_id_2,
db=ledger.db
)
assert len(quotes_2) == 1
assert quotes_2[0].quote == "quote_id_diff_2"

View File

@@ -753,3 +753,55 @@ async def test_promises_fk_constraints_enforced(ledger: Ledger):
) )
# Done. This test only checks FK enforcement paths. # Done. This test only checks FK enforcement paths.
# Tests for unique pending melt quote checking_id - concurrency and migration
@pytest.mark.asyncio
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",
method="bolt11",
request="lnbc123",
checking_id=checking_id,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_conc_2",
method="bolt11",
request="lnbc456",
checking_id=checking_id,
unit="sat",
amount=200,
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
)
# 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

@@ -12,7 +12,10 @@ from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT
from tests.helpers import ( from tests.helpers import (
get_real_invoice,
is_fake,
is_regtest, is_regtest,
pay_if_regtest,
) )
SEED = "TEST_PRIVATE_KEY" SEED = "TEST_PRIVATE_KEY"
@@ -369,3 +372,197 @@ async def test_mint_melt_different_units(ledger: Ledger, wallet: Wallet):
mint_resp = await ledger.mint(outputs=outputs, quote_id=sat_mint_quote.quote) mint_resp = await ledger.mint(outputs=outputs, quote_id=sat_mint_quote.quote)
assert len(mint_resp) == len(outputs) assert len(mint_resp) == len(outputs)
# Tests for unique pending melt quote checking_id constraint
@pytest.mark.asyncio
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",
request="lnbc123",
checking_id="temp_id",
unit="sat",
amount=100,
fee_reserve=1,
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")
except TransactionError as e:
assert "Melt quote doesn't have checking ID" in str(e)
@pytest.mark.asyncio
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",
request="lnbc123",
checking_id=checking_id,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_dup_second",
method="bolt11",
request="lnbc456",
checking_id=checking_id,
unit="sat",
amount=200,
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)
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)
assert quote2_db.state == MeltQuoteState.unpaid
@pytest.mark.asyncio
async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledger):
"""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",
request="lnbc123",
checking_id=checking_id_1,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_allow_2",
method="bolt11",
request="lnbc456",
checking_id=checking_id_2,
unit="sat",
amount=200,
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)
assert quote1_db.state == MeltQuoteState.pending
assert quote2_db.state == MeltQuoteState.pending
@pytest.mark.asyncio
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",
request="lnbc123",
checking_id=checking_id,
unit="sat",
amount=100,
fee_reserve=1,
state=MeltQuoteState.unpaid,
)
quote2 = MeltQuote(
quote="quote_id_unset_second",
method="bolt11",
request="lnbc456",
checking_id=checking_id,
unit="sat",
amount=200,
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)
# 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)
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.")
# Verify the second quote is unpaid
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):
mint_quote1 = await wallet.request_mint(1024)
mint_quote2 = await wallet.request_mint(1024)
await pay_if_regtest(mint_quote1.request)
await pay_if_regtest(mint_quote2.request)
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']
# 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'
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

@@ -8,7 +8,6 @@ import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Method, Proof from cashu.core.base import MeltQuote, MeltQuoteState, Method, Proof
from cashu.lightning.base import PaymentResponse from cashu.lightning.base import PaymentResponse
from cashu.lightning.clnrest import CLNRestWallet
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT
@@ -85,6 +84,7 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
assert wallet.balance == 64 assert wallet.balance == 64
'''
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest") @pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger): async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger):
@@ -147,6 +147,7 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger
await asyncio.sleep(2) await asyncio.sleep(2)
assert wallet.balance <= 384 - 64 assert wallet.balance <= 384 - 64
'''
@pytest.mark.asyncio @pytest.mark.asyncio