From 2cbe28ec60b3bba796dc595510c7c042b665dc95 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Mon, 3 Nov 2025 16:36:07 +0100 Subject: [PATCH 01/15] chore: work ongoing to remove dprecated paid field in mint-quote --- cashu/core/base.py | 7 ------- cashu/core/models.py | 1 - cashu/mint/auth/crud.py | 8 +++----- cashu/mint/crud.py | 5 ++--- cashu/mint/migrations.py | 36 +++++++++++++++++++++++++++++++++++- cashu/mint/router.py | 2 -- 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index f71235934..b400bd24c 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -457,13 +457,6 @@ def from_row(cls, row: Row): @classmethod def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str): - # BEGIN: BACKWARDS COMPATIBILITY < 0.16.0: "paid" field to "state" - if mint_quote_resp.state is None: - if mint_quote_resp.paid is True: - mint_quote_resp.state = MintQuoteState.paid - elif mint_quote_resp.paid is False: - mint_quote_resp.state = MintQuoteState.unpaid - # END: BACKWARDS COMPATIBILITY < 0.16.0 return cls( quote=mint_quote_resp.quote, method="bolt11", diff --git a/cashu/core/models.py b/cashu/core/models.py index 911a6339c..acd10d9e6 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -155,7 +155,6 @@ class PostMintQuoteResponse(BaseModel): state: Optional[str] # state of the quote (optional for backwards compat) expiry: Optional[int] # expiry of the quote pubkey: Optional[str] = None # NUT-20 quote lock pubkey - paid: Optional[bool] = None # DEPRECATED as per NUT-04 PR #141 @classmethod def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse": diff --git a/cashu/mint/auth/crud.py b/cashu/mint/auth/crud.py index f3b650a18..31d427003 100644 --- a/cashu/mint/auth/crud.py +++ b/cashu/mint/auth/crud.py @@ -387,8 +387,8 @@ async def store_mint_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('mint_quotes')} - (quote, method, request, checking_id, unit, amount, issued, paid, state, created_time, paid_time) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :paid, :state, :created_time, :paid_time) + (quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time) """, { "quote": quote.quote, @@ -398,7 +398,6 @@ async def store_mint_quote( "unit": quote.unit, "amount": quote.amount, "issued": quote.issued, - "paid": quote.paid, "state": quote.state.name, "created_time": db.to_timestamp( db.timestamp_from_seconds(quote.created_time) or "" @@ -467,10 +466,9 @@ async def update_mint_quote( conn: Optional[Connection] = None, ) -> None: await (conn or db).execute( - f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, paid = :paid, state = :state, paid_time = :paid_time WHERE quote = :quote", + f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, state = :state, paid_time = :paid_time WHERE quote = :quote", { "issued": quote.issued, - "paid": quote.paid, "state": quote.state.name, "paid_time": db.to_timestamp( db.timestamp_from_seconds(quote.paid_time) or "" diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 56305844a..74a3104bc 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -604,8 +604,8 @@ async def store_mint_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('mint_quotes')} - (quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time, pubkey) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :pubkey) + (quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time, pubkey) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time, :pubkey) """, { "quote": quote.quote, @@ -614,7 +614,6 @@ async def store_mint_quote( "checking_id": quote.checking_id, "unit": quote.unit, "amount": quote.amount, - "paid": quote.paid, # this is deprecated! we need to store it because we have a NOT NULL constraint | we could also remove the column but sqlite doesn't support that (we would have to make a new table) "issued": quote.issued, # this is deprecated! we need to store it because we have a NOT NULL constraint | we could also remove the column but sqlite doesn't support that (we would have to make a new table) "state": quote.state.value, "created_time": db.to_timestamp( diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 3ebfe129b..4b10031d7 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -741,7 +741,8 @@ async def m018_duplicate_deprecated_keyset_ids(db: Database): if keyset.version_tuple < (0, 15): keyset_copy.id = derive_keyset_id(keyset_copy.public_keys) else: - keyset_copy.id = derive_keyset_id_deprecated(keyset_copy.public_keys) + keyset_copy.id = derive_keyset_id_deprecated( + keyset_copy.public_keys) duplicated_keysets.append(keyset_copy) for keyset in duplicated_keysets: @@ -1137,3 +1138,36 @@ async def recreate_promises_table(db: Database, conn: Connection): # recreate the balance views await create_balance_views(db, conn) + + +async def m029_remove_paid_from_mint_quote(db: Database): + """ + Remove the deprecated 'paid' field from mint_quotes + The 'state' column now fully represents payment status. + """ + + async with db.connect() as conn: + if conn.type == "SQLITE": + await conn.execute("PRAGMA foreign_keys=OFF;") + + # Recreate mint_quotes without 'paid' + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {db.table_with_schema('mint_quotes_new')} AS + SELECT quote, method, request, checking_id, unit, amount, + issued, created_time, paid_time, state, pubkey + FROM {db.table_with_schema('mint_quotes')}; + """ + ) + await conn.execute(f"DROP TABLE {db.table_with_schema('mint_quotes')}") + await conn.execute( + f"ALTER TABLE {db.table_with_schema('mint_quotes_new')} RENAME TO {db.table_with_schema('mint_quotes')}" + ) + + await conn.execute("PRAGMA foreign_keys=ON;") + + else: + # Postgres and Cockroach + await conn.execute( + f"ALTER TABLE {db.table_with_schema('mint_quotes')} DROP COLUMN IF EXISTS paid" + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 75e217ef4..7cb8061c5 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -168,7 +168,6 @@ async def mint_quote( request=quote.request, amount=quote.amount, unit=quote.unit, - paid=quote.paid, # deprecated state=quote.state.value, expiry=quote.expiry, pubkey=quote.pubkey, @@ -195,7 +194,6 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: request=mint_quote.request, amount=mint_quote.amount, unit=mint_quote.unit, - paid=mint_quote.paid, # deprecated state=mint_quote.state.value, expiry=mint_quote.expiry, pubkey=mint_quote.pubkey, From efc361170d100536407186cd1afe55c9a9598b9b Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Tue, 4 Nov 2025 17:57:32 +0100 Subject: [PATCH 02/15] still working --- cashu/mint/ledger.py | 2 +- tests/mint/test_mint_api.py | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c13e52946..d2c6db3d1 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -481,7 +481,7 @@ async def mint( raise TransactionError("Mint quote already pending.") if quote.issued: raise TransactionError("Mint quote already issued.") - if not quote.paid: + if quote.state != MintQuoteState.paid: raise QuoteNotPaidError() previous_state = quote.state diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index deb6b80a9..fae7b791d 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -208,10 +208,6 @@ async def test_mint_quote(ledger: Ledger): assert resp_quote.unit == "sat" assert resp_quote.request == result["request"] - # check if DEPRECATED paid flag is also returned - assert result["paid"] is False - assert resp_quote.paid is False - invoice = bolt11.decode(result["request"]) assert invoice.amount_msat == 100 * 1000 @@ -239,9 +235,6 @@ async def test_mint_quote(ledger: Ledger): assert resp_quote.unit == "sat" assert resp_quote.request == result["request"] - # check if DEPRECATED paid flag is also returned - assert result2["paid"] is True - assert resp_quote.paid is True assert resp_quote.pubkey == "02" + "00" * 32 @@ -350,10 +343,6 @@ async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet): assert resp_quote.unit == "sat" assert resp_quote.request == request - # check if DEPRECATED paid flag is also returned - assert result["paid"] is False - assert resp_quote.paid is False - invoice_obj = bolt11.decode(request) expiry = None From d67b210a028c18098944dc88334cae61afc3fa7d Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Sat, 22 Nov 2025 20:28:15 +0100 Subject: [PATCH 03/15] feat: deprecated paid field and tests all pass --- cashu/core/base.py | 10 ++++++---- cashu/mint/auth/crud.py | 8 +++++--- cashu/mint/migrations.py | 42 ++++++++++++++++++++++++---------------- yourdb.sqlite | 0 4 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 yourdb.sqlite diff --git a/cashu/core/base.py b/cashu/core/base.py index b400bd24c..4e05e1008 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -457,20 +457,22 @@ def from_row(cls, row: Row): @classmethod def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str): + # Ensure default fallbacks for backwards compatibility + resp_state = getattr(mint_quote_resp, "state", None) or "unpaid" return cls( quote=mint_quote_resp.quote, method="bolt11", request=mint_quote_resp.request, checking_id="", - unit=mint_quote_resp.unit + unit=mint_quote_resp.unit or unit, # BACKWARDS COMPATIBILITY mint response < 0.17.0 amount=mint_quote_resp.amount or amount, # BACKWARDS COMPATIBILITY mint response < 0.17.0 - state=MintQuoteState(mint_quote_resp.state), + state=MintQuoteState(resp_state), mint=mint, - expiry=mint_quote_resp.expiry, + expiry=getattr(mint_quote_resp, "expiry", None), created_time=int(time.time()), - pubkey=mint_quote_resp.pubkey, + pubkey=getattr(mint_quote_resp, "pubkey", None), ) @property diff --git a/cashu/mint/auth/crud.py b/cashu/mint/auth/crud.py index 31d427003..c43d1bc62 100644 --- a/cashu/mint/auth/crud.py +++ b/cashu/mint/auth/crud.py @@ -387,8 +387,8 @@ async def store_mint_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('mint_quotes')} - (quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time) + (quote, method, request, checking_id, unit, amount, issued, state, created_time, paid_time, pubkey) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :issued, :state, :created_time, :paid_time, :pubkey) """, { "quote": quote.quote, @@ -405,6 +405,7 @@ async def store_mint_quote( "paid_time": db.to_timestamp( db.timestamp_from_seconds(quote.paid_time) or "" ), + "pubkey": quote.pubkey or "", }, ) @@ -466,13 +467,14 @@ async def update_mint_quote( conn: Optional[Connection] = None, ) -> None: await (conn or db).execute( - f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, state = :state, paid_time = :paid_time WHERE quote = :quote", + f"UPDATE {db.table_with_schema('mint_quotes')} SET issued = :issued, state = :state, paid_time = :paid_time, pubkey = :pubkey WHERE quote = :quote", { "issued": quote.issued, "state": quote.state.name, "paid_time": db.to_timestamp( db.timestamp_from_seconds(quote.paid_time) or "" ), + "pubkey": quote.pubkey or "", "quote": quote.quote, }, ) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 4b10031d7..f5629880b 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1145,29 +1145,37 @@ async def m029_remove_paid_from_mint_quote(db: Database): Remove the deprecated 'paid' field from mint_quotes The 'state' column now fully represents payment status. """ - async with db.connect() as conn: if conn.type == "SQLITE": await conn.execute("PRAGMA foreign_keys=OFF;") # Recreate mint_quotes without 'paid' - await conn.execute( - f""" - CREATE TABLE IF NOT EXISTS {db.table_with_schema('mint_quotes_new')} AS + await conn.execute(""" + CREATE TABLE mint_quotes_new ( + quote TEXT PRIMARY KEY, + method TEXT, + request TEXT, + checking_id TEXT, + unit TEXT, + amount INT, + issued NUM, + created_time NUM, + paid_time NUM, + state TEXT, + pubkey TEXT + ); + """) + + # Copy data + await conn.execute(""" + INSERT INTO mint_quotes_new SELECT quote, method, request, checking_id, unit, amount, issued, created_time, paid_time, state, pubkey - FROM {db.table_with_schema('mint_quotes')}; - """ - ) - await conn.execute(f"DROP TABLE {db.table_with_schema('mint_quotes')}") - await conn.execute( - f"ALTER TABLE {db.table_with_schema('mint_quotes_new')} RENAME TO {db.table_with_schema('mint_quotes')}" - ) + FROM mint_quotes; + """) - await conn.execute("PRAGMA foreign_keys=ON;") + # Swap tables + await conn.execute("DROP TABLE mint_quotes;") + await conn.execute("ALTER TABLE mint_quotes_new RENAME TO mint_quotes;") - else: - # Postgres and Cockroach - await conn.execute( - f"ALTER TABLE {db.table_with_schema('mint_quotes')} DROP COLUMN IF EXISTS paid" - ) + await conn.execute("PRAGMA foreign_keys=ON;") diff --git a/yourdb.sqlite b/yourdb.sqlite new file mode 100644 index 000000000..e69de29bb From 168899a285e519ce486ba69f17d68d677274b7b4 Mon Sep 17 00:00:00 2001 From: Michael <74271024+KvngMikey@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:35:57 +0100 Subject: [PATCH 04/15] Delete yourdb.sqlite --- yourdb.sqlite | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 yourdb.sqlite diff --git a/yourdb.sqlite b/yourdb.sqlite deleted file mode 100644 index e69de29bb..000000000 From 06cdf2f391f89f3d63204eff7574f3f74e9bde8b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 4 Nov 2025 19:39:16 +0100 Subject: [PATCH 05/15] Mint: migration to clean up overly large witnesses (#817) * add migration to clean up overly large witnesses * bump to 0.18.1 --- README.md | 2 +- cashu/core/settings.py | 2 +- cashu/mint/migrations.py | 21 ++++++++- pyproject.toml | 2 +- setup.py | 2 +- tests/mint/test_mint_migrations.py | 74 ++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tests/mint/test_mint_migrations.py diff --git a/README.md b/README.md index ebcef43c8..f6c974d96 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ This command runs the mint on your local computer. Skip this step if you want to ## Docker ``` -docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.0 poetry run mint +docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.1 poetry run mint ``` ## From this repository diff --git a/cashu/core/settings.py b/cashu/core/settings.py index cc5f2e50f..cd0a11ee7 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.18.0" +VERSION = "0.18.1" def find_env_file(): diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index f5629880b..2e63debe6 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -1140,7 +1140,24 @@ async def recreate_promises_table(db: Database, conn: Connection): await create_balance_views(db, conn) -async def m029_remove_paid_from_mint_quote(db: Database): +async def m029_remove_overlong_witness_values(db: Database): + """ + Delete any witness values longer than 1024 characters in proofs tables. + """ + async with db.connect() as conn: + # Clean proofs_used + await conn.execute( + f"UPDATE {db.table_with_schema('proofs_used')} SET witness = NULL " + "WHERE witness IS NOT NULL AND LENGTH(witness) > 1024" + ) + + # Clean proofs_pending (column exists in newer schemas) + await conn.execute( + f"UPDATE {db.table_with_schema('proofs_pending')} SET witness = NULL " + "WHERE witness IS NOT NULL AND LENGTH(witness) > 1024" + ) + +async def m030_remove_paid_from_mint_quote(db: Database): """ Remove the deprecated 'paid' field from mint_quotes The 'state' column now fully represents payment status. @@ -1178,4 +1195,4 @@ async def m029_remove_paid_from_mint_quote(db: Database): await conn.execute("DROP TABLE mint_quotes;") await conn.execute("ALTER TABLE mint_quotes_new RENAME TO mint_quotes;") - await conn.execute("PRAGMA foreign_keys=ON;") + await conn.execute("PRAGMA foreign_keys=ON;") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 20bec5c5e..f3c6c3fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.18.0" +version = "0.18.1" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index af59b1761..4bd51f68d 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.18.0", + version="0.18.1", description="Ecash wallet and mint", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/mint/test_mint_migrations.py b/tests/mint/test_mint_migrations.py new file mode 100644 index 000000000..dd97d2180 --- /dev/null +++ b/tests/mint/test_mint_migrations.py @@ -0,0 +1,74 @@ +import pytest + +from cashu.core.db import Database +from cashu.core.migrations import migrate_databases +from cashu.mint import migrations as mint_migrations + + +@pytest.mark.asyncio +async def test_m029_witness_cleanup(): + db = Database("mint", "./test_data/mig_witness_cleanup") + + # Ensure schema is at latest so tables exist + await migrate_databases(db, mint_migrations) + + long_witness = "a" * 1025 + short_witness = "b" * 10 + + async with db.connect() as conn: + # Insert into proofs_used + await conn.execute( + f""" + INSERT INTO {db.table_with_schema('proofs_used')} (amount, id, c, secret, y, witness, created, melt_quote) + VALUES (1, 'kid', 'c_used_long', 's_used_long', 'y_used_long', :w, {db.timestamp_now}, NULL) + """, + {"w": long_witness}, + ) + await conn.execute( + f""" + INSERT INTO {db.table_with_schema('proofs_used')} (amount, id, c, secret, y, witness, created, melt_quote) + VALUES (1, 'kid', 'c_used_short', 's_used_short', 'y_used_short', :w, {db.timestamp_now}, NULL) + """, + {"w": short_witness}, + ) + + # Insert into proofs_pending + await conn.execute( + f""" + INSERT INTO {db.table_with_schema('proofs_pending')} (amount, id, c, secret, y, witness, created, melt_quote) + VALUES (1, 'kid', 'c_pend_long', 's_pend_long', 'y_pend_long', :w, {db.timestamp_now}, NULL) + """, + {"w": long_witness}, + ) + await conn.execute( + f""" + INSERT INTO {db.table_with_schema('proofs_pending')} (amount, id, c, secret, y, witness, created, melt_quote) + VALUES (1, 'kid', 'c_pend_short', 's_pend_short', 'y_pend_short', :w, {db.timestamp_now}, NULL) + """, + {"w": short_witness}, + ) + + # Run the migration under test directly + await mint_migrations.m029_remove_overlong_witness_values(db) + + # Validate cleanup + async with db.connect() as conn: + row = await conn.fetchone( + f"SELECT witness FROM {db.table_with_schema('proofs_used')} WHERE secret = 's_used_long'" + ) + assert row["witness"] is None + + row = await conn.fetchone( + f"SELECT witness FROM {db.table_with_schema('proofs_used')} WHERE secret = 's_used_short'" + ) + assert row["witness"] == short_witness + + row = await conn.fetchone( + f"SELECT witness FROM {db.table_with_schema('proofs_pending')} WHERE secret = 's_pend_long'" + ) + assert row["witness"] is None + + row = await conn.fetchone( + f"SELECT witness FROM {db.table_with_schema('proofs_pending')} WHERE secret = 's_pend_short'" + ) + assert row["witness"] == short_witness From db32f743ef24dadebbb4ce70f1fdf3ba3c90a6d6 Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:52:18 +0100 Subject: [PATCH 06/15] remove 5 seconds timeout (#819) --- cashu/lightning/blink.py | 2 +- cashu/lightning/clnrest.py | 2 +- cashu/lightning/lnbits.py | 1 + cashu/lightning/lndrest.py | 2 +- cashu/lightning/strike.py | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 61006a28b..18f1143ab 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -72,7 +72,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): "Content-Type": "application/json", }, base_url=self.endpoint, - timeout=15, + timeout=None, ) async def status(self) -> StatusResponse: diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 8fb85a078..fe8c05657 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -80,7 +80,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.cert = settings.mint_clnrest_cert or False self.client = httpx.AsyncClient( - base_url=self.url, verify=self.cert, headers=self.auth + base_url=self.url, verify=self.cert, headers=self.auth, timeout=None, ) self.last_pay_index = 0 diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index c5e0ab636..e4392aeb0 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -40,6 +40,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.client = httpx.AsyncClient( verify=not settings.debug, headers={"X-Api-Key": settings.mint_lnbits_key}, + timeout=None, ) self.ws_url = f"{self.endpoint.replace('http', 'ws', 1)}/api/v1/ws/{settings.mint_lnbits_key}" self.old_api = True diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index fd5990abe..f8057fecc 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -100,7 +100,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.auth = {"Grpc-Metadata-macaroon": self.macaroon} self.client = httpx.AsyncClient( - base_url=self.endpoint, headers=self.auth, verify=self.cert + base_url=self.endpoint, headers=self.auth, verify=self.cert, timeout=None, ) if self.supports_mpp: logger.info("LNDRestWallet enabling MPP feature") diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 7142a8c71..3745e3eba 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -124,6 +124,7 @@ def __init__(self, unit: Unit, **kwargs): self.client = httpx.AsyncClient( verify=not settings.debug, headers=bearer_auth, + timeout=None, ) async def status(self) -> StatusResponse: From 0aac99142b1ab7ecd582e8808fc5d590870c9486 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:21:07 +0100 Subject: [PATCH 07/15] 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 --- cashu/core/errors.py | 2 +- cashu/mint/auth/crud.py | 25 +-- cashu/mint/crud.py | 23 +-- cashu/mint/ledger.py | 2 +- cashu/mint/verification.py | 10 +- tests/mint/test_mint_db_operations.py | 20 +- tests/mint/test_mint_melt.py | 273 ++++++++++++++++++++++---- tests/mint/test_mint_operations.py | 9 +- 8 files changed, 282 insertions(+), 82 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 8b0c3cc23..601b4d36d 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -20,7 +20,7 @@ def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): class OutputsAlreadySignedError(CashuError): - detail = "outputs have already been signed before." + detail = "outputs have already been signed before or are pending." code = 10002 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): diff --git a/cashu/mint/auth/crud.py b/cashu/mint/auth/crud.py index c43d1bc62..651b6948a 100644 --- a/cashu/mint/auth/crud.py +++ b/cashu/mint/auth/crud.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional from ...core.base import ( + BlindedMessage, BlindedSignature, MeltQuote, MintKeyset, @@ -129,7 +130,7 @@ async def store_promise( ) -> None: ... @abstractmethod - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -138,13 +139,13 @@ async def get_promise( ) -> Optional[BlindedSignature]: ... @abstractmethod - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: ... + ) -> List[BlindedMessage]: ... class AuthLedgerCrudSqlite(AuthLedgerCrud): @@ -234,7 +235,7 @@ async def store_promise( }, ) - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -248,15 +249,15 @@ async def get_promise( """, {"b_": str(b_)}, ) - return BlindedSignature.from_row(row) if row else None + return BlindedSignature.from_row(row) if row else None # type: ignore - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: + ) -> List[BlindedMessage]: rows = await (conn or db).fetchall( f""" SELECT * from {db.table_with_schema('promises')} @@ -264,7 +265,7 @@ async def get_promises( """, {f"b_{i}": b_s[i] for i in range(len(b_s))}, ) - return [BlindedSignature.from_row(r) for r in rows] if rows else [] + return [BlindedMessage.from_row(r) for r in rows] if rows else [] async def invalidate_proof( self, @@ -303,7 +304,7 @@ async def get_all_melt_quotes_from_pending_proofs( SELECT * from {db.table_with_schema('melt_quotes')} WHERE quote in (SELECT DISTINCT melt_quote FROM {db.table_with_schema('proofs_pending')}) """ ) - return [MeltQuote.from_row(r) for r in rows] + return [MeltQuote.from_row(r) for r in rows] if rows else [] # type: ignore async def get_pending_proofs_for_quote( self, @@ -441,7 +442,7 @@ async def get_mint_quote( ) if row is None: return None - return MintQuote.from_row(row) if row else None + return MintQuote.from_row(row) if row else None # type: ignore async def get_mint_quote_by_request( self, @@ -457,7 +458,7 @@ async def get_mint_quote_by_request( """, {"request": request}, ) - return MintQuote.from_row(row) if row else None + return MintQuote.from_row(row) if row else None # type: ignore async def update_mint_quote( self, @@ -549,7 +550,7 @@ async def get_melt_quote( ) if row is None: return None - return MeltQuote.from_row(row) if row else None + return MeltQuote.from_row(row) if row else None # type: ignore async def update_melt_quote( self, diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 74a3104bc..844fd48e0 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -206,7 +206,7 @@ async def get_blind_signatures_melt_id( ) -> List[BlindedSignature]: ... @abstractmethod - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -215,13 +215,13 @@ async def get_promise( ) -> Optional[BlindedSignature]: ... @abstractmethod - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: ... + ) -> List[BlindedMessage]: ... @abstractmethod async def store_mint_quote( @@ -451,7 +451,7 @@ async def update_blinded_message_signature( }, ) - async def get_promise( + async def get_blind_signature( self, *, db: Database, @@ -467,21 +467,22 @@ async def get_promise( ) return BlindedSignature.from_row(row) if row else None # type: ignore - async def get_promises( + async def get_outputs( self, *, db: Database, b_s: List[str], conn: Optional[Connection] = None, - ) -> List[BlindedSignature]: + ) -> List[BlindedMessage]: rows = await (conn or db).fetchall( f""" SELECT * from {db.table_with_schema('promises')} - WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])}) AND c_ IS NOT NULL + WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])}) """, {f"b_{i}": b_s[i] for i in range(len(b_s))}, ) - return [BlindedSignature.from_row(r) for r in rows] if rows else [] # type: ignore + # could be unsigned (BlindedMessage) or signed (BlindedSignature), but BlindedMessage is a subclass of BlindedSignature + return [BlindedMessage.from_row(r) for r in rows] if rows else [] # type: ignore async def invalidate_proof( self, @@ -1036,7 +1037,7 @@ async def get_last_balance_log_entry( ) return MintBalanceLogEntry.from_row(row) if row else None - + async def get_melt_quotes_by_checking_id( self, *, @@ -1049,6 +1050,6 @@ async def get_melt_quotes_by_checking_id( SELECT * FROM {db.table_with_schema('melt_quotes')} WHERE checking_id = :checking_id """, - {"checking_id": checking_id} + {"checking_id": checking_id}, ) - return [MeltQuote.from_row(row) for row in results] # type: ignore \ No newline at end of file + return [MeltQuote.from_row(row) for row in results] # type: ignore diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d2c6db3d1..5781ea34d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1086,7 +1086,7 @@ async def restore( async with self.db.get_connection() as conn: for output in outputs: logger.trace(f"looking for promise: {output}") - promise = await self.crud.get_promise( + promise = await self.crud.get_blind_signature( b_=output.B_, db=self.db, conn=conn ) if promise is not None: diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 3fdc9dfd8..acccb0654 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -133,17 +133,19 @@ async def _verify_outputs( if not self._verify_no_duplicate_outputs(outputs): raise TransactionDuplicateOutputsError() # verify that outputs have not been signed previously - signed_before = await self._check_outputs_issued_before(outputs, conn) + signed_before = await self._check_outputs_pending_or_issued_before( + outputs, conn + ) if any(signed_before): raise OutputsAlreadySignedError() logger.trace(f"Verified {len(outputs)} outputs.") - async def _check_outputs_issued_before( + async def _check_outputs_pending_or_issued_before( self, outputs: List[BlindedMessage], conn: Optional[Connection] = None, ) -> List[bool]: - """Checks whether the provided outputs have previously been signed by the mint + """Checks whether the provided outputs have previously stored (as blinded messages) been signed (as blind signatures) by the mint (which would lead to a duplication error later when trying to store these outputs again). Args: @@ -153,7 +155,7 @@ async def _check_outputs_issued_before( result (List[bool]): Whether outputs are already present in the database. """ async with self.db.get_connection(conn) as conn: - promises = await self.crud.get_promises( + promises = await self.crud.get_outputs( b_s=[output.B_ for output in outputs], db=self.db, conn=conn ) return [True if promise else False for promise in promises] diff --git a/tests/mint/test_mint_db_operations.py b/tests/mint/test_mint_db_operations.py index b3d19ee0b..8719526b6 100644 --- a/tests/mint/test_mint_db_operations.py +++ b/tests/mint/test_mint_db_operations.py @@ -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) diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 6ad4ce3d6..ef7b13f5d 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -4,7 +4,7 @@ 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 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' - - 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.") - + 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.", + ) diff --git a/tests/mint/test_mint_operations.py b/tests/mint/test_mint_operations.py index 4312a0cb7..51bbca849 100644 --- a/tests/mint/test_mint_operations.py +++ b/tests/mint/test_mint_operations.py @@ -2,6 +2,7 @@ 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, ) From 314579ceda31b77a3a63de2d1394a00a4e5e0456 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:48:52 +0100 Subject: [PATCH 08/15] handle USDT fees (#829) --- cashu/lightning/strike.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 3745e3eba..72f91e853 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -99,6 +99,7 @@ def fee_int( unit: Unit, ) -> int: fee_str = strike_quote.totalFee.amount + fee: int = 0 if strike_quote.totalFee.currency == self.currency_map[Unit.sat]: if unit == Unit.sat: fee = int(float(fee_str) * 1e8) @@ -107,8 +108,13 @@ def fee_int( elif strike_quote.totalFee.currency in [ self.currency_map[Unit.usd], self.currency_map[Unit.eur], + USDT, ]: fee = int(float(fee_str) * 100) + else: + raise Exception( + f"Unexpected currency {strike_quote.totalFee.currency} in fee" + ) return fee def __init__(self, unit: Unit, **kwargs): @@ -154,7 +160,7 @@ async def status(self) -> StatusResponse: balance=Amount.from_float(float(balance["total"]), self.unit), ) - # if no the unit is USD but no USD balance was found, we try USDT + # if the unit is USD but no USD balance was found, we try USDT if self.unit == Unit.usd: for balance in data: if balance["currency"] == USDT: From 1d8a093ececd70b4169f197b0b2f104640c0df5b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:58:55 +0100 Subject: [PATCH 09/15] description of settings (#823) --- .env.example | 3 ++- cashu/core/settings.py | 26 ++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 63b134328..71d032a4b 100644 --- a/.env.example +++ b/.env.example @@ -118,10 +118,11 @@ LIGHTNING_FEE_PERCENT=1.0 LIGHTNING_RESERVE_FEE_MIN=2000 # Mint Management gRPC service configurations +# Run the script in cashu/mint/management_rpc/generate_certificates.sh to generate certificates for the server and client. +# Use `poetry run mint-cli get-info` to test the connection. MINT_RPC_SERVER_ENABLE=FALSE MINT_RPC_SERVER_ADDR=localhost MINT_RPC_SERVER_PORT=8086 -MINT_RPC_SERVER_MUTUAL_TLS=TRUE MINT_RPC_SERVER_KEY="./server_private.pem" MINT_RPC_SERVER_CERT="./server_cert.pem" MINT_RPC_SERVER_CA="./ca_cert.pem" diff --git a/cashu/core/settings.py b/cashu/core/settings.py index cd0a11ee7..1b0ed8e5e 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -208,13 +208,27 @@ class MintInformation(CashuSettings): class MintManagementRPCSettings(MintSettings): - mint_rpc_server_enable: bool = Field(default=False) - mint_rpc_server_ca: str = Field(default=None) - mint_rpc_server_cert: str = Field(default=None) + mint_rpc_server_enable: bool = Field( + default=False, description="Enable the management RPC server." + ) + mint_rpc_server_ca: str = Field( + default=None, + description="CA certificate file path for the management RPC server.", + ) + mint_rpc_server_cert: str = Field( + default=None, + description="Server certificate file path for the management RPC server.", + ) mint_rpc_server_key: str = Field(default=None) - mint_rpc_server_addr: str = Field(default="localhost") - mint_rpc_server_port: int = Field(default=8086) - mint_rpc_server_mutual_tls: bool = Field(default=True) + mint_rpc_server_addr: str = Field( + default="localhost", description="Address for the management RPC server." + ) + mint_rpc_server_port: int = Field( + default=8086, gt=0, lt=65536, description="Port for the management RPC server." + ) + mint_rpc_server_mutual_tls: bool = Field( + default=True, description="Require client certificates." + ) class WalletSettings(CashuSettings): From 0504f53758610d2b3e79c2cd66355ae3845ced65 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:06:20 +0100 Subject: [PATCH 10/15] update fees automatically (#830) --- cashu/core/base.py | 2 +- cashu/wallet/crud.py | 3 ++- cashu/wallet/wallet.py | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 4e05e1008..197ab9e0b 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -728,7 +728,7 @@ def __init__( self.valid_from = valid_from self.valid_to = valid_to self.first_seen = first_seen - self.active = active + self.active = bool(active) self.mint_url = mint_url self.input_fee_ppk = input_fee_ppk diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index bc34644f7..63bdc59dc 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -243,12 +243,13 @@ async def update_keyset( await (conn or db).execute( """ UPDATE keysets - SET active = :active + SET active = :active, input_fee_ppk = :input_fee_ppk WHERE id = :id """, { "active": keyset.active, "id": keyset.id, + "input_fee_ppk": keyset.input_fee_ppk, }, ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 6ffeb97b5..fde770e58 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -343,6 +343,9 @@ async def load_mint_keysets(self, force_old_keysets=False): ].input_fee_ppk = mint_keyset.input_fee_ppk changed = True if changed: + logger.debug( + f"Updating mint keyset: {mint_keyset.id} ({mint_keyset.unit}) fee: {mint_keyset.input_fee_ppk} ppk, active: {mint_keyset.active}" + ) await update_keyset( keyset=keysets_in_db_dict[mint_keyset.id], db=self.db ) From ff3fabddeaa176fbb15e6a9e0b78d08e74ec26db Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:06:33 +0100 Subject: [PATCH 11/15] turn on fees by default (#831) --- .env.example | 4 ++++ cashu/core/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 71d032a4b..bcd942bc7 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" # In this example, we have 2 keysets for sat, 1 for msat and 1 for usd # MINT_DERIVATION_PATH_LIST=["m/0'/0'/0'", "m/0'/0'/1'", "m/0'/1'/0'", "m/0'/2'/0'"] +# Input fee per 1000 inputs (ppk = per kilo). +# e.g. for 100 ppk: up to 10 inputs = 1 sat / 1 cent fee, for up to 20 inputs = 2 sat / 2 cent fee +MINT_INPUT_FEE_PPK=100 + # To use SQLite, choose a directory to store the database MINT_DATABASE=data/mint # To use PostgreSQL, set the connection string diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 1b0ed8e5e..79caa74fb 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -61,7 +61,7 @@ class MintSettings(CashuSettings): mint_test_database: str = Field(default="test_data/test_mint") mint_max_secret_length: int = Field(default=1024) - mint_input_fee_ppk: int = Field(default=0) + mint_input_fee_ppk: int = Field(default=100) mint_disable_melt_on_error: bool = Field(default=False) mint_regular_tasks_interval_seconds: int = Field( From 9fbf83f32554048cc2dfab746c74fd729381a206 Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:06:57 +0100 Subject: [PATCH 12/15] fix: increment fees for correct keysets (#825) * fix: increment fees for correct keysets * pythonic short --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- cashu/mint/ledger.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 5781ea34d..a42eae951 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -224,8 +224,11 @@ async def _invalidate_proofs( proofs (List[Proof]): Proofs to add to known secret table. conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. """ - # sum_proofs = sum([p.amount for p in proofs]) - fees_proofs = self.get_fees_for_proofs(proofs) + # Group proofs by keyset_id to calculate fees per keyset + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + async with self.db.get_connection(conn) as conn: # store in db for p in proofs: @@ -241,9 +244,18 @@ async def _invalidate_proofs( Y=p.Y, state=ProofSpentState.spent, witness=p.witness or None ) ) - await self.crud.bump_keyset_fees_paid( - keyset=self.keyset, amount=fees_proofs, db=self.db, conn=conn - ) + + # Calculate and increment fees for each keyset separately + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees = self.get_fees_for_proofs(keyset_proofs) + if keyset_fees > 0: + logger.trace(f"Adding fees {keyset_fees} to keyset {keyset_id}") + await self.crud.bump_keyset_fees_paid( + keyset=self.keysets[keyset_id], + amount=keyset_fees, + db=self.db, + conn=conn, + ) async def _generate_change_promises( self, From 3934fba359733bf254be084891d5a7d8855a98c7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:27:03 +0100 Subject: [PATCH 13/15] bump to 0.18.2 (#832) --- README.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6c974d96..6c7ec23fe 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ This command runs the mint on your local computer. Skip this step if you want to ## Docker ``` -docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.1 poetry run mint +docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.2 poetry run mint ``` ## From this repository diff --git a/pyproject.toml b/pyproject.toml index f3c6c3fdc..c1a82c13b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.18.1" +version = "0.18.2" description = "Ecash wallet and mint" authors = ["calle "] license = "MIT" From a5e2dcda555a100ff79fea82be2dbd636ef135a2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:30:33 +0100 Subject: [PATCH 14/15] bump to 0.18.2 again (#834) --- cashu/core/settings.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 79caa74fb..9a7029af8 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ env = Env() -VERSION = "0.18.1" +VERSION = "0.18.2" def find_env_file(): diff --git a/setup.py b/setup.py index 4bd51f68d..e5a3a243b 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="cashu", - version="0.18.1", + version="0.18.2", description="Ecash wallet and mint", long_description=long_description, long_description_content_type="text/markdown", From 738dd14152da8b485c42accf8a7811845b716435 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:22:37 +0100 Subject: [PATCH 15/15] revert error message change from #828 (#835) --- cashu/core/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 601b4d36d..8b0c3cc23 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -20,7 +20,7 @@ def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): class OutputsAlreadySignedError(CashuError): - detail = "outputs have already been signed before or are pending." + detail = "outputs have already been signed before." code = 10002 def __init__(self, detail: Optional[str] = None, code: Optional[int] = None):