From 3bdb52096ca5d99815a54a9be85bc68b4366af6a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 25 May 2026 07:17:06 +0000 Subject: [PATCH 1/3] fix(mint): clean up blank outputs on melt change early-return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_generate_change_promises` already deletes the wallet's blank NUT-08 outputs from `promises` after signing them. But when the function takes the early-return branch (overpaid_fee <= 0 or no outputs supplied) those rows were left behind with `c_ IS NULL` — and later operations that re-derive the same `B_` (notably NUT-13 seed restore) collide with them and surface as `OutputsArePendingError`. Also soften the negative-overpaid_fee log to debug with a message that explains the case: in practice the backend can report a fee greater than the wallet-provided reserve (e.g. an LNbits backend skimming a service fee on top of the routing fee), so an error-level log here is noisy and misleading. Adds two regression tests covering both the `overpaid_fee == 0` and `overpaid_fee < 0` branches. --- cashu/mint/ledger.py | 12 +++- tests/mint/test_mint_melt.py | 132 ++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index ffa651e24..0b5ab381a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -253,8 +253,16 @@ async def _generate_change_promises( if overpaid_fee <= 0 or outputs is None: if overpaid_fee < 0: - logger.error( - f"Overpaid fee is negative ({overpaid_fee}). This should not happen." + logger.debug( + f"No change to return: backend fee {fee_paid} exceeds wallet's " + f"fee reserve {fee_provided} by {-overpaid_fee}." + ) + # Clean up the blank outputs the wallet sent for fee return; otherwise + # they remain in `promises` with c_ IS NULL and collide with later + # operations that re-derive the same B_ (e.g. NUT-13 seed restore). + if melt_id: + await self.crud.delete_blinded_messages_melt_id( + melt_id=melt_id, db=self.db ) return [] diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 33e475d55..9bc3e63f2 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -3,7 +3,15 @@ import pytest import pytest_asyncio -from cashu.core.base import MeltQuote, MeltQuoteState, MintQuoteState, Proof +from cashu.core.base import ( + Amount, + MeltQuote, + MeltQuoteState, + Method, + MintQuoteState, + Proof, + Unit, +) from cashu.core.errors import ( LightningPaymentFailedError, OutputsAlreadySignedError, @@ -11,7 +19,7 @@ ) from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.settings import settings -from cashu.lightning.base import PaymentResult +from cashu.lightning.base import PaymentResponse, PaymentResult from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -858,3 +866,123 @@ async def test_melt_with_wrong_unit_proofs(ledger: Ledger, wallet: Wallet): ), "proof unit usd does not match quote unit sat" ) + + +INVOICE_64_SAT = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" + + +async def _run_melt_with_patched_fee( + wallet: Wallet, + ledger: Ledger, + monkeypatch, + fee_paid_sat_offset: int, +): + """Drives a successful melt where the backend reports a controlled fee. + + `fee_paid_sat_offset` is added to `fee_reserve_provided`: + - 0 → overpaid_fee == 0 + - >0 → overpaid_fee < 0 + Both hit the early-return branch in `_generate_change_promises`. + Returns (melt_quote_id, response). + """ + settings.fakewallet_payment_state = PaymentResult.SETTLED.name + # Clear the FakeWallet pay_invoice override so our monkeypatch is what runs. + settings.fakewallet_pay_invoice_state = "" + + mint_quote = await wallet.request_mint(100) + proofs = await wallet.mint(amount=100, quote_id=mint_quote.quote) + + melt_quote = await wallet.melt_quote(INVOICE_64_SAT) + + total_provided = sum(p.amount for p in proofs) + input_fees = ledger.get_fees_for_proofs(proofs) + fee_reserve_provided = total_provided - melt_quote.amount - input_fees + fee_paid_sat = fee_reserve_provided + fee_paid_sat_offset + + backend = ledger.backends[Method.bolt11][Unit.sat] + + async def patched_pay_invoice(quote: MeltQuote, fee_limit_msat: int): + return PaymentResponse( + result=PaymentResult.SETTLED, + checking_id=quote.checking_id or "fake_checking_id", + fee=Amount(unit=Unit.sat, amount=fee_paid_sat), + preimage="0" * 64, + ) + + monkeypatch.setattr(backend, "pay_invoice", patched_pay_invoice) + + n_change_outputs = 4 + change_secrets, change_rs, _ = await wallet.generate_n_secrets( + n_change_outputs, skip_bump=True + ) + change_outputs, _ = wallet._construct_outputs( + n_change_outputs * [1], change_secrets, change_rs + ) + + response = await ledger.melt( + proofs=proofs, quote=melt_quote.quote, outputs=change_outputs + ) + return melt_quote.quote, response + + +@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_overpaid_fee_zero_leaves_no_orphan_blank_outputs( + wallet, ledger: Ledger, monkeypatch +): + """A successful melt where fee_paid == fee_reserve_provided takes the + early-return branch in `_generate_change_promises`. The wallet's blank + NUT-08 outputs (already inserted into `promises` with c_ IS NULL before + the LN payment) must not be left behind as orphans — they collide with + later mint/swap operations that re-derive the same B_ (e.g. after a + NUT-13 seed restore) and surface as `OutputsArePendingError`. + """ + melt_id, response = await _run_melt_with_patched_fee( + wallet, ledger, monkeypatch, fee_paid_sat_offset=0 + ) + + assert response.state == MeltQuoteState.paid.value + assert not response.change + + orphans = await ledger.crud.get_blinded_messages_melt_id( + db=ledger.db, melt_id=melt_id + ) + assert orphans == [], ( + f"Expected no orphan blank outputs for melt {melt_id}, " + f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}" + ) + + +@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_overpaid_fee_negative_leaves_no_orphan_blank_outputs( + wallet, ledger: Ledger, monkeypatch +): + """Same orphan-row regression as the zero case, but for the + `overpaid_fee < 0` branch — when the backend reports a fee greater than + the reserve the wallet provided. Realistic when the backend doesn't + strictly enforce the `max_fee` we pass it (e.g. an LNbits backend + skimming a service fee on top of the routing fee). + """ + melt_id, response = await _run_melt_with_patched_fee( + wallet, ledger, monkeypatch, fee_paid_sat_offset=1 + ) + + assert response.state == MeltQuoteState.paid.value + assert not response.change + + orphans = await ledger.crud.get_blinded_messages_melt_id( + db=ledger.db, melt_id=melt_id + ) + assert orphans == [], ( + f"Expected no orphan blank outputs for melt {melt_id}, " + f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}" + ) + + From c0045f40b158c5279392104c66f59f73e8b7282d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 25 May 2026 07:26:17 +0000 Subject: [PATCH 2/3] refactor(test): collapse melt early-return tests via parametrize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `overpaid_fee == 0` / `overpaid_fee < 0` cases differ only in one integer offset, so collapse them into a single parametrized test and drop the `_run_melt_with_patched_fee` helper / `INVOICE_64_SAT` module constant — the invoice is inlined to match the style of the other tests in this file. --- tests/mint/test_mint_melt.py | 100 +++++++++++------------------------ 1 file changed, 31 insertions(+), 69 deletions(-) diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 9bc3e63f2..624d8f5f1 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -868,31 +868,44 @@ async def test_melt_with_wrong_unit_proofs(ledger: Ledger, wallet: Wallet): ) -INVOICE_64_SAT = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" - - -async def _run_melt_with_patched_fee( - wallet: Wallet, - ledger: Ledger, - monkeypatch, - fee_paid_sat_offset: int, +@pytest.mark.asyncio +@pytest.mark.skipif( + not is_fake or is_deprecated_api_only, + reason="only fakewallet and non-deprecated api", +) +@pytest.mark.parametrize( + "fee_paid_sat_offset", + [ + pytest.param(0, id="overpaid_fee_zero"), + pytest.param(1, id="overpaid_fee_negative"), + ], +) +async def test_melt_early_return_leaves_no_orphan_blank_outputs( + wallet, ledger: Ledger, monkeypatch, fee_paid_sat_offset: int ): - """Drives a successful melt where the backend reports a controlled fee. - - `fee_paid_sat_offset` is added to `fee_reserve_provided`: - - 0 → overpaid_fee == 0 - - >0 → overpaid_fee < 0 - Both hit the early-return branch in `_generate_change_promises`. - Returns (melt_quote_id, response). + """When `_generate_change_promises` takes its early-return branch + (overpaid_fee <= 0), the wallet's blank NUT-08 outputs — already + inserted into `promises` with c_ IS NULL before the LN payment — + must not be left behind as orphans. Later operations that re-derive + the same B_ (e.g. NUT-13 seed restore) collide with them and surface + as `OutputsArePendingError`. + + Both parametrize cases hit the same early-return branch: + - offset == 0 → overpaid_fee == 0 (fee exactly matched reserve) + - offset > 0 → overpaid_fee < 0 (backend took more than the + reserve, e.g. an LNbits backend skimming a service fee on top + of the routing fee) """ settings.fakewallet_payment_state = PaymentResult.SETTLED.name # Clear the FakeWallet pay_invoice override so our monkeypatch is what runs. settings.fakewallet_pay_invoice_state = "" + invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0" + mint_quote = await wallet.request_mint(100) proofs = await wallet.mint(amount=100, quote_id=mint_quote.quote) - melt_quote = await wallet.melt_quote(INVOICE_64_SAT) + melt_quote = await wallet.melt_quote(invoice_64_sat) total_provided = sum(p.amount for p in proofs) input_fees = ledger.get_fees_for_proofs(proofs) @@ -922,66 +935,15 @@ async def patched_pay_invoice(quote: MeltQuote, fee_limit_msat: int): response = await ledger.melt( proofs=proofs, quote=melt_quote.quote, outputs=change_outputs ) - return melt_quote.quote, response - - -@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_overpaid_fee_zero_leaves_no_orphan_blank_outputs( - wallet, ledger: Ledger, monkeypatch -): - """A successful melt where fee_paid == fee_reserve_provided takes the - early-return branch in `_generate_change_promises`. The wallet's blank - NUT-08 outputs (already inserted into `promises` with c_ IS NULL before - the LN payment) must not be left behind as orphans — they collide with - later mint/swap operations that re-derive the same B_ (e.g. after a - NUT-13 seed restore) and surface as `OutputsArePendingError`. - """ - melt_id, response = await _run_melt_with_patched_fee( - wallet, ledger, monkeypatch, fee_paid_sat_offset=0 - ) - - assert response.state == MeltQuoteState.paid.value - assert not response.change - - orphans = await ledger.crud.get_blinded_messages_melt_id( - db=ledger.db, melt_id=melt_id - ) - assert orphans == [], ( - f"Expected no orphan blank outputs for melt {melt_id}, " - f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}" - ) - - -@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_overpaid_fee_negative_leaves_no_orphan_blank_outputs( - wallet, ledger: Ledger, monkeypatch -): - """Same orphan-row regression as the zero case, but for the - `overpaid_fee < 0` branch — when the backend reports a fee greater than - the reserve the wallet provided. Realistic when the backend doesn't - strictly enforce the `max_fee` we pass it (e.g. an LNbits backend - skimming a service fee on top of the routing fee). - """ - melt_id, response = await _run_melt_with_patched_fee( - wallet, ledger, monkeypatch, fee_paid_sat_offset=1 - ) assert response.state == MeltQuoteState.paid.value assert not response.change orphans = await ledger.crud.get_blinded_messages_melt_id( - db=ledger.db, melt_id=melt_id + db=ledger.db, melt_id=melt_quote.quote ) assert orphans == [], ( - f"Expected no orphan blank outputs for melt {melt_id}, " + f"Expected no orphan blank outputs for melt {melt_quote.quote}, " f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}" ) From d82ebf3ea7fd0247d1781cff4582acccdf198dec Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 1 Jun 2026 07:58:35 +0000 Subject: [PATCH 3/3] fix(test): align test_generate_change_promises_zero_fee_deletes_all_blanks with fix The test was added in 9fed0f0 (PR #795) with a name asserting deletion ("..._deletes_all_blanks") but assertions baked in the then-current bug where the overpaid_fee <= 0 early-return skipped cleanup. This PR completes the fix; flip the assertions to match the fixed behavior the test name already claimed. Also apply KvngMikey's review suggestion: guard the cleanup with `outputs is not None` so we don't issue a no-op DELETE when the wallet sent no blanks to begin with. --- cashu/mint/ledger.py | 3 ++- tests/mint/test_mint.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 0b5ab381a..192c84816 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -260,7 +260,8 @@ async def _generate_change_promises( # Clean up the blank outputs the wallet sent for fee return; otherwise # they remain in `promises` with c_ IS NULL and collide with later # operations that re-derive the same B_ (e.g. NUT-13 seed restore). - if melt_id: + # Skip when outputs is None — nothing was stored in the first place. + if melt_id and outputs is not None: await self.crud.delete_blinded_messages_melt_id( melt_id=melt_id, db=self.db ) diff --git a/tests/mint/test_mint.py b/tests/mint/test_mint.py index a26593aea..2f0fd0700 100644 --- a/tests/mint/test_mint.py +++ b/tests/mint/test_mint.py @@ -351,8 +351,10 @@ async def test_generate_change_promises_zero_fee_deletes_all_blanks(ledger: Ledg remaining_unsigned = await ledger.crud.get_blinded_messages_melt_id( db=ledger.db, melt_id=melt_id ) - # With zero fee nothing is signed or deleted; blanks stay pending. - assert len(remaining_unsigned) == n_blank + # With zero overpaid fee the early-return cleans up the staged blanks so + # they don't linger as orphan rows in `promises` with c_ IS NULL (which + # would collide with later B_ re-derivation, e.g. NUT-13 seed restore). + assert len(remaining_unsigned) == 0 async with ledger.db.connect() as conn: rows = await conn.fetchall( @@ -362,8 +364,7 @@ async def test_generate_change_promises_zero_fee_deletes_all_blanks(ledger: Ledg """, {"melt_id": melt_id}, ) - assert len(rows) == n_blank - assert all(row["c_"] is None for row in rows) + assert rows == [] @pytest.mark.asyncio