Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,17 @@ 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).
# 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
)
return []

Expand Down
9 changes: 5 additions & 4 deletions tests/mint/test_mint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
94 changes: 92 additions & 2 deletions tests/mint/test_mint_melt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@
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,
OutputsArePendingError,
)
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
Expand Down Expand Up @@ -858,3 +866,85 @@ async def test_melt_with_wrong_unit_proofs(ledger: Ledger, wallet: Wallet):
),
"proof unit usd does not match quote unit sat"
)


@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
):
"""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)

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
)

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_quote.quote
)
assert orphans == [], (
f"Expected no orphan blank outputs for melt {melt_quote.quote}, "
f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}"
)


Loading