From a47a40b233d1144b500266a80076099bc1dbd6fb Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Sun, 21 Jun 2026 12:31:23 +0300 Subject: [PATCH 1/9] feat(core): new properties added to nut18 and nut26 --- cashu/core/base.py | 3 +++ cashu/core/nuts/nut26.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cashu/core/base.py b/cashu/core/base.py index 17368c050..82cbac55c 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1684,6 +1684,9 @@ class PaymentRequest(BaseModel): u: Optional[str] = None # unit s: Optional[bool] = None # single use m: Optional[List[str]] = None # mints + ms: Optional[bool] = None # mint list strict + fr: Optional[int] = None # fee reserve + sm: Optional[List[str]] = None # supported methods d: Optional[str] = None # description t: Optional[List[Transport]] = None # transports nut10: Optional[NUT10Option] = None diff --git a/cashu/core/nuts/nut26.py b/cashu/core/nuts/nut26.py index 6c364e959..8fe06eb60 100644 --- a/cashu/core/nuts/nut26.py +++ b/cashu/core/nuts/nut26.py @@ -273,6 +273,13 @@ def _pr_to_tlv(pr: PaymentRequest) -> bytes: out += _tlv_entry(0x07, _encode_transport(tr)) if pr.nut10 is not None: out += _tlv_entry(0x08, _encode_nut10(pr.nut10)) + if pr.ms is not None: + out += _tlv_entry(0x09, bytes([1 if pr.ms else 0])) + if pr.fr is not None: + out += _tlv_entry(0x0A, struct.pack(">Q", pr.fr)) + if pr.sm: + for method in pr.sm: + out += _tlv_entry(0x0B, method.encode()) return out def _tlv_to_pr(data: bytes) -> PaymentRequest: @@ -280,6 +287,7 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: kwargs: dict = {} mints: List[str] = [] transports: List[Transport] = [] + supported_methods: List[str] = [] for tag, val in entries: if len(val) == 0 and tag in (0x01,): @@ -307,11 +315,19 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: transports.append(_decode_transport(val)) elif tag == 0x08: kwargs["nut10"] = _decode_nut10(val) + elif tag == 0x09: + kwargs["ms"] = val[0] == 1 + elif tag == 0x0A: + kwargs["fr"] = struct.unpack(">Q", val)[0] + elif tag == 0x0B: + supported_methods.append(val.decode()) if mints: kwargs["m"] = mints if transports: kwargs["t"] = transports + if supported_methods: + kwargs["sm"] = supported_methods return PaymentRequest(**kwargs) From 844492054d35d06905233d082d6be242bcd6f63c Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Sun, 21 Jun 2026 13:33:50 +0300 Subject: [PATCH 2/9] chore: tests added --- tests/wallet/test_wallet_payment_request.py | 93 +++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 818b8d885..01826a5aa 100644 --- a/tests/wallet/test_wallet_payment_request.py +++ b/tests/wallet/test_wallet_payment_request.py @@ -58,6 +58,49 @@ def test_nut18_round_trip(): assert decoded.i is None +def test_nut18_preferred_mints_vector(): + """Encoding vector for the preferred mint list, fee reserve and methods.""" + expected = ( + "creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBz" + "Oi8vbWludC5leGFtcGxlLmNvbWJtc_RiZnICYnNtgWZib2x0MTE" + ) + req = PaymentRequest( + i="b7a90177", + a=100, + u="sat", + m=["https://mint.example.com"], + ms=False, + fr=2, + sm=["bolt11"], + ) + assert serialize(req) == expected + + +def test_nut18_round_trip_mint_list_fields(): + """ms, fr and sm round-trip through the CBOR format.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + ms=False, + fr=2, + sm=["bolt11"], + ) + decoded = deserialize(serialize(req)) + assert decoded.ms is False + assert decoded.fr == 2 + assert decoded.sm == ["bolt11"] + + +def test_nut18_omits_mint_list_fields_when_unset(): + """ms, fr and sm must not appear in the encoding when unset.""" + req = PaymentRequest(a=100, u="sat") + decoded = deserialize(serialize(req)) + assert decoded.ms is None + assert decoded.fr is None + assert decoded.sm is None + + # ─── NUT-26 Tests ──────────────────────────────────────────────────── def test_nut26_spec_example(): """Test the example from the NUT-26 spec.""" @@ -143,6 +186,56 @@ def test_nut26_round_trip_nut10(): assert decoded.nut10.d == "abcdef1234567890" * 4 +def test_nut26_preferred_mints_vector(): + """Encoding vector for the mint list, fee reserve and supported methods.""" + expected = ( + "CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQ" + "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGQPGQQSQQQQQQQ" + "QQQQQG9SQPNZDAK8GVF3A8DTHZ" + ) + req = PaymentRequest( + i="b7a90178", + a=100, + u="sat", + m=["https://mint.example.com"], + ms=False, + fr=2, + sm=["bolt11"], + ) + assert serialize_bech32m(req) == expected + + +def test_nut26_round_trip_mint_list_fields(): + """ms, fr and sm round-trip through the TLV format.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + ms=False, + fr=2, + sm=["bolt11"], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.ms is False + assert decoded.fr == 2 + assert decoded.sm == ["bolt11"] + + +def test_nut26_round_trip_strict_mint_list(): + """ms=True round-trips and a single supported method is preserved.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + ms=True, + sm=["bolt11"], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.ms is True + assert decoded.fr is None + assert decoded.sm == ["bolt11"] + + def test_nut26_case_insensitive_decode(): """Bech32m decoding must accept both upper and lower case.""" req = PaymentRequest(a=1, u="sat") From 5cda7f64588bf4db2e31157c2651f167224adcf3 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Mon, 22 Jun 2026 10:00:35 +0300 Subject: [PATCH 3/9] chore: failing test --- tests/wallet/test_wallet_payment_request.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 01826a5aa..5033f9387 100644 --- a/tests/wallet/test_wallet_payment_request.py +++ b/tests/wallet/test_wallet_payment_request.py @@ -65,7 +65,7 @@ def test_nut18_preferred_mints_vector(): "Oi8vbWludC5leGFtcGxlLmNvbWJtc_RiZnICYnNtgWZib2x0MTE" ) req = PaymentRequest( - i="b7a90177", + i="preferred_fee_methods", a=100, u="sat", m=["https://mint.example.com"], @@ -194,7 +194,7 @@ def test_nut26_preferred_mints_vector(): "QQQQQG9SQPNZDAK8GVF3A8DTHZ" ) req = PaymentRequest( - i="b7a90178", + i="preferred_fee_methods", a=100, u="sat", m=["https://mint.example.com"], From 11a089495f35cc0b63d1334e2a025a4627fe2bdd Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 25 Jun 2026 14:47:09 +0100 Subject: [PATCH 4/9] chore: wallet now acts on fields when paying and creates a payment request that carries fields --- cashu/wallet/cli/cli.py | 91 +++++++++++++++++++++++++++++++++- tests/wallet/test_cli_nut18.py | 72 ++++++++++++++++++++++++++- tests/wallet/test_cli_nut26.py | 23 +++++++++ 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 18fa65250..717897f77 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -25,6 +25,7 @@ Method, MintQuote, MintQuoteState, + PaymentRequest, TokenV4, Unit, ) @@ -33,6 +34,8 @@ from ...core.logging import configure_logger from ...core.models import PostMintQuoteResponse from ...core.nuts.nut18 import deserialize as deserialize_payment_request +from ...core.nuts.nut18 import serialize as serialize_payment_request +from ...core.nuts.nut26 import serialize as serialize_payment_request_bech32 from ...core.settings import settings from ...tor.tor import TorProxy from ...wallet.crud import ( @@ -296,18 +299,34 @@ async def pay( if pr.a: print(f"Amount: {wallet.unit.str(pr.a)} ({pr.a} {pr.u})") - if pr.m and wallet.url not in pr.m: + # The mint list is strict unless `ms` is explicitly false + mint_outside_list = pr.m is not None and wallet.url not in pr.m + mint_list_strict = pr.ms is None or pr.ms + if mint_outside_list and mint_list_strict: print( f"Error: Current mint {wallet.url} is not accepted by the receiver.") print(f"Accepted mints: {pr.m}") return + # The sending mint must support one of the requested methods + if pr.sm is not None and Method.bolt11.name not in pr.sm: + print(f"Error: Current mint does not support a requested method: {pr.sm}") + return + amount_to_pay = pr.a if not amount_to_pay: # TODO: Handle amounts not specified in request (ask user) print("Error: Amount not specified in payment request.") return + # Add the fee reserve when paying from a mint outside a non-strict list + if mint_outside_list and pr.fr: + amount_to_pay += pr.fr + print( + f"Adding fee reserve of {wallet.unit.str(pr.fr)} " + "(paying from a mint outside the preferred list)." + ) + if not yes and not ctx.obj.get("YES"): message = f"Pay {wallet.unit.str(amount_to_pay)}?" click.confirm( @@ -478,6 +497,76 @@ async def pay( await print_balance(ctx) +@cli.command("request", help="Create a NUT-18 payment request.") +@click.argument("amount", type=int) +@click.option("--description", "-d", default=None, help="Human-readable description.") +@click.option( + "--mint", + "-m", + "mints", + multiple=True, + help="Accepted mint URL (repeatable, defaults to the current mint).", +) +@click.option( + "--preferred", + default=False, + is_flag=True, + help="Treat the mint list as preferred rather than strict.", +) +@click.option( + "--fee-reserve", + "-f", + "fee_reserve", + default=None, + type=int, + help="Fee reserve a payer adds when paying from a mint outside a preferred list.", +) +@click.option( + "--method", + "methods", + multiple=True, + help="Required supported method, e.g. bolt11 (repeatable).", +) +@click.option( + "--single-use", "-s", default=False, is_flag=True, help="Mark the request single use." +) +@click.option( + "--bech32", default=False, is_flag=True, help="Encode as a NUT-26 bech32m request." +) +@click.pass_context +@coro +@init_auth_wallet +async def request( + ctx: Context, + amount: int, + description: Optional[str], + mints: tuple, + preferred: bool, + fee_reserve: Optional[int], + methods: tuple, + single_use: bool, + bech32: bool, +): + wallet: Wallet = ctx.obj["WALLET"] + await wallet.load_mint() + pr = PaymentRequest( + a=amount, + u=wallet.unit.name, + m=list(mints) or [wallet.url], + ms=False if preferred else None, + fr=fee_reserve, + sm=list(methods) or None, + s=True if single_use else None, + d=description, + ) + encoded = ( + serialize_payment_request_bech32(pr) + if bech32 + else serialize_payment_request(pr) + ) + print(encoded) + + @cli.command("invoice", help="Create Lighting invoice.") @click.argument("amount", type=float) @click.option("memo", "-m", default="", help="Memo for the invoice.", type=str) diff --git a/tests/wallet/test_cli_nut18.py b/tests/wallet/test_cli_nut18.py index 29ac7ea41..2426b990a 100644 --- a/tests/wallet/test_cli_nut18.py +++ b/tests/wallet/test_cli_nut18.py @@ -2,7 +2,7 @@ from click.testing import CliRunner from cashu.core.base import NUT10Option, PaymentRequest -from cashu.core.nuts.nut18 import serialize +from cashu.core.nuts.nut18 import deserialize, serialize from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from tests.helpers import is_fake @@ -81,3 +81,73 @@ def test_pay_nut18_wrong_mint(mint, cli_prefix): assert "Error: Current mint" in result.output assert "not accepted" in result.output assert "cashuB" not in result.output + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_pay_nut18_preferred_mint_with_fee_reserve(mint, cli_prefix): + """A non-strict (ms=False) mint list is accepted and adds the fee reserve.""" + runner = CliRunner() + + pr = PaymentRequest( + a=10, u="sat", m=["https://other.mint/"], ms=False, fr=5 + ) + creq = serialize(pr) + + result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) + + # The mint outside the preferred list is not rejected ... + assert "not accepted" not in result.output + # ... and the fee reserve is added on top of the requested amount. + assert "Adding fee reserve of 5 sat" in result.output + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_pay_nut18_unsupported_method(mint, cli_prefix): + """A request whose supported methods exclude bolt11 is rejected.""" + runner = CliRunner() + + pr = PaymentRequest(a=10, u="sat", sm=["bolt12"]) + creq = serialize(pr) + + result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) + + assert "does not support a requested method" in result.output + assert "cashuB" not in result.output + + +def _extract_creq(output: str) -> str: + """Pull the creqA payment request out of CLI output that may include logs.""" + for line in output.splitlines(): + if line.strip().startswith("creqA"): + return line.strip() + raise AssertionError(f"No creqA found in output:\n{output}") + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_request_creates_payment_request(mint, cli_prefix): + """The request command builds a NUT-18 request carrying the new fields.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + *cli_prefix, "request", "100", + "-d", "Coffee", + "-m", "https://mint.example.com", + "--preferred", + "-f", "2", + "--method", "bolt11", + "-s", + ], + ) + assert result.exception is None, f"Exception: {result.exception}" + + pr = deserialize(_extract_creq(result.output)) + assert pr.a == 100 + assert pr.u == "sat" + assert pr.d == "Coffee" + assert pr.m == ["https://mint.example.com"] + assert pr.ms is False + assert pr.fr == 2 + assert pr.sm == ["bolt11"] + assert pr.s is True diff --git a/tests/wallet/test_cli_nut26.py b/tests/wallet/test_cli_nut26.py index 74bd5c977..b44efa939 100644 --- a/tests/wallet/test_cli_nut26.py +++ b/tests/wallet/test_cli_nut26.py @@ -4,6 +4,7 @@ from click.testing import CliRunner from cashu.core.base import NUT10Option, PaymentRequest +from cashu.core.nuts.nut26 import deserialize as nut26_deserialize from cashu.core.nuts.nut26 import serialize as nut26_serialize from cashu.core.settings import settings from cashu.wallet.cli.cli import cli @@ -118,3 +119,25 @@ def test_pay_nut26_unsupported_lock(mint, cli_prefix): assert "Unsupported lock kind 'HTLC'" in result.output assert "cashuB" not in result.output + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_request_bech32_creates_nut26_request(mint, cli_prefix): + """The request command emits a NUT-26 creqB1 string with --bech32.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [*cli_prefix, "request", "100", "-m", "https://mint.example.com", "--bech32"], + ) + assert result.exception is None, f"Exception: {result.exception}" + + creqb = next( + line.strip() + for line in result.output.splitlines() + if line.strip().upper().startswith("CREQB1") + ) + pr = nut26_deserialize(creqb) + assert pr.a == 100 + assert pr.u == "sat" + assert pr.m == ["https://mint.example.com"] From f247d9e7a8931259ab9a4962874e2facd4a0a39c Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Sun, 28 Jun 2026 12:04:05 +0100 Subject: [PATCH 5/9] chore: updated ms to mp(mint_preferred) --- cashu/core/base.py | 2 +- cashu/core/nuts/nut26.py | 6 ++-- cashu/wallet/cli/cli.py | 7 ++--- tests/wallet/test_cli_nut18.py | 6 ++-- tests/wallet/test_wallet_payment_request.py | 32 ++++++++++----------- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 82cbac55c..7fc2c3991 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1684,7 +1684,7 @@ class PaymentRequest(BaseModel): u: Optional[str] = None # unit s: Optional[bool] = None # single use m: Optional[List[str]] = None # mints - ms: Optional[bool] = None # mint list strict + mp: Optional[bool] = None # mint list preferred fr: Optional[int] = None # fee reserve sm: Optional[List[str]] = None # supported methods d: Optional[str] = None # description diff --git a/cashu/core/nuts/nut26.py b/cashu/core/nuts/nut26.py index 8fe06eb60..87f6a070f 100644 --- a/cashu/core/nuts/nut26.py +++ b/cashu/core/nuts/nut26.py @@ -273,8 +273,8 @@ def _pr_to_tlv(pr: PaymentRequest) -> bytes: out += _tlv_entry(0x07, _encode_transport(tr)) if pr.nut10 is not None: out += _tlv_entry(0x08, _encode_nut10(pr.nut10)) - if pr.ms is not None: - out += _tlv_entry(0x09, bytes([1 if pr.ms else 0])) + if pr.mp is not None: + out += _tlv_entry(0x09, bytes([1 if pr.mp else 0])) if pr.fr is not None: out += _tlv_entry(0x0A, struct.pack(">Q", pr.fr)) if pr.sm: @@ -316,7 +316,7 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: elif tag == 0x08: kwargs["nut10"] = _decode_nut10(val) elif tag == 0x09: - kwargs["ms"] = val[0] == 1 + kwargs["mp"] = val[0] == 1 elif tag == 0x0A: kwargs["fr"] = struct.unpack(">Q", val)[0] elif tag == 0x0B: diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 717897f77..28fef0b08 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -299,10 +299,9 @@ async def pay( if pr.a: print(f"Amount: {wallet.unit.str(pr.a)} ({pr.a} {pr.u})") - # The mint list is strict unless `ms` is explicitly false + # The mint list is strict unless `mp` is explicitly true (preferred) mint_outside_list = pr.m is not None and wallet.url not in pr.m - mint_list_strict = pr.ms is None or pr.ms - if mint_outside_list and mint_list_strict: + if mint_outside_list and not pr.mp: print( f"Error: Current mint {wallet.url} is not accepted by the receiver.") print(f"Accepted mints: {pr.m}") @@ -553,7 +552,7 @@ async def request( a=amount, u=wallet.unit.name, m=list(mints) or [wallet.url], - ms=False if preferred else None, + mp=True if preferred else None, fr=fee_reserve, sm=list(methods) or None, s=True if single_use else None, diff --git a/tests/wallet/test_cli_nut18.py b/tests/wallet/test_cli_nut18.py index 2426b990a..06b08c7e2 100644 --- a/tests/wallet/test_cli_nut18.py +++ b/tests/wallet/test_cli_nut18.py @@ -85,11 +85,11 @@ def test_pay_nut18_wrong_mint(mint, cli_prefix): @pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") def test_pay_nut18_preferred_mint_with_fee_reserve(mint, cli_prefix): - """A non-strict (ms=False) mint list is accepted and adds the fee reserve.""" + """A preferred (mp=True) mint list is accepted and adds the fee reserve.""" runner = CliRunner() pr = PaymentRequest( - a=10, u="sat", m=["https://other.mint/"], ms=False, fr=5 + a=10, u="sat", m=["https://other.mint/"], mp=True, fr=5 ) creq = serialize(pr) @@ -147,7 +147,7 @@ def test_request_creates_payment_request(mint, cli_prefix): assert pr.u == "sat" assert pr.d == "Coffee" assert pr.m == ["https://mint.example.com"] - assert pr.ms is False + assert pr.mp is True assert pr.fr == 2 assert pr.sm == ["bolt11"] assert pr.s is True diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 5033f9387..8a342fa83 100644 --- a/tests/wallet/test_wallet_payment_request.py +++ b/tests/wallet/test_wallet_payment_request.py @@ -62,14 +62,14 @@ def test_nut18_preferred_mints_vector(): """Encoding vector for the preferred mint list, fee reserve and methods.""" expected = ( "creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBz" - "Oi8vbWludC5leGFtcGxlLmNvbWJtc_RiZnICYnNtgWZib2x0MTE" + "Oi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgWZib2x0MTE" ) req = PaymentRequest( i="preferred_fee_methods", a=100, u="sat", m=["https://mint.example.com"], - ms=False, + mp=True, fr=2, sm=["bolt11"], ) @@ -77,26 +77,26 @@ def test_nut18_preferred_mints_vector(): def test_nut18_round_trip_mint_list_fields(): - """ms, fr and sm round-trip through the CBOR format.""" + """mp, fr and sm round-trip through the CBOR format.""" req = PaymentRequest( a=100, u="sat", m=["https://mint.example.com"], - ms=False, + mp=True, fr=2, sm=["bolt11"], ) decoded = deserialize(serialize(req)) - assert decoded.ms is False + assert decoded.mp is True assert decoded.fr == 2 assert decoded.sm == ["bolt11"] def test_nut18_omits_mint_list_fields_when_unset(): - """ms, fr and sm must not appear in the encoding when unset.""" + """mp, fr and sm must not appear in the encoding when unset.""" req = PaymentRequest(a=100, u="sat") decoded = deserialize(serialize(req)) - assert decoded.ms is None + assert decoded.mp is None assert decoded.fr is None assert decoded.sm is None @@ -190,15 +190,15 @@ def test_nut26_preferred_mints_vector(): """Encoding vector for the mint list, fee reserve and supported methods.""" expected = ( "CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQ" - "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGQPGQQSQQQQQQQ" - "QQQQQG9SQPNZDAK8GVF3A8DTHZ" + "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQ" + "QQQQQG9SQPNZDAK8GVF3KE6Q6F" ) req = PaymentRequest( i="preferred_fee_methods", a=100, u="sat", m=["https://mint.example.com"], - ms=False, + mp=True, fr=2, sm=["bolt11"], ) @@ -206,32 +206,32 @@ def test_nut26_preferred_mints_vector(): def test_nut26_round_trip_mint_list_fields(): - """ms, fr and sm round-trip through the TLV format.""" + """mp, fr and sm round-trip through the TLV format.""" req = PaymentRequest( a=100, u="sat", m=["https://mint.example.com"], - ms=False, + mp=True, fr=2, sm=["bolt11"], ) decoded = deserialize(serialize_bech32m(req)) - assert decoded.ms is False + assert decoded.mp is True assert decoded.fr == 2 assert decoded.sm == ["bolt11"] def test_nut26_round_trip_strict_mint_list(): - """ms=True round-trips and a single supported method is preserved.""" + """mp=False round-trips and a single supported method is preserved.""" req = PaymentRequest( a=100, u="sat", m=["https://mint.example.com"], - ms=True, + mp=False, sm=["bolt11"], ) decoded = deserialize(serialize_bech32m(req)) - assert decoded.ms is True + assert decoded.mp is False assert decoded.fr is None assert decoded.sm == ["bolt11"] From 53c3cc2b84f90947854d590b70e7358adedc94eb Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Fri, 10 Jul 2026 16:48:47 +0100 Subject: [PATCH 6/9] feat(core): sync NUT-18/26 sm with spec (drop fr, add per-method mf) Drop `fr` and replace `sm: List[str]` with `sm: List[SupportedMethod]` (`mn`, optional `mf`) per cashubtc/nuts#381. NUT-26 tag 0x0a is now a repeatable supported_method sub-TLV (0x01=method, 0x02=fee u64 BE); 0x0b removed. Also fixes a mis-scoped guard error message (tag 0x01 is id, not transport). Byte-verified against frozen spec vectors. --- cashu/core/base.py | 8 +- cashu/core/nuts/nut26.py | 40 +++++-- tests/wallet/test_wallet_payment_request.py | 111 +++++++++++++++----- 3 files changed, 121 insertions(+), 38 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 7fc2c3991..973987d98 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1678,6 +1678,11 @@ class NUT10Option(BaseModel): t: Optional[List[List[str]]] = None # tags +class SupportedMethod(BaseModel): + mn: str # method name + mf: Optional[int] = None # per-method fee, absent = 0 + + class PaymentRequest(BaseModel): i: Optional[str] = None # payment id a: Optional[int] = None # amount @@ -1685,8 +1690,7 @@ class PaymentRequest(BaseModel): s: Optional[bool] = None # single use m: Optional[List[str]] = None # mints mp: Optional[bool] = None # mint list preferred - fr: Optional[int] = None # fee reserve - sm: Optional[List[str]] = None # supported methods + sm: Optional[List[SupportedMethod]] = None # supported methods d: Optional[str] = None # description t: Optional[List[Transport]] = None # transports nut10: Optional[NUT10Option] = None diff --git a/cashu/core/nuts/nut26.py b/cashu/core/nuts/nut26.py index 87f6a070f..c179992a1 100644 --- a/cashu/core/nuts/nut26.py +++ b/cashu/core/nuts/nut26.py @@ -12,7 +12,7 @@ bech32_encode as _bech32_encode, ) -from ..base import NUT10Option, PaymentRequest, Transport +from ..base import NUT10Option, PaymentRequest, SupportedMethod, Transport BECH32M_CONST = 0x2BC830A3 HRP = "creqb" @@ -248,6 +248,32 @@ def _decode_nut10(data: bytes) -> NUT10Option: t=tags if tags else None, ) +# ─── Supported method encode/decode ───────────────────────────────── + +def _encode_supported_method(sm: SupportedMethod) -> bytes: + inner = _tlv_entry(0x01, sm.mn.encode()) + if sm.mf is not None: + inner += _tlv_entry(0x02, struct.pack(">Q", sm.mf)) + return inner + +def _decode_supported_method(data: bytes) -> SupportedMethod: + entries = _tlv_parse(data) + mn: Optional[str] = None + mf: Optional[int] = None + + for tag, val in entries: + if tag == 0x01: + mn = val.decode() + elif tag == 0x02: + if len(val) != 8: + raise ValueError("Invalid method fee length") + mf = struct.unpack(">Q", val)[0] + + if mn is None: + raise ValueError("Missing method name in supported method") + + return SupportedMethod(mn=mn, mf=mf) + # ─── PaymentRequest <-> TLV bytes ─────────────────────────────────── def _pr_to_tlv(pr: PaymentRequest) -> bytes: @@ -275,11 +301,9 @@ def _pr_to_tlv(pr: PaymentRequest) -> bytes: out += _tlv_entry(0x08, _encode_nut10(pr.nut10)) if pr.mp is not None: out += _tlv_entry(0x09, bytes([1 if pr.mp else 0])) - if pr.fr is not None: - out += _tlv_entry(0x0A, struct.pack(">Q", pr.fr)) if pr.sm: for method in pr.sm: - out += _tlv_entry(0x0B, method.encode()) + out += _tlv_entry(0x0A, _encode_supported_method(method)) return out def _tlv_to_pr(data: bytes) -> PaymentRequest: @@ -287,11 +311,11 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: kwargs: dict = {} mints: List[str] = [] transports: List[Transport] = [] - supported_methods: List[str] = [] + supported_methods: List[SupportedMethod] = [] for tag, val in entries: if len(val) == 0 and tag in (0x01,): - raise ValueError("Empty value for transport tag") + raise ValueError("Empty value for id tag") if tag == 0x01: kwargs["i"] = val.decode() elif tag == 0x02: @@ -318,9 +342,7 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: elif tag == 0x09: kwargs["mp"] = val[0] == 1 elif tag == 0x0A: - kwargs["fr"] = struct.unpack(">Q", val)[0] - elif tag == 0x0B: - supported_methods.append(val.decode()) + supported_methods.append(_decode_supported_method(val)) if mints: kwargs["m"] = mints diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 8a342fa83..4876de12f 100644 --- a/tests/wallet/test_wallet_payment_request.py +++ b/tests/wallet/test_wallet_payment_request.py @@ -1,8 +1,10 @@ import pytest +from bech32 import convertbits -from cashu.core.base import NUT10Option, PaymentRequest, Transport +from cashu.core.base import NUT10Option, PaymentRequest, SupportedMethod, Transport from cashu.core.nuts.nut18 import deserialize, serialize -from cashu.core.nuts.nut26 import bech32m_decode +from cashu.core.nuts.nut26 import _tlv_entry as nut26_tlv_entry +from cashu.core.nuts.nut26 import bech32m_decode, bech32m_encode from cashu.core.nuts.nut26 import serialize as serialize_bech32m @@ -59,10 +61,11 @@ def test_nut18_round_trip(): def test_nut18_preferred_mints_vector(): - """Encoding vector for the preferred mint list, fee reserve and methods.""" + """Frozen spec vector: preferred mint list with supported methods and a per-method fee.""" expected = ( - "creqAp2FpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBz" - "Oi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgWZib2x0MTE" + "creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBz" + "Oi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQx" + "MmJtZgU" ) req = PaymentRequest( i="preferred_fee_methods", @@ -70,34 +73,56 @@ def test_nut18_preferred_mints_vector(): u="sat", m=["https://mint.example.com"], mp=True, - fr=2, - sm=["bolt11"], + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], ) assert serialize(req) == expected + decoded = deserialize(expected) + assert decoded.mp is True + assert decoded.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="bolt12", mf=5), + ] + def test_nut18_round_trip_mint_list_fields(): - """mp, fr and sm round-trip through the CBOR format.""" + """mp and sm (with a per-method fee) round-trip through the CBOR format.""" req = PaymentRequest( a=100, u="sat", m=["https://mint.example.com"], mp=True, - fr=2, - sm=["bolt11"], + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], ) decoded = deserialize(serialize(req)) assert decoded.mp is True - assert decoded.fr == 2 - assert decoded.sm == ["bolt11"] + assert decoded.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="bolt12", mf=5), + ] + + +def test_nut18_round_trip_explicit_zero_fee(): + """An explicit mf=0 must round-trip distinctly from an omitted mf.""" + req = PaymentRequest(a=100, u="sat", sm=[SupportedMethod(mn="bolt11", mf=0)]) + decoded = deserialize(serialize(req)) + assert decoded.sm == [SupportedMethod(mn="bolt11", mf=0)] + assert decoded.sm[0].mf == 0 + assert decoded.sm[0].mf is not None + + +def test_nut18_round_trip_unknown_method_name(): + """Method names unknown to this wallet (e.g. non-bolt11) still round-trip opaquely.""" + req = PaymentRequest(a=100, u="sat", sm=[SupportedMethod(mn="onchain", mf=50)]) + decoded = deserialize(serialize(req)) + assert decoded.sm == [SupportedMethod(mn="onchain", mf=50)] def test_nut18_omits_mint_list_fields_when_unset(): - """mp, fr and sm must not appear in the encoding when unset.""" + """mp and sm must not appear in the encoding when unset.""" req = PaymentRequest(a=100, u="sat") decoded = deserialize(serialize(req)) assert decoded.mp is None - assert decoded.fr is None assert decoded.sm is None @@ -187,11 +212,11 @@ def test_nut26_round_trip_nut10(): def test_nut26_preferred_mints_vector(): - """Encoding vector for the mint list, fee reserve and supported methods.""" + """Frozen spec vector: preferred mint list with supported methods and a per-method fee.""" expected = ( "CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQ" - "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQ" - "QQQQQG9SQPNZDAK8GVF3KE6Q6F" + "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X" + "7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568" ) req = PaymentRequest( i="preferred_fee_methods", @@ -199,26 +224,33 @@ def test_nut26_preferred_mints_vector(): u="sat", m=["https://mint.example.com"], mp=True, - fr=2, - sm=["bolt11"], + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], ) assert serialize_bech32m(req) == expected + decoded = deserialize(expected) + assert decoded.mp is True + assert decoded.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="bolt12", mf=5), + ] + def test_nut26_round_trip_mint_list_fields(): - """mp, fr and sm round-trip through the TLV format.""" + """mp and sm (with a per-method fee) round-trip through the TLV format.""" req = PaymentRequest( a=100, u="sat", m=["https://mint.example.com"], mp=True, - fr=2, - sm=["bolt11"], + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], ) decoded = deserialize(serialize_bech32m(req)) assert decoded.mp is True - assert decoded.fr == 2 - assert decoded.sm == ["bolt11"] + assert decoded.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="bolt12", mf=5), + ] def test_nut26_round_trip_strict_mint_list(): @@ -228,12 +260,37 @@ def test_nut26_round_trip_strict_mint_list(): u="sat", m=["https://mint.example.com"], mp=False, - sm=["bolt11"], + sm=[SupportedMethod(mn="bolt11")], ) decoded = deserialize(serialize_bech32m(req)) assert decoded.mp is False - assert decoded.fr is None - assert decoded.sm == ["bolt11"] + assert decoded.sm == [SupportedMethod(mn="bolt11")] + + +def test_nut26_round_trip_explicit_zero_fee(): + """An explicit mf=0 must round-trip distinctly from an omitted mf.""" + req = PaymentRequest(a=100, u="sat", sm=[SupportedMethod(mn="bolt11", mf=0)]) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.sm == [SupportedMethod(mn="bolt11", mf=0)] + assert decoded.sm[0].mf == 0 + assert decoded.sm[0].mf is not None + + +def test_nut26_round_trip_unknown_method_name(): + """Method names unknown to this wallet (e.g. non-bolt11) still round-trip opaquely.""" + req = PaymentRequest(a=100, u="sat", sm=[SupportedMethod(mn="onchain", mf=50)]) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.sm == [SupportedMethod(mn="onchain", mf=50)] + + +def test_nut26_decode_rejects_invalid_method_fee_length(): + """A crafted supported_method sub-TLV with a malformed fee length must raise.""" + bad_sub_tlv = nut26_tlv_entry(0x01, b"bolt11") + nut26_tlv_entry(0x02, b"\x00\x01") + raw = nut26_tlv_entry(0x0A, bad_sub_tlv) + five_bit = convertbits(raw, 8, 5) + token = bech32m_encode("creqb", five_bit).upper() + with pytest.raises(ValueError, match="Invalid method fee length"): + deserialize(token) def test_nut26_case_insensitive_decode(): From 967d141d5b50f5abcc77c1e1c99ee40f191b8b75 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Fri, 10 Jul 2026 17:13:22 +0100 Subject: [PATCH 7/9] feat(wallet): enforce per-method fee on payment-request pay path Add MintInfo.payment_request_method_fee (mirrors supports_mpp): reject when the mint melts none of a request's `sm` methods, else return the lowest matching mf, unit-filtered. Wire it into `pay`: fee applies only when paying from a mint outside a strict/preferred list, or when no mint list is set. Fix `request` command's --method/--fee-reserve to match the new SupportedMethod shape (fr no longer exists). --- cashu/core/mint_info.py | 41 ++++++++++++++-- cashu/wallet/cli/cli.py | 28 +++++------ tests/wallet/test_cli_nut18.py | 85 +++++++++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 31 deletions(-) diff --git a/cashu/core/mint_info.py b/cashu/core/mint_info.py index 2ececcf58..ef0c7be06 100644 --- a/cashu/core/mint_info.py +++ b/cashu/core/mint_info.py @@ -3,9 +3,14 @@ from pydantic import BaseModel -from .base import Method, Unit -from .models import MintInfoContact, MintInfoProtectedEndpoint, Nut15MppSupport -from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MPP_NUT, WEBSOCKETS_NUT +from .base import Method, SupportedMethod, Unit +from .models import ( + MeltMethodSetting, + MintInfoContact, + MintInfoProtectedEndpoint, + Nut15MppSupport, +) +from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MELT_NUT, MPP_NUT, WEBSOCKETS_NUT def _match_protected_endpoint(endpoint_path: str, request_path: str) -> bool: @@ -67,6 +72,36 @@ def supports_mpp(self, method: str, unit: Unit) -> bool: return False + def payment_request_method_fee( + self, sm: Optional[List[SupportedMethod]], unit: Unit + ) -> Optional[int]: + """ + Given the `sm` (supported methods) list from a NUT-18/26 payment request, + return the lowest per-method fee this mint owes for melting via one of the + listed methods in `unit`, or `None` if it melts none of them. + """ + if not sm: + return 0 + if not self.nuts: + return None + nut_05 = self.nuts.get(MELT_NUT) + if not nut_05 or not nut_05.get("methods"): + return None + + melt_methods = [MeltMethodSetting.model_validate(e) for e in nut_05["methods"]] + + lowest_fee: Optional[int] = None + for entry in sm: + supported = any( + m.method == entry.mn and m.unit == unit.name for m in melt_methods + ) + if supported: + fee = entry.mf or 0 + if lowest_fee is None or fee < lowest_fee: + lowest_fee = fee + + return lowest_fee + def supports_websocket_mint_quote(self, method: Method, unit: Unit) -> bool: if not self.nuts or not self.supports_nut(WEBSOCKETS_NUT): return False diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 28fef0b08..2f4507b5a 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -26,6 +26,7 @@ MintQuote, MintQuoteState, PaymentRequest, + SupportedMethod, TokenV4, Unit, ) @@ -307,8 +308,9 @@ async def pay( print(f"Accepted mints: {pr.m}") return - # The sending mint must support one of the requested methods - if pr.sm is not None and Method.bolt11.name not in pr.sm: + # The sending mint must support (melt via) one of the requested methods + method_fee = wallet.mint_info.payment_request_method_fee(pr.sm, wallet.unit) + if method_fee is None: print(f"Error: Current mint does not support a requested method: {pr.sm}") return @@ -318,11 +320,13 @@ async def pay( print("Error: Amount not specified in payment request.") return - # Add the fee reserve when paying from a mint outside a non-strict list - if mint_outside_list and pr.fr: - amount_to_pay += pr.fr + # The method fee applies only when paying from a mint outside the mint + # list, or when no mint list is set at all + fee_applies = pr.m is None or mint_outside_list + if fee_applies and method_fee: + amount_to_pay += method_fee print( - f"Adding fee reserve of {wallet.unit.str(pr.fr)} " + f"Adding method fee of {wallet.unit.str(method_fee)} " "(paying from a mint outside the preferred list)." ) @@ -512,14 +516,6 @@ async def pay( is_flag=True, help="Treat the mint list as preferred rather than strict.", ) -@click.option( - "--fee-reserve", - "-f", - "fee_reserve", - default=None, - type=int, - help="Fee reserve a payer adds when paying from a mint outside a preferred list.", -) @click.option( "--method", "methods", @@ -541,7 +537,6 @@ async def request( description: Optional[str], mints: tuple, preferred: bool, - fee_reserve: Optional[int], methods: tuple, single_use: bool, bech32: bool, @@ -553,8 +548,7 @@ async def request( u=wallet.unit.name, m=list(mints) or [wallet.url], mp=True if preferred else None, - fr=fee_reserve, - sm=list(methods) or None, + sm=[SupportedMethod(mn=m) for m in methods] or None, s=True if single_use else None, d=description, ) diff --git a/tests/wallet/test_cli_nut18.py b/tests/wallet/test_cli_nut18.py index 06b08c7e2..9670ba656 100644 --- a/tests/wallet/test_cli_nut18.py +++ b/tests/wallet/test_cli_nut18.py @@ -1,7 +1,7 @@ import pytest from click.testing import CliRunner -from cashu.core.base import NUT10Option, PaymentRequest +from cashu.core.base import NUT10Option, PaymentRequest, SupportedMethod from cashu.core.nuts.nut18 import deserialize, serialize from cashu.core.settings import settings from cashu.wallet.cli.cli import cli @@ -83,13 +83,77 @@ def test_pay_nut18_wrong_mint(mint, cli_prefix): assert "cashuB" not in result.output +# ─── Method-fee quadrants ──────────────────────────────────────────── +# The method fee applies only when paying from a mint outside the mint +# list, or when no mint list is set at all; when it applies, the payer +# owes the lowest `mf` among the listed methods their mint melts. + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_pay_nut18_mint_in_list_zero_fee_method(mint, cli_prefix): + """Mint is in the (strict) list; a zero-fee method adds nothing.""" + runner = CliRunner() + + pr = PaymentRequest( + a=10, u="sat", m=[settings.mint_url], sm=[SupportedMethod(mn="bolt11")] + ) + creq = serialize(pr) + + result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) + + assert "not accepted" not in result.output + assert "Adding method fee" not in result.output + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_pay_nut18_mint_in_list_priced_method_no_fee_applied(mint, cli_prefix): + """Mint is in the list; a priced method's fee does NOT apply (mint ∈ m ⇒ no fee).""" + runner = CliRunner() + + pr = PaymentRequest( + a=10, + u="sat", + m=[settings.mint_url], + sm=[SupportedMethod(mn="bolt11", mf=5)], + ) + creq = serialize(pr) + + result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) + + assert "not accepted" not in result.output + assert "Adding method fee" not in result.output + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_pay_nut18_mint_outside_preferred_list_zero_fee_method(mint, cli_prefix): + """Mint outside a preferred list with a zero-fee method: accepted, nothing added.""" + runner = CliRunner() + + pr = PaymentRequest( + a=10, + u="sat", + m=["https://other.mint/"], + mp=True, + sm=[SupportedMethod(mn="bolt11")], + ) + creq = serialize(pr) + + result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) + + assert "not accepted" not in result.output + assert "Adding method fee" not in result.output + + @pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") -def test_pay_nut18_preferred_mint_with_fee_reserve(mint, cli_prefix): - """A preferred (mp=True) mint list is accepted and adds the fee reserve.""" +def test_pay_nut18_mint_outside_preferred_list_with_method_fee(mint, cli_prefix): + """Mint outside a preferred list with a priced method: fee is added.""" runner = CliRunner() pr = PaymentRequest( - a=10, u="sat", m=["https://other.mint/"], mp=True, fr=5 + a=10, + u="sat", + m=["https://other.mint/"], + mp=True, + sm=[SupportedMethod(mn="bolt11", mf=5)], ) creq = serialize(pr) @@ -97,16 +161,17 @@ def test_pay_nut18_preferred_mint_with_fee_reserve(mint, cli_prefix): # The mint outside the preferred list is not rejected ... assert "not accepted" not in result.output - # ... and the fee reserve is added on top of the requested amount. - assert "Adding fee reserve of 5 sat" in result.output + # ... and the method fee is added on top of the requested amount. + assert "Adding method fee of 5 sat" in result.output @pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") def test_pay_nut18_unsupported_method(mint, cli_prefix): - """A request whose supported methods exclude bolt11 is rejected.""" + """A request whose supported methods exclude bolt11 is hard-rejected, + regardless of the mint list.""" runner = CliRunner() - pr = PaymentRequest(a=10, u="sat", sm=["bolt12"]) + pr = PaymentRequest(a=10, u="sat", sm=[SupportedMethod(mn="bolt12")]) creq = serialize(pr) result = runner.invoke(cli, [*cli_prefix, "pay", creq, "-y"]) @@ -135,7 +200,6 @@ def test_request_creates_payment_request(mint, cli_prefix): "-d", "Coffee", "-m", "https://mint.example.com", "--preferred", - "-f", "2", "--method", "bolt11", "-s", ], @@ -148,6 +212,5 @@ def test_request_creates_payment_request(mint, cli_prefix): assert pr.d == "Coffee" assert pr.m == ["https://mint.example.com"] assert pr.mp is True - assert pr.fr == 2 - assert pr.sm == ["bolt11"] + assert pr.sm == [SupportedMethod(mn="bolt11")] assert pr.s is True From 31536c53932b666511e8df5a417663a8660ecab0 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Fri, 10 Jul 2026 17:18:35 +0100 Subject: [PATCH 8/9] test(mint): guard net-of-input-fees invariant for payment-request pay path the creq pay path already selects proofs via include_fees=True, which enforces sum(proofs) - input_fee(proofs) >= amount. Add a dust-proof regression test (20x 1-sat proofs at 250 ppk) proving the payer tops up rather than underpays. No CDK oracle exists for this clause. --- tests/mint/test_mint_fees.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/mint/test_mint_fees.py b/tests/mint/test_mint_fees.py index 752383913..9484ded3a 100644 --- a/tests/mint/test_mint_fees.py +++ b/tests/mint/test_mint_fees.py @@ -119,6 +119,34 @@ async def test_wallet_selection_with_fee(wallet1: Wallet, ledger: Ledger): assert sum_proofs(send_proofs_with_fees) == 11 +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_payment_request_dust_proofs_net_of_input_fees( + wallet1: Wallet, ledger: Ledger +): + """the requested amount `a` (plus any method fee) is net of + input fees. A payer selecting from many small (dust) proofs on a + fee-charging keyset must top up the selection so the receiver's net + value still meets the requested amount, rather than underpay. + """ + set_ledger_keyset_fees(250, ledger, wallet1) + + mint_quote = await wallet1.request_mint(20) + await pay_if_regtest(mint_quote.request) + await wallet1.mint(20, split=[1] * 20, quote_id=mint_quote.quote) + + amount_to_pay = 10 + send_proofs, fees = await wallet1.select_to_send( + wallet1.proofs, amount_to_pay, include_fees=True + ) + # every selected proof is 1-sat dust + assert all(p.amount == 1 for p in send_proofs) + # the invariant this payment-request flow relies on: + # sum(proofs) - input_fee(proofs) >= amount_to_pay + assert sum_proofs(send_proofs) - fees >= amount_to_pay + assert sum_proofs(send_proofs) == amount_to_pay + fees + + @pytest.mark.asyncio @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") async def test_wallet_swap_to_send_with_fee(wallet1: Wallet, ledger: Ledger): From 91195fdec95b35c4fcbf494181298711e92b6575 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Fri, 10 Jul 2026 17:42:39 +0100 Subject: [PATCH 9/9] feat(wallet): support per-method fee syntax in request creation CLI --method now accepts name or name:fee (e.g. bolt11:50), rejecting non-integer or negative fees. Help text states the fee only applies when the payer mint is outside a preferred mint list. Full suite green (839 passed); no fr references remain outside unrelated tor binaries. --- cashu/wallet/cli/cli.py | 32 +++++++++++++++++++++++++-- tests/wallet/test_cli_nut18.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 2f4507b5a..adeeadd3e 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -500,6 +500,21 @@ async def pay( await print_balance(ctx) +def _parse_supported_method(spec: str) -> Optional[SupportedMethod]: + """Parse a `--method` value of the form `name` or `name:fee` into a + SupportedMethod, or return None if `fee` isn't a non-negative int.""" + mn, sep, fee_str = spec.partition(":") + if not sep: + return SupportedMethod(mn=mn) + try: + fee = int(fee_str) + except ValueError: + return None + if fee < 0: + return None + return SupportedMethod(mn=mn, mf=fee) + + @cli.command("request", help="Create a NUT-18 payment request.") @click.argument("amount", type=int) @click.option("--description", "-d", default=None, help="Human-readable description.") @@ -520,7 +535,11 @@ async def pay( "--method", "methods", multiple=True, - help="Required supported method, e.g. bolt11 (repeatable).", + help=( + "Required supported method as name or name:fee, e.g. bolt11 or" + " onchain:50 (repeatable). The fee applies only when the payer's" + " mint is outside a preferred mint list." + ), ) @click.option( "--single-use", "-s", default=False, is_flag=True, help="Mark the request single use." @@ -543,12 +562,21 @@ async def request( ): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() + + supported_methods = [] + for spec in methods: + parsed = _parse_supported_method(spec) + if parsed is None: + print(f"Error: Invalid --method '{spec}', fee must be a non-negative int.") + return + supported_methods.append(parsed) + pr = PaymentRequest( a=amount, u=wallet.unit.name, m=list(mints) or [wallet.url], mp=True if preferred else None, - sm=[SupportedMethod(mn=m) for m in methods] or None, + sm=supported_methods or None, s=True if single_use else None, d=description, ) diff --git a/tests/wallet/test_cli_nut18.py b/tests/wallet/test_cli_nut18.py index 9670ba656..428b41b8a 100644 --- a/tests/wallet/test_cli_nut18.py +++ b/tests/wallet/test_cli_nut18.py @@ -214,3 +214,43 @@ def test_request_creates_payment_request(mint, cli_prefix): assert pr.mp is True assert pr.sm == [SupportedMethod(mn="bolt11")] assert pr.s is True + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_request_method_with_fee_syntax(mint, cli_prefix): + """--method name:fee builds a SupportedMethod with a per-method fee.""" + runner = CliRunner() + + result = runner.invoke( + cli, + [ + *cli_prefix, "request", "100", + "--method", "bolt11", + "--method", "onchain:50", + ], + ) + assert result.exception is None, f"Exception: {result.exception}" + + pr = deserialize(_extract_creq(result.output)) + assert pr.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="onchain", mf=50), + ] + + +@pytest.mark.skipif(not is_fake, reason="only works with FakeWallet") +def test_request_rejects_invalid_method_fee(mint, cli_prefix): + """--method name:fee with a non-integer or negative fee is rejected.""" + runner = CliRunner() + + result = runner.invoke( + cli, [*cli_prefix, "request", "100", "--method", "onchain:-5"] + ) + assert "Invalid --method" in result.output + assert "creqA" not in result.output + + result = runner.invoke( + cli, [*cli_prefix, "request", "100", "--method", "onchain:abc"] + ) + assert "Invalid --method" in result.output + assert "creqA" not in result.output