Skip to content
7 changes: 7 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 38 additions & 3 deletions cashu/core/mint_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
42 changes: 40 additions & 2 deletions cashu/core/nuts/nut26.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -273,17 +299,23 @@ 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:
entries = _tlv_parse(data)
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:
Expand All @@ -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)

Expand Down
112 changes: 111 additions & 1 deletion cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
Method,
MintQuote,
MintQuoteState,
PaymentRequest,
SupportedMethod,
TokenV4,
Unit,
)
Expand All @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions tests/mint/test_mint_fees.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading