mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 18:44:20 +01:00
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user