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,
) -> Optional[MeltQuote]: ...
@abstractmethod
async def get_melt_quotes_by_checking_id(
self,
*,
checking_id: str,
db: Database,
conn: Optional[Connection] = None,
) -> List[MeltQuote]: ...
@abstractmethod
async def get_melt_quote_by_request(
self,
@@ -1028,3 +1037,19 @@ class LedgerCrudSqlite(LedgerCrud):
)
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_copy = quote.copy()
if not quote.checking_id:
raise TransactionError("Melt quote doesn't have checking ID.")
async with self.db.get_connection(
lock_table="melt_quotes",
lock_select_statement=f"quote='{quote.quote}'",
lock_select_statement=f"checking_id='{quote.checking_id}'",
) as conn:
# get melt quote from db and check if it is already pending
quote_db = await self.crud.get_melt_quote(
quote_id=quote.quote, db=self.db, conn=conn
# get all melt quotes with same checking_id from db and check if there is one already pending or paid
quotes_db = await self.crud.get_melt_quotes_by_checking_id(
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.")
if quote_db.pending:
raise TransactionError("Melt quote already pending.")
if any(
[
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
quote_copy.state = MeltQuoteState.pending
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.",
)
# 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.
# 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 tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
is_fake,
is_regtest,
pay_if_regtest,
)
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)
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.lightning.base import PaymentResponse
from cashu.lightning.clnrest import CLNRestWallet
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
@@ -85,6 +84,7 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
assert wallet.balance == 64
'''
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
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)
assert wallet.balance <= 384 - 64
'''
@pytest.mark.asyncio