diff --git a/cashu/core/base.py b/cashu/core/base.py index 17368c050..973987d98 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1678,12 +1678,19 @@ 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 u: Optional[str] = None # unit s: Optional[bool] = None # single use m: Optional[List[str]] = None # mints + mp: Optional[bool] = None # mint list preferred + 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/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/core/nuts/nut26.py b/cashu/core/nuts/nut26.py index 6c364e959..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: @@ -273,6 +299,11 @@ 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.mp is not None: + out += _tlv_entry(0x09, bytes([1 if pr.mp else 0])) + if pr.sm: + for method in pr.sm: + out += _tlv_entry(0x0A, _encode_supported_method(method)) return out def _tlv_to_pr(data: bytes) -> PaymentRequest: @@ -280,10 +311,11 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: kwargs: dict = {} mints: List[str] = [] transports: List[Transport] = [] + 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: @@ -307,11 +339,17 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest: transports.append(_decode_transport(val)) elif tag == 0x08: kwargs["nut10"] = _decode_nut10(val) + elif tag == 0x09: + kwargs["mp"] = val[0] == 1 + elif tag == 0x0A: + supported_methods.append(_decode_supported_method(val)) if mints: kwargs["m"] = mints if transports: kwargs["t"] = transports + if supported_methods: + kwargs["sm"] = supported_methods return PaymentRequest(**kwargs) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 18fa65250..adeeadd3e 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -25,6 +25,8 @@ Method, MintQuote, MintQuoteState, + PaymentRequest, + SupportedMethod, TokenV4, Unit, ) @@ -33,6 +35,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 +300,36 @@ 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 `mp` is explicitly true (preferred) + mint_outside_list = pr.m is not None and wallet.url not in pr.m + 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}") return + # 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 + 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 + # 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 method fee of {wallet.unit.str(method_fee)} " + "(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 +500,94 @@ 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.") +@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( + "--method", + "methods", + multiple=True, + 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." +) +@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, + methods: tuple, + single_use: bool, + bech32: bool, +): + 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=supported_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/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): diff --git a/tests/wallet/test_cli_nut18.py b/tests/wallet/test_cli_nut18.py index 29ac7ea41..428b41b8a 100644 --- a/tests/wallet/test_cli_nut18.py +++ b/tests/wallet/test_cli_nut18.py @@ -1,8 +1,8 @@ import pytest from click.testing import CliRunner -from cashu.core.base import NUT10Option, PaymentRequest -from cashu.core.nuts.nut18 import serialize +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 from tests.helpers import is_fake @@ -81,3 +81,176 @@ 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 + + +# ─── 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_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, + sm=[SupportedMethod(mn="bolt11", mf=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 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 hard-rejected, + regardless of the mint list.""" + runner = CliRunner() + + pr = PaymentRequest(a=10, u="sat", sm=[SupportedMethod(mn="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", + "--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.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 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"] diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 818b8d885..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 @@ -58,6 +60,72 @@ def test_nut18_round_trip(): assert decoded.i is None +def test_nut18_preferred_mints_vector(): + """Frozen spec vector: preferred mint list with supported methods and a per-method fee.""" + expected = ( + "creqApmFpdXByZWZlcnJlZF9mZWVfbWV0aG9kc2FhGGRhdWNzYXRhbYF4GGh0dHBz" + "Oi8vbWludC5leGFtcGxlLmNvbWJtcPVic22CoWJtbmZib2x0MTGiYm1uZmJvbHQx" + "MmJtZgU" + ) + req = PaymentRequest( + i="preferred_fee_methods", + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + 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 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, + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], + ) + decoded = deserialize(serialize(req)) + assert decoded.mp is True + 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 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.sm is None + + # ─── NUT-26 Tests ──────────────────────────────────────────────────── def test_nut26_spec_example(): """Test the example from the NUT-26 spec.""" @@ -143,6 +211,88 @@ def test_nut26_round_trip_nut10(): assert decoded.nut10.d == "abcdef1234567890" * 4 +def test_nut26_preferred_mints_vector(): + """Frozen spec vector: preferred mint list with supported methods and a per-method fee.""" + expected = ( + "CREQB1QYQP2URJV4NX2UNJV4J97EN9V40K6ET5DPHKGUCZQQYQQQQQQQQQQQRYQVQ" + "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQJQGQQE3X" + "7MR5XYCS5QQ5QYQQVCN0D36RZVSZQQYQQQQQQQQQQQQ9FJ2568" + ) + req = PaymentRequest( + i="preferred_fee_methods", + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + 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 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, + sm=[SupportedMethod(mn="bolt11"), SupportedMethod(mn="bolt12", mf=5)], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.mp is True + assert decoded.sm == [ + SupportedMethod(mn="bolt11"), + SupportedMethod(mn="bolt12", mf=5), + ] + + +def test_nut26_round_trip_strict_mint_list(): + """mp=False round-trips and a single supported method is preserved.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + mp=False, + sm=[SupportedMethod(mn="bolt11")], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.mp is False + 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(): """Bech32m decoding must accept both upper and lower case.""" req = PaymentRequest(a=1, u="sat")