mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: cap melt inputs at mint request limit
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user