NUT-04: add description (#613)

* NUT-04: add description

* skip test for deprecated api

* fix for lndgrpc

* add test for cli

* add two random tests

* add max length to request model validator

* skip cli test with description for deprecated api

* add cli test for invoice command

* default value to None
This commit is contained in:
callebtc
2024-09-09 11:50:02 +02:00
committed by GitHub
parent 0287c02f97
commit 637e4ba80c
19 changed files with 185 additions and 64 deletions

View File

@@ -117,6 +117,9 @@ class KeysetsResponse_deprecated(BaseModel):
class PostMintQuoteRequest(BaseModel):
unit: str = Field(..., max_length=settings.mint_max_request_length) # output unit
amount: int = Field(..., gt=0) # output amount
description: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # invoice description
class PostMintQuoteResponse(BaseModel):
@@ -206,7 +209,7 @@ class PostMeltQuoteResponse(BaseModel):
state: Optional[str] # state of the quote
expiry: Optional[int] # expiry of the quote
payment_preimage: Optional[str] = None # payment preimage
change: Union[List[BlindedSignature], None] = None
change: Union[List[BlindedSignature], None] = None # NUT-08 change
@classmethod
def from_melt_quote(self, melt_quote: MeltQuote) -> "PostMeltQuoteResponse":

View File

@@ -70,6 +70,7 @@ class LightningBackend(ABC):
supports_mpp: bool = False
supports_incoming_payment_stream: bool = False
supported_units: set[Unit]
supports_description: bool = False
unit: Unit
def assert_unit_supported(self, unit: Unit):

View File

@@ -47,6 +47,7 @@ class BlinkWallet(LightningBackend):
payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False}
supported_units = set([Unit.sat, Unit.msat])
supports_description: bool = True
unit = Unit.sat
def __init__(self, unit: Unit = Unit.sat, **kwargs):

View File

@@ -31,6 +31,7 @@ class CLNRestWallet(LightningBackend):
unit = Unit.sat
supports_mpp = settings.mint_clnrest_enable_mpp
supports_incoming_payment_stream: bool = True
supports_description: bool = True
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)

View File

@@ -30,6 +30,7 @@ class CoreLightningRestWallet(LightningBackend):
supported_units = set([Unit.sat, Unit.msat])
unit = Unit.sat
supports_incoming_payment_stream: bool = True
supports_description: bool = True
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)

View File

@@ -48,6 +48,7 @@ class FakeWallet(LightningBackend):
unit = Unit.sat
supports_incoming_payment_stream: bool = True
supports_description: bool = True
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)

View File

@@ -28,6 +28,7 @@ class LNbitsWallet(LightningBackend):
supported_units = set([Unit.sat])
unit = Unit.sat
supports_incoming_payment_stream: bool = True
supports_description: bool = True
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)

View File

@@ -45,39 +45,43 @@ INVOICE_STATUSES = {
lnrpc.Invoice.InvoiceState.ACCEPTED: None,
}
class LndRPCWallet(LightningBackend):
class LndRPCWallet(LightningBackend):
supports_mpp = settings.mint_lnd_enable_mpp
supports_incoming_payment_stream = True
supported_units = set([Unit.sat, Unit.msat])
supports_description: bool = True
unit = Unit.sat
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)
self.unit = unit
self.endpoint = settings.mint_lnd_rpc_endpoint
cert_path = settings.mint_lnd_rpc_cert
macaroon_path = settings.mint_lnd_rpc_macaroon
if not self.endpoint:
if not settings.mint_lnd_rpc_endpoint:
raise Exception("cannot initialize LndRPCWallet: no endpoint")
self.endpoint = settings.mint_lnd_rpc_endpoint
if not macaroon_path:
raise Exception("cannot initialize LndRPCWallet: no macaroon")
if not cert_path:
raise Exception("no certificate for LndRPCWallet provided")
self.macaroon = codecs.encode(open(macaroon_path, 'rb').read(), 'hex')
self.macaroon = codecs.encode(open(macaroon_path, "rb").read(), "hex")
def metadata_callback(context, callback):
callback([('macaroon', self.macaroon)], None)
callback([("macaroon", self.macaroon)], None)
auth_creds = grpc.metadata_call_credentials(metadata_callback)
# create SSL credentials
os.environ['GRPC_SSL_CIPHER_SUITES'] = 'HIGH+ECDSA'
cert = open(cert_path, 'rb').read()
os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
cert = open(cert_path, "rb").read()
ssl_creds = grpc.ssl_channel_credentials(cert)
# combine macaroon and SSL credentials
@@ -86,11 +90,12 @@ class LndRPCWallet(LightningBackend):
if self.supports_mpp:
logger.info("LndRPCWallet enabling MPP feature")
async def status(self) -> StatusResponse:
r = None
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
r = await lnstub.ChannelBalance(lnrpc.ChannelBalanceRequest())
except AioRpcError as e:
@@ -98,9 +103,8 @@ class LndRPCWallet(LightningBackend):
error_message=f"Error calling Lnd gRPC: {e}", balance=0
)
# NOTE: `balance` field is deprecated. Change this.
return StatusResponse(error_message=None, balance=r.balance*1000)
return StatusResponse(error_message=None, balance=r.balance * 1000)
async def create_invoice(
self,
amount: Amount,
@@ -124,7 +128,9 @@ class LndRPCWallet(LightningBackend):
r = None
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
r = await lnstub.AddInvoice(data)
except AioRpcError as e:
@@ -144,7 +150,7 @@ class LndRPCWallet(LightningBackend):
payment_request=payment_request,
error_message=None,
)
async def pay_invoice(
self, quote: MeltQuote, fee_limit_msat: int
) -> PaymentResponse:
@@ -159,12 +165,12 @@ class LndRPCWallet(LightningBackend):
)
# set the fee limit for the payment
feelimit = lnrpc.FeeLimit(
fixed_msat=fee_limit_msat
)
feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat)
r = None
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
r = await lnstub.SendPaymentSync(
lnrpc.SendRequest(
@@ -195,14 +201,12 @@ class LndRPCWallet(LightningBackend):
preimage=preimage,
error_message=None,
)
async def pay_partial_invoice(
self, quote: MeltQuote, amount: Amount, fee_limit_msat: int
) -> PaymentResponse:
# set the fee limit for the payment
feelimit = lnrpc.FeeLimit(
fixed_msat=fee_limit_msat
)
feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat)
invoice = bolt11.decode(quote.request)
invoice_amount = invoice.amount_msat
@@ -220,7 +224,9 @@ class LndRPCWallet(LightningBackend):
# get the route
r = None
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
router_stub = routerstub.RouterStub(channel)
r = await lnstub.QueryRoutes(
@@ -230,23 +236,27 @@ class LndRPCWallet(LightningBackend):
fee_limit=feelimit,
)
)
'''
"""
# We need to set the mpp_record for a partial payment
mpp_record = lnrpc.MPPRecord(
payment_addr=bytes.fromhex(payer_addr),
total_amt_msat=total_amount_msat,
)
'''
"""
# modify the mpp_record in the last hop
route_nr = 0
r.routes[route_nr].hops[-1].mpp_record.payment_addr = bytes.fromhex(payer_addr)
r.routes[route_nr].hops[-1].mpp_record.total_amt_msat = total_amount_msat
r.routes[route_nr].hops[-1].mpp_record.payment_addr = bytes.fromhex( # type: ignore
payer_addr
)
r.routes[route_nr].hops[ # type: ignore
-1
].mpp_record.total_amt_msat = total_amount_msat
# Send to route request
r = await router_stub.SendToRouteV2(
routerrpc.SendToRouteRequest(
payment_hash=bytes.fromhex(invoice.payment_hash),
route=r.routes[route_nr],
route=r.routes[route_nr], # type: ignore
)
)
except AioRpcError as e:
@@ -275,16 +285,16 @@ class LndRPCWallet(LightningBackend):
preimage=preimage,
error_message=None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = None
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
r = await lnstub.LookupInvoice(
lnrpc.PaymentHash(
r_hash=bytes.fromhex(checking_id)
)
lnrpc.PaymentHash(r_hash=bytes.fromhex(checking_id))
)
except AioRpcError as e:
error_message = f"LookupInvoice failed: {e}"
@@ -292,7 +302,7 @@ class LndRPCWallet(LightningBackend):
return PaymentStatus(paid=None)
return PaymentStatus(paid=INVOICE_STATUSES[r.state])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
@@ -307,7 +317,9 @@ class LndRPCWallet(LightningBackend):
request = routerrpc.TrackPaymentRequest(payment_hash=checking_id_bytes)
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
router_stub = routerstub.RouterStub(channel)
async for payment in router_stub.TrackPaymentV2(request):
if payment is not None and payment.status:
@@ -325,21 +337,23 @@ class LndRPCWallet(LightningBackend):
logger.error(error_message)
return PaymentStatus(paid=None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds
) as channel:
lnstub = lightningstub.LightningStub(channel)
async for invoice in lnstub.SubscribeInvoices(lnrpc.InvoiceSubscription()):
async for invoice in lnstub.SubscribeInvoices(
lnrpc.InvoiceSubscription()
):
if invoice.state != lnrpc.Invoice.InvoiceState.SETTLED:
continue
payment_hash = invoice.r_hash.hex()
yield payment_hash
except AioRpcError as exc:
logger.error(
f"SubscribeInvoices failed: {exc}. Retrying in 1 sec..."
)
logger.error(f"SubscribeInvoices failed: {exc}. Retrying in 1 sec...")
await asyncio.sleep(1)
async def get_payment_quote(

View File

@@ -33,6 +33,7 @@ class LndRestWallet(LightningBackend):
supports_mpp = settings.mint_lnd_enable_mpp
supports_incoming_payment_stream = True
supported_units = set([Unit.sat, Unit.msat])
supports_description: bool = True
unit = Unit.sat
def __init__(self, unit: Unit = Unit.sat, **kwargs):

View File

@@ -1,6 +1,6 @@
# type: ignore
import secrets
from typing import AsyncGenerator, Dict, Optional
from typing import AsyncGenerator, Optional
import httpx
@@ -23,6 +23,7 @@ class StrikeWallet(LightningBackend):
"""https://docs.strike.me/api/"""
supported_units = [Unit.sat, Unit.usd, Unit.eur]
supports_description: bool = False
currency_map = {Unit.sat: "BTC", Unit.usd: "USD", Unit.eur: "EUR"}
def __init__(self, unit: Unit, **kwargs):
@@ -95,13 +96,6 @@ class StrikeWallet(LightningBackend):
) -> InvoiceResponse:
self.assert_unit_supported(amount.unit)
data: Dict = {"out": False, "amount": amount}
if description_hash:
data["description_hash"] = description_hash.hex()
if unhashed_description:
data["unhashed_description"] = unhashed_description.hex()
data["memo"] = memo or ""
payload = {
"correlationId": secrets.token_hex(16),
"description": "Invoice for order 123",

View File

@@ -403,6 +403,12 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
quote_request.unit, Method.bolt11.name
)
if (
quote_request.description
and not self.backends[method][unit].supports_description
):
raise NotAllowedError("Backend does not support descriptions.")
if settings.mint_max_balance:
balance = await self.get_balance()
if balance + quote_request.amount > settings.mint_max_balance:
@@ -411,7 +417,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe
logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}")
invoice_response: InvoiceResponse = await self.backends[method][
unit
].create_invoice(Amount(unit=unit, amount=quote_request.amount))
].create_invoice(
amount=Amount(unit=unit, amount=quote_request.amount),
memo=quote_request.description,
)
logger.trace(
f"got invoice {invoice_response.payment_request} with checking id"
f" {invoice_response.checking_id}"

View File

@@ -241,6 +241,7 @@ async def pay(
@cli.command("invoice", help="Create Lighting invoice.")
@click.argument("amount", type=float)
@click.option("memo", "-m", default="", help="Memo for the invoice.", type=str)
@click.option("--id", default="", help="Id of the paid invoice.", type=str)
@click.option(
"--split",
@@ -259,7 +260,14 @@ async def pay(
)
@click.pass_context
@coro
async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bool):
async def invoice(
ctx: Context,
amount: float,
memo: str,
id: str,
split: int,
no_check: bool,
):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_mint()
await print_balance(ctx)
@@ -324,11 +332,11 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo
)
if mint_supports_websockets and not no_check:
invoice, subscription = await wallet.request_mint_with_callback(
amount, callback=mint_invoice_callback
amount, callback=mint_invoice_callback, memo=memo
)
invoice_nonlocal, subscription_nonlocal = invoice, subscription
else:
invoice = await wallet.request_mint(amount)
invoice = await wallet.request_mint(amount, memo=memo)
if invoice.bolt11:
print("")
print(f"Pay invoice to mint {wallet.unit.str(amount)}:")

View File

@@ -1,3 +1,5 @@
from typing import Optional
import bolt11
from ...core.base import Amount, ProofSpentState, Unit
@@ -33,15 +35,18 @@ class LightningWallet(Wallet):
pass
super().__init__(*args, **kwargs)
async def create_invoice(self, amount: int) -> InvoiceResponse:
async def create_invoice(
self, amount: int, memo: Optional[str] = None
) -> InvoiceResponse:
"""Create lightning invoice
Args:
amount (int): amount in satoshis
memo (str, optional): invoice memo. Defaults to None.
Returns:
str: invoice
"""
invoice = await self.request_mint(amount)
invoice = await self.request_mint(amount, memo)
return InvoiceResponse(
ok=True, payment_request=invoice.bolt11, checking_id=invoice.payment_hash
)

View File

@@ -283,11 +283,15 @@ class LedgerAPI(LedgerAPIDeprecated, object):
@async_set_httpx_client
@async_ensure_mint_loaded
async def mint_quote(self, amount: int, unit: Unit) -> PostMintQuoteResponse:
async def mint_quote(
self, amount: int, unit: Unit, memo: Optional[str] = None
) -> PostMintQuoteResponse:
"""Requests a mint quote from the server and returns a payment request.
Args:
amount (int): Amount of tokens to mint
unit (Unit): Unit of the amount
memo (Optional[str], optional): Memo to attach to Lightning invoice. Defaults to None.
Returns:
PostMintQuoteResponse: Mint Quote Response
@@ -296,7 +300,7 @@ class LedgerAPI(LedgerAPIDeprecated, object):
Exception: If the mint request fails
"""
logger.trace("Requesting mint: POST /v1/mint/bolt11")
payload = PostMintQuoteRequest(unit=unit.name, amount=amount)
payload = PostMintQuoteRequest(unit=unit.name, amount=amount, description=memo)
resp = await self.httpx.post(
join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict()
)

View File

@@ -379,18 +379,19 @@ class Wallet(
logger.trace("Secret check complete.")
async def request_mint_with_callback(
self, amount: int, callback: Callable
self, amount: int, callback: Callable, memo: Optional[str] = None
) -> Tuple[Invoice, SubscriptionManager]:
"""Request a Lightning invoice for minting tokens.
Args:
amount (int): Amount for Lightning invoice in satoshis
callback (Callable): Callback function to be called when the invoice is paid.
memo (Optional[str], optional): Memo for the Lightning invoice. Defaults
Returns:
Invoice: Lightning invoice
"""
mint_qoute = await super().mint_quote(amount, self.unit)
mint_qoute = await super().mint_quote(amount, self.unit, memo)
subscriptions = SubscriptionManager(self.url)
threading.Thread(
target=subscriptions.connect, name="SubscriptionManager", daemon=True
@@ -413,17 +414,18 @@ class Wallet(
await store_lightning_invoice(db=self.db, invoice=invoice)
return invoice, subscriptions
async def request_mint(self, amount: int) -> Invoice:
async def request_mint(self, amount: int, memo: Optional[str] = None) -> Invoice:
"""Request a Lightning invoice for minting tokens.
Args:
amount (int): Amount for Lightning invoice in satoshis
callback (Optional[Callable], optional): Callback function to be called when the invoice is paid. Defaults to None.
memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None.
Returns:
PostMintQuoteResponse: Mint Quote Response
"""
mint_quote_response = await super().mint_quote(amount, self.unit)
mint_quote_response = await super().mint_quote(amount, self.unit, memo)
decoded_invoice = bolt11.decode(mint_quote_response.request)
invoice = Invoice(
amount=amount,
@@ -480,11 +482,12 @@ class Wallet(
return amounts
async def mint_quote(self, amount: int) -> Invoice:
async def mint_quote(self, amount: int, memo: Optional[str] = None) -> Invoice:
"""Request a Lightning invoice for minting tokens.
Args:
amount (int): Amount for Lightning invoice in satoshis
memo (Optional[str], optional): Memo for the Lightning invoice. Defaults to None.
Returns:
Invoice: Lightning invoice for minting tokens

View File

@@ -6,3 +6,6 @@ ignore_missing_imports = True
[mypy-cashu.nostr.*]
ignore_errors = True
[mypy-cashu.lightning.lnd_grpc.protos.*]
ignore_errors = True

View File

@@ -86,6 +86,22 @@ async def test_mint(ledger: Ledger):
)
@pytest.mark.asyncio
async def test_mint_invalid_quote(ledger: Ledger):
await assert_err(
ledger.get_mint_quote(quote_id="invalid_quote_id"),
"quote not found",
)
@pytest.mark.asyncio
async def test_melt_invalid_quote(ledger: Ledger):
await assert_err(
ledger.get_melt_quote(quote_id="invalid_quote_id"),
"quote not found",
)
@pytest.mark.asyncio
async def test_mint_invalid_blinded_message(ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat"))

View File

@@ -1,6 +1,7 @@
import asyncio
from typing import Tuple
import bolt11
import pytest
from click.testing import CliRunner
@@ -8,7 +9,7 @@ from cashu.core.base import TokenV4
from cashu.core.settings import settings
from cashu.wallet.cli.cli import cli
from cashu.wallet.wallet import Wallet
from tests.helpers import is_fake, pay_if_regtest
from tests.helpers import is_deprecated_api_only, is_fake, is_regtest, pay_if_regtest
@pytest.fixture(autouse=True, scope="session")
@@ -107,6 +108,19 @@ def test_balance(cli_prefix):
assert result.exit_code == 0
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
def test_invoice(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "1000"],
)
wallet = asyncio.run(init_wallet())
assert wallet.available_balance >= 1000
assert result.exit_code == 0
def test_invoice_return_immediately(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
@@ -130,6 +144,30 @@ def test_invoice_return_immediately(mint, cli_prefix):
assert result.exit_code == 0
@pytest.mark.skipif(is_deprecated_api_only, reason="only works with v1 API")
def test_invoice_with_memo(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "-n", "1000", "-m", "test memo"],
)
assert result.exception is None
# find word starting with ln in the output
lines = result.output.split("\n")
invoice_str = ""
for line in lines:
for word in line.split(" "):
if word.startswith("ln"):
invoice_str = word
break
if not invoice_str:
raise Exception("No invoice found in the output")
invoice_obj = bolt11.decode(invoice_str)
assert invoice_obj.amount_msat == 1000_000
assert invoice_obj.description == "test memo"
def test_invoice_with_split(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(

View File

@@ -1,5 +1,6 @@
from typing import List, Union
import bolt11
import pytest
import pytest_asyncio
@@ -7,7 +8,13 @@ from cashu.core.base import Proof
from cashu.core.errors import CashuError
from cashu.wallet.lightning import LightningWallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
from tests.helpers import (
get_real_invoice,
is_deprecated_api_only,
is_fake,
is_regtest,
pay_if_regtest,
)
async def assert_err(f, msg: Union[str, CashuError]):
@@ -58,6 +65,16 @@ async def test_create_invoice(wallet: LightningWallet):
assert invoice.payment_request.startswith("ln")
@pytest.mark.asyncio
@pytest.mark.skipif(is_deprecated_api_only, reason="only works with v1 API")
async def test_create_invoice_with_description(wallet: LightningWallet):
invoice = await wallet.create_invoice(64, "test description")
assert invoice.payment_request
assert invoice.payment_request.startswith("ln")
invoiceObj = bolt11.decode(invoice.payment_request)
assert invoiceObj.description == "test description"
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_check_invoice_internal(wallet: LightningWallet):