Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1677,12 +1677,20 @@ class NUT10Option(BaseModel):
t: Optional[List[List[str]]] = None # tags


class SupportedMethod(BaseModel):
mn: str = Field(min_length=1) # method name
# per-method fee, absent = 0; u64 range per NUT-26
mf: Optional[int] = Field(default=None, ge=0, lt=2**64)


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
23 changes: 21 additions & 2 deletions cashu/core/mint_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@

from .base import Method, Unit
from .json_rpc.base import JSONRPCSubscriptionKinds
from .models import MintInfoContact, MintInfoProtectedEndpoint, Nut15MppSupport
from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MPP_NUT, WEBSOCKETS_NUT
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 @@ -68,6 +73,20 @@ def supports_mpp(self, method: str, unit: Unit) -> bool:

return False

def supports_melt_method(self, method: str, unit: Unit) -> bool:
if not self.nuts:
return False
nut_05 = self.nuts.get(MELT_NUT)
if not nut_05 or not nut_05.get("methods") or nut_05.get("disabled"):
return False

for entry in nut_05["methods"]:
entry_obj = MeltMethodSetting.model_validate(entry)
if entry_obj.method == method and entry_obj.unit == unit.name:
return True

return False

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
22 changes: 21 additions & 1 deletion cashu/core/nuts/nut18.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import base64
from typing import List, Optional

import cbor2

from ..base import PaymentRequest
from ..base import PaymentRequest, SupportedMethod, Unit
from ..mint_info import MintInfo
from .nut26 import deserialize as deserialize_bech32m


Expand All @@ -28,3 +30,21 @@ def deserialize(creq: str) -> PaymentRequest:
decoded = base64.urlsafe_b64decode(padded)
obj = cbor2.loads(decoded)
return PaymentRequest(**obj)


def method_fee(
sm: Optional[List[SupportedMethod]], mint_info: MintInfo, unit: Unit
) -> Optional[int]:
"""
The fee a payer owes for the methods `sm` requested, which is the lowest `mf`
among the listed methods `mint_info` can melt in `unit`. Returns 0 when `sm`
is unset, i.e. the request makes no method requirement, and `None` when the
mint can melt via none of the listed methods.
"""
if not sm:
return 0

fees = [
entry.mf or 0 for entry in sm if mint_info.supports_melt_method(entry.mn, unit)
]
return min(fees) if fees else None
44 changes: 42 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, Unit
from ..base import NUT10Option, PaymentRequest, SupportedMethod, Transport, Unit

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,19 @@ def _tlv_to_pr(data: bytes) -> PaymentRequest:
transports.append(_decode_transport(val))
elif tag == 0x08:
kwargs["nut10"] = _decode_nut10(val)
elif tag == 0x09:
if len(val) != 1:
raise ValueError("Invalid mint preferred length")
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
134 changes: 133 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,9 @@
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 method_fee as payment_request_method_fee
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 +301,51 @@ 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.rstrip("/") not in [
mint.rstrip("/") for mint 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

# `a` and `mf` are denominated in the request unit `u`
if pr.sm is not None and pr.u is None:
print("Error: Payment request lists supported methods but no unit.")
return
if pr.u is not None and pr.u != wallet.unit.name:
print(
f"Error: Payment request unit '{pr.u}' does not match "
f"wallet unit '{wallet.unit.name}'."
)
return

# The sending mint must support (melt via) one of the requested methods
method_fee = payment_request_method_fee(pr.sm, wallet.mint_info, 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
reason = (
"the request has no mint list"
if pr.m is None
else "paying from a mint outside the preferred list"
)
print(f"Adding method fee of {wallet.unit.str(method_fee)} ({reason}).")

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 +516,100 @@ 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 `name` is empty or `fee` isn't a
non-negative int."""
mn, sep, fee_str = spec.partition(":")
if not mn:
return None
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=click.IntRange(min=1))
@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}', expected a method name and a"
" non-negative int fee."
)
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