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
3 changes: 3 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions cashu/core/nuts/nut26.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,21 @@ 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:
entries = _tlv_parse(data)
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,):
Expand Down Expand Up @@ -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)

Expand Down
90 changes: 89 additions & 1 deletion cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Method,
MintQuote,
MintQuoteState,
PaymentRequest,
TokenV4,
Unit,
)
Expand All @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 71 additions & 1 deletion tests/wallet/test_cli_nut18.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
23 changes: 23 additions & 0 deletions tests/wallet/test_cli_nut26.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Loading
Loading