diff --git a/cashu/core/base.py b/cashu/core/base.py index 51aaf2a98..f6c0220c3 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1494,6 +1494,9 @@ class PaymentRequest(BaseModel): u: Optional[str] = None # unit 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 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..87f6a070f 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.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()) 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["mp"] = 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) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 18fa65250..28fef0b08 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,33 @@ 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 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 +496,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], + mp=True 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..06b08c7e2 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 preferred (mp=True) mint list is accepted and adds the fee reserve.""" + runner = CliRunner() + + pr = PaymentRequest( + a=10, u="sat", m=["https://other.mint/"], mp=True, 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.mp is True + 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"] diff --git a/tests/wallet/test_wallet_payment_request.py b/tests/wallet/test_wallet_payment_request.py index 818b8d885..8a342fa83 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" + "Oi8vbWludC5leGFtcGxlLmNvbWJtcPViZnICYnNtgWZib2x0MTE" + ) + req = PaymentRequest( + i="preferred_fee_methods", + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + fr=2, + sm=["bolt11"], + ) + assert serialize(req) == expected + + +def test_nut18_round_trip_mint_list_fields(): + """mp, fr and sm round-trip through the CBOR format.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + fr=2, + sm=["bolt11"], + ) + decoded = deserialize(serialize(req)) + assert decoded.mp is True + assert decoded.fr == 2 + assert decoded.sm == ["bolt11"] + + +def test_nut18_omits_mint_list_fields_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.mp 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" + "QZQQ9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5YSQQGPPGQQSQQQQQQQ" + "QQQQQG9SQPNZDAK8GVF3KE6Q6F" + ) + req = PaymentRequest( + i="preferred_fee_methods", + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + fr=2, + sm=["bolt11"], + ) + assert serialize_bech32m(req) == expected + + +def test_nut26_round_trip_mint_list_fields(): + """mp, fr and sm round-trip through the TLV format.""" + req = PaymentRequest( + a=100, + u="sat", + m=["https://mint.example.com"], + mp=True, + fr=2, + sm=["bolt11"], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.mp is True + assert decoded.fr == 2 + assert decoded.sm == ["bolt11"] + + +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=["bolt11"], + ) + decoded = deserialize(serialize_bech32m(req)) + assert decoded.mp is False + 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")