Merge pull request #733 from Routstr/fix-melt-input-limit

fix: cap melt inputs at mint request limit
This commit is contained in:
9qeklajc
2026-09-16 01:54:58 +02:00
committed by GitHub
2 changed files with 78 additions and 10 deletions
+11 -3
View File
@@ -6,6 +6,7 @@ from typing import Any, TypedDict
import httpx import httpx
from cashu.core.base import MeltQuoteState from cashu.core.base import MeltQuoteState
from cashu.core.settings import settings as cashu_settings
from cashu.wallet.wallet import Proof, Wallet from cashu.wallet.wallet import Proof, Wallet
from ..cashu_compat import install_cashu_httpx_shim from ..cashu_compat import install_cashu_httpx_shim
@@ -243,13 +244,20 @@ def _select_melt_proofs(
Cashu 0.20's ``select_to_send`` may recursively swap when asked to spend a Cashu 0.20's ``select_to_send`` may recursively swap when asked to spend a
wallet's full balance. Melts accept overpayment and return change, so a wallet's full balance. Melts accept overpayment and return change, so a
bounded, largest-first selection is both safer and minimizes input fees. bounded, largest-first selection is both safer and minimizes input fees.
Mints reject a melt carrying more than ``mint_max_request_length`` inputs,
so a dust-heavy wallet can only pay what its largest inputs cover; the
caller lowers the amount and the rest goes out on later payouts.
""" """
selected: list[Proof] = [] selected: list[Proof] = []
selected_amount = 0 selected_amount = 0
required = quote_amount + fee_reserve required = quote_amount + fee_reserve
for proof in sorted(proofs, key=lambda item: item.amount, reverse=True): spendable = [
if getattr(proof, "reserved", False) is True: proof
continue for proof in sorted(proofs, key=lambda item: item.amount, reverse=True)
if getattr(proof, "reserved", False) is not True
]
for proof in spendable[: cashu_settings.mint_max_request_length]:
selected.append(proof) selected.append(proof)
selected_amount += proof.amount selected_amount += proof.amount
input_fees = int(wallet.get_fees_for_proofs(selected)) input_fees = int(wallet.get_fees_for_proofs(selected))
@@ -163,15 +163,15 @@ async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion() -> ( async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion() -> (
None None
): ):
proofs = [MagicMock(amount=1, reserved=False) for _ in range(1500)] proofs = [MagicMock(amount=1, reserved=False) for _ in range(500)]
wallet = MagicMock(url="https://mint.test") wallet = MagicMock(url="https://mint.test")
wallet.get_fees_for_proofs = MagicMock( wallet.get_fees_for_proofs = MagicMock(
side_effect=lambda selected: math.ceil(len(selected) / 100) side_effect=lambda selected: math.ceil(len(selected) / 100)
) )
wallet.melt_quote = AsyncMock( wallet.melt_quote = AsyncMock(
side_effect=[ side_effect=[
MagicMock(fee_reserve=10, quote="q1", amount=1500), MagicMock(fee_reserve=10, quote="q1", amount=500),
MagicMock(fee_reserve=10, quote="q2", amount=1475), MagicMock(fee_reserve=10, quote="q2", amount=485),
] ]
) )
wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid)) wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
@@ -185,17 +185,17 @@ async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion
proofs, proofs,
"owner@ln.tld", "owner@ln.tld",
"sat", "sat",
amount=1500, amount=500,
on_melt_quote=checkpoint, on_melt_quote=checkpoint,
) )
assert paid == 1_475_000 assert paid == 485_000
assert wallet.melt_quote.await_count == 2 assert wallet.melt_quote.await_count == 2
checkpoint.assert_awaited_once_with("q2") checkpoint.assert_awaited_once_with("q2")
wallet.select_to_send.assert_not_called() wallet.select_to_send.assert_not_called()
selected = wallet.melt.await_args.kwargs["proofs"] selected = wallet.melt.await_args.kwargs["proofs"]
assert sum(proof.amount for proof in selected) == 1500 assert sum(proof.amount for proof in selected) == 500
assert 1475 + 10 + wallet.get_fees_for_proofs(selected) == 1500 assert 485 + 10 + wallet.get_fees_for_proofs(selected) == 500
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -365,3 +365,63 @@ def test_select_melt_proofs_ignores_fees_for_unneeded_wallet_proofs() -> None:
assert selected is None assert selected is None
assert shortfall == 2 assert shortfall == 2
assert wallet.get_fees_for_proofs.call_count == 1 assert wallet.get_fees_for_proofs.call_count == 1
def test_select_melt_proofs_respects_mint_input_limit() -> None:
from cashu.core.settings import settings as cashu_settings
from routstr.payment.lnurl import _select_melt_proofs
limit = cashu_settings.mint_max_request_length
wallet = MagicMock()
wallet.get_fees_for_proofs = MagicMock(return_value=0)
proofs = [MagicMock(amount=1, reserved=False) for _ in range(limit + 563)]
selected, shortfall = _select_melt_proofs(
wallet,
proofs,
quote_amount=limit + 563,
fee_reserve=0,
gross_budget=limit + 563,
)
assert selected is None
assert shortfall == 563
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_pays_what_the_input_limit_allows() -> None:
from cashu.core.settings import settings as cashu_settings
limit = cashu_settings.mint_max_request_length
proofs = [MagicMock(amount=1, reserved=False) for _ in range(limit + 563)]
wallet = MagicMock(url="https://mint.test")
wallet.get_fees_for_proofs = MagicMock(return_value=0)
wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
wallet.set_reserved_for_send = AsyncMock()
requested: list[int] = []
async def invoice(_callback: str, amount_msat: int) -> tuple[str, dict]:
requested.append(amount_msat)
return "lnbc1...", {}
async def melt_quote(invoice: str) -> MagicMock:
return MagicMock(fee_reserve=0, quote="q", amount=requested[-1] // 1000)
wallet.melt_quote = AsyncMock(side_effect=melt_quote)
with (
patch(
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=LNURL_DATA)
),
patch(
"routstr.payment.lnurl.get_lnurl_invoice", AsyncMock(side_effect=invoice)
),
):
paid = await raw_send_to_lnurl(
wallet, proofs, "owner@ln.tld", "sat", amount=limit + 563
)
assert paid == limit * 1000
assert len(wallet.melt.await_args.kwargs["proofs"]) == limit