From 826ac82fcf4d67385d37ac066bb342622e2ecbcc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 18:23:29 +0200 Subject: [PATCH 1/9] NUT-04: add description --- cashu/core/models.py | 1 + cashu/lightning/base.py | 1 + cashu/lightning/blink.py | 1 + cashu/lightning/clnrest.py | 1 + cashu/lightning/corelightningrest.py | 1 + cashu/lightning/fake.py | 1 + cashu/lightning/lnbits.py | 1 + cashu/lightning/lndrest.py | 1 + cashu/lightning/strike.py | 10 ++-------- cashu/mint/ledger.py | 11 ++++++++++- cashu/wallet/cli/cli.py | 14 +++++++++++--- cashu/wallet/lightning/lightning.py | 9 +++++++-- cashu/wallet/v1_api.py | 8 ++++++-- cashu/wallet/wallet.py | 13 ++++++++----- tests/test_wallet_lightning.py | 10 ++++++++++ 15 files changed, 62 insertions(+), 21 deletions(-) diff --git a/cashu/core/models.py b/cashu/core/models.py index 4f3c8f3e..0e5b2261 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -117,6 +117,7 @@ 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] = None # invoice description class PostMintQuoteResponse(BaseModel): diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 06fbe975..c1500baa 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -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): diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 2320baad..1acb8a51 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -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): diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 129c4d7f..0dcb68eb 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -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) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 94dcf6f8..51a1ac23 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -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) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 7517bfe3..b9d3cb39 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -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) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index d231e043..7daff1fe 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -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) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 3274da97..18dbcd49 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -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): diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index cf35cb4a..2103c2ce 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -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 @@ async def create_invoice( ) -> 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", diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 434cbb93..324de902 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -403,6 +403,12 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: 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 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: 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}" diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 59e8009a..d41c45cb 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -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 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams): ) 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)}:") diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 56ab06aa..3e5489d7 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -1,3 +1,5 @@ +from typing import Optional + import bolt11 from ...core.base import Amount, ProofSpentState, Unit @@ -33,15 +35,18 @@ def __init__(self, *args, **kwargs): 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 ) diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index e68c5560..f1a8bb16 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -283,11 +283,15 @@ async def _get_info(self) -> GetInfoResponse: @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 @@ async def mint_quote(self, amount: int, unit: Unit) -> PostMintQuoteResponse: 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() ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 8a255d5c..20f9839e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -379,18 +379,19 @@ async def _check_used_secrets(self, secrets): 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 @@ async def request_mint_with_callback( 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 @@ def split_wallet_state(self, amount: int) -> List[int]: 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 diff --git a/tests/test_wallet_lightning.py b/tests/test_wallet_lightning.py index 3b3cdd48..a40a4446 100644 --- a/tests/test_wallet_lightning.py +++ b/tests/test_wallet_lightning.py @@ -1,5 +1,6 @@ from typing import List, Union +import bolt11 import pytest import pytest_asyncio @@ -58,6 +59,15 @@ async def test_create_invoice(wallet: LightningWallet): assert invoice.payment_request.startswith("ln") +@pytest.mark.asyncio +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): From 1655e096305f4fd47d097f0e1a25aa48de3ce732 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:30:18 +0200 Subject: [PATCH 2/9] skip test for deprecated api --- tests/test_wallet_lightning.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_wallet_lightning.py b/tests/test_wallet_lightning.py index a40a4446..5dd567f8 100644 --- a/tests/test_wallet_lightning.py +++ b/tests/test_wallet_lightning.py @@ -8,7 +8,13 @@ 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]): @@ -60,6 +66,7 @@ async def test_create_invoice(wallet: LightningWallet): @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 From a569a71272eeb4f331aa55756f4c3b53a0f60369 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:34:04 +0200 Subject: [PATCH 3/9] fix for lndgrpc --- cashu/lightning/lnd_grpc/lnd_grpc.py | 94 ++++++++++++++++------------ mypy.ini | 3 + 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index 0c38e623..fa667ee0 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -45,39 +45,43 @@ 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 @@ def metadata_callback(context, callback): 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 @@ async def status(self) -> StatusResponse: 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 @@ async def create_invoice( 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 @@ async def create_invoice( payment_request=payment_request, error_message=None, ) - + async def pay_invoice( self, quote: MeltQuote, fee_limit_msat: int ) -> PaymentResponse: @@ -159,12 +165,12 @@ async def pay_invoice( ) # 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 @@ async def pay_invoice( 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 @@ async def pay_partial_invoice( # 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 @@ async def pay_partial_invoice( 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 @@ async def pay_partial_invoice( 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 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: 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 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: 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 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: 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( diff --git a/mypy.ini b/mypy.ini index 0df56bd0..087ac3c0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,3 +6,6 @@ ignore_missing_imports = True [mypy-cashu.nostr.*] ignore_errors = True + +[mypy-cashu.lightning.lnd_grpc.protos.*] +ignore_errors = True From 4f976b472c94e066b06f8814c6260a7a33d0e0fd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:45:39 +0200 Subject: [PATCH 4/9] add test for cli --- tests/test_wallet_cli.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_wallet_cli.py b/tests/test_wallet_cli.py index 81a2e4b6..787f7c56 100644 --- a/tests/test_wallet_cli.py +++ b/tests/test_wallet_cli.py @@ -1,6 +1,7 @@ import asyncio from typing import Tuple +import bolt11 import pytest from click.testing import CliRunner @@ -130,6 +131,29 @@ def test_invoice_return_immediately(mint, cli_prefix): assert result.exit_code == 0 +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( From 9c7871356233f0d2c64be4011717d5dba659b1d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:49:05 +0200 Subject: [PATCH 5/9] add two random tests --- tests/test_mint.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_mint.py b/tests/test_mint.py index f5976a82..9d097296 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -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")) From 72177f1bdf6aaf9cc1f4b573ef548718b23cc3b2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:51:05 +0200 Subject: [PATCH 6/9] add max length to request model validator --- cashu/core/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cashu/core/models.py b/cashu/core/models.py index 0e5b2261..a136dd20 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -117,7 +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] = None # invoice description + description: Optional[str] = Field( + ..., max_length=settings.mint_max_request_length + ) # invoice description class PostMintQuoteResponse(BaseModel): @@ -207,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": From b4f6ed8262c3cea7223bb35b96f19d20d496a948 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:55:35 +0200 Subject: [PATCH 7/9] skip cli test with description for deprecated api --- tests/test_wallet_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_wallet_cli.py b/tests/test_wallet_cli.py index 787f7c56..ec366b26 100644 --- a/tests/test_wallet_cli.py +++ b/tests/test_wallet_cli.py @@ -9,7 +9,7 @@ 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, pay_if_regtest @pytest.fixture(autouse=True, scope="session") @@ -131,6 +131,7 @@ 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( From 49b844c3221f2325f77a11f2e49e0daef9f8ca23 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:57:50 +0200 Subject: [PATCH 8/9] add cli test for invoice command --- tests/test_wallet_cli.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_wallet_cli.py b/tests/test_wallet_cli.py index ec366b26..995518dd 100644 --- a/tests/test_wallet_cli.py +++ b/tests/test_wallet_cli.py @@ -9,7 +9,7 @@ from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet -from tests.helpers import is_deprecated_api_only, 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") @@ -108,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( From 6f91caf548df607feaf7327d66bedde22affc961 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:59:55 +0200 Subject: [PATCH 9/9] default value to None --- cashu/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/core/models.py b/cashu/core/models.py index a136dd20..fa0b57bd 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -118,7 +118,7 @@ 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( - ..., max_length=settings.mint_max_request_length + default=None, max_length=settings.mint_max_request_length ) # invoice description