diff --git a/.env.example b/.env.example index aae43e0ce..78b607024 100644 --- a/.env.example +++ b/.env.example @@ -65,7 +65,7 @@ MINT_DATABASE=data/mint # Funding source backends # Set one funding source backend for each unit -# Supported: FakeWallet, LndRestWallet, LndRPCWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated) +# Supported: FakeWallet, LndRestWallet, LndRPCWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated), NWCWallet MINT_BACKEND_BOLT11_SAT=FakeWallet # Only works if a usd derivation path is set @@ -102,6 +102,9 @@ MINT_CORELIGHTNING_REST_URL=https://localhost:3001 MINT_CORELIGHTNING_REST_MACAROON="./clightning-rest/access.macaroon" MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" +# Use with NWCWallet +MINT_NWC_URL=nostr+walletconnect://?relay=&secret= + # Use with LNbitsWallet MINT_LNBITS_ENDPOINT=https://legend.lnbits.com MINT_LNBITS_KEY=yourkeyasdasdasd diff --git a/README.md b/README.md index 4291515af..8f81f2556 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Cashu is a free and open-source [Ecash protocol](https://github.com/cashubtc/nut ### Feature overview -- Bitcoin Lightning support (LND, CLN, et al.) +- Bitcoin Lightning support (LND, CLN, NWC, et al.) - Full support for the Cashu protocol [specifications](https://github.com/cashubtc/nuts) - Standalone CLI wallet and mint server - Wallet and mint library you can include in other Python projects diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 77ff00869..e70bdc804 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -103,6 +103,7 @@ class MintBackends(MintSettings): mint_lnbits_key: str = Field(default=None) mint_strike_key: str = Field(default=None) mint_blink_key: str = Field(default=None) + mint_nwc_url: str = Field(default=None) class MintLimits(MintSettings): diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index dfa66b941..5c772fadb 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -7,14 +7,16 @@ from .lnbits import LNbitsWallet # noqa: F401 from .lnd_grpc.lnd_grpc import LndRPCWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 +from .nwc import NWCWallet # noqa: F401 from .strike import StrikeWallet # noqa: F401 backend_settings = [ settings.mint_backend_bolt11_sat, + settings.mint_backend_bolt11_msat, settings.mint_backend_bolt11_usd, settings.mint_backend_bolt11_eur, ] if all([s is None for s in backend_settings]): raise Exception( - "MINT_BACKEND_BOLT11_SAT or MINT_BACKEND_BOLT11_USD or MINT_BACKEND_BOLT11_EUR not set" + "At least one MINT_BACKEND_BOLT11_{SAT|MSAT|USD|EUR} must be set" ) diff --git a/cashu/lightning/nwc.py b/cashu/lightning/nwc.py new file mode 100644 index 000000000..a9962ba31 --- /dev/null +++ b/cashu/lightning/nwc.py @@ -0,0 +1,188 @@ +from typing import AsyncGenerator, Optional + +from bolt11 import decode +from loguru import logger + +from ..core.base import Amount, MeltQuote, Unit +from ..core.helpers import fee_reserve +from ..core.models import PostMeltQuoteRequest +from ..core.settings import settings +from ..nostr.nwc import ( + Nip47Error, + Nip47LookupInvoiceRequest, + Nip47MakeInvoiceRequest, + Nip47PayInvoiceRequest, + NWCClient, +) +from .base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentResult, + PaymentStatus, + StatusResponse, +) + +required_nip47_methods = [ + "get_info", + "get_balance", + "make_invoice", + "pay_invoice", + "lookup_invoice", +] + + +class NWCWallet(LightningBackend): + + supported_units = {Unit.sat, Unit.msat} + + def __init__(self, unit: Unit, **kwargs): + logger.debug(f"Initializing NWCWallet with unit: {unit}") + logger.debug(f"Unit type: {type(unit)}") + logger.debug(f"Supported units: {self.supported_units}") + logger.debug(f"Supported units types: {[type(u) for u in self.supported_units]}") + logger.debug(f"Unit in supported_units: {unit in self.supported_units}") + logger.debug(f"Unit.msat: {Unit.msat}, Unit.sat: {Unit.sat}") + self.assert_unit_supported(unit) + self.unit = unit + self.client = NWCClient(nostrWalletConnectUrl=settings.mint_nwc_url) + + async def status(self) -> StatusResponse: + try: + info = await self.client.get_info() + if not all([method in info.methods for method in required_nip47_methods]): + return StatusResponse( + error_message=f"NWC does not support all required methods. Supports: {info.methods}", + balance=Amount(unit=self.unit, amount=0), + ) + res = await self.client.get_balance() + balance_msat = res.balance + # NWC returns balance in msats, convert to configured unit + balance_amount = Amount(unit=Unit.msat, amount=balance_msat) + return StatusResponse(balance=balance_amount.to(self.unit), error_message=None) + except Nip47Error as exc: + return StatusResponse( + error_message=str(exc), + balance=Amount(unit=self.unit, amount=0), + ) + except Exception as exc: + return StatusResponse( + error_message=f"Failed to connect to lightning wallet via NWC due to: {exc}", + balance=Amount(unit=self.unit, amount=0), + ) + + async def create_invoice( + self, + amount: Amount, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[str] = None, + ) -> InvoiceResponse: + try: + # NWC expects amount in msats, convert from configured unit + amount_msat = amount.to(Unit.msat).amount + res = await self.client.create_invoice( + request=Nip47MakeInvoiceRequest(amount=amount_msat) + ) + return InvoiceResponse( + checking_id=res.payment_hash, + payment_request=res.invoice, + ok=True, + error_message=None, + ) + except Nip47Error as exc: + return InvoiceResponse( + error_message=str(exc), + ok=False, + ) + except Exception as exc: + return InvoiceResponse( + error_message=f"Failed to create invoice due to: {exc}", + ok=False, + ) + + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: + try: + pay_invoice_res = await self.client.pay_invoice( + Nip47PayInvoiceRequest(invoice=quote.request) + ) + invoice = await self.client.lookup_invoice( + Nip47LookupInvoiceRequest(payment_hash=quote.checking_id) + ) + # NWC returns fees in msats, convert to configured unit + fees_msat = invoice.fees_paid + fees_amount = Amount(unit=Unit.msat, amount=fees_msat) + + return PaymentResponse( + result=PaymentResult.SETTLED, + checking_id=None, + fee=fees_amount.to(self.unit), + preimage=pay_invoice_res.preimage, + ) + except Nip47Error as exc: + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=str(exc), + ) + except Exception as exc: + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=f"Failed to pay invoice due to: {exc}", + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + res = await self.client.lookup_invoice( + Nip47LookupInvoiceRequest(payment_hash=checking_id) + ) + paid = res.preimage is not None and res.preimage != "" + return PaymentStatus(paid=paid) + except Exception as exc: + logger.error(f"Failed to get invoice status due to: {exc}") + return PaymentStatus(paid=False) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + res = await self.client.lookup_invoice( + Nip47LookupInvoiceRequest(payment_hash=checking_id) + ) + paid = res.preimage is not None and res.preimage != "" + return PaymentStatus(paid=paid) + except Exception as exc: + logger.error(f"Failed to get invoice status due to: {exc}") + return PaymentStatus(paid=False) + + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + # get amount from melt_quote or from bolt11 + amount = ( + Amount(Unit[melt_quote.unit], melt_quote.mpp_amount) + if melt_quote.is_mpp + else None + ) + + invoice_obj = decode(melt_quote.request) + assert invoice_obj.amount_msat, "invoice has no amount." + + if amount: + amount_msat = amount.to(Unit.msat).amount + else: + amount_msat = int(invoice_obj.amount_msat) + + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + + amount = Amount(unit=Unit.msat, amount=amount_msat) + + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), + ) + + def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + raise NotImplementedError("paid_invoices_stream not implemented") diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index bc14a6713..f3a8a99a4 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -35,6 +35,7 @@ "mint_lnbits_key", "mint_blink_key", "mint_strike_key", + "mint_nwc_url", "mint_lnd_rest_macaroon", "mint_lnd_rest_admin_macaroon", "mint_lnd_rest_invoice_macaroon", diff --git a/cashu/nostr/event.py b/cashu/nostr/event.py index f63322844..ff52640aa 100644 --- a/cashu/nostr/event.py +++ b/cashu/nostr/event.py @@ -16,6 +16,8 @@ class EventKind(IntEnum): CONTACTS = 3 ENCRYPTED_DIRECT_MESSAGE = 4 DELETE = 5 + NWC_REQUEST = 23194 + NWC_RESPONSE = 23195 @dataclass @@ -125,3 +127,32 @@ def id(self) -> str: " encrypted and stored in the `content` field" ) return super().id + + +@dataclass +class NWCRequest(Event): + recipient_pubkey: str = None + cleartext_content: str = None + + def __post_init__(self): + if self.content is not None: + self.cleartext_content = self.content + self.content = None + + if self.recipient_pubkey is None: + raise Exception("Must specify a recipient_pubkey.") + + self.kind = EventKind.NWC_REQUEST + super().__post_init__() + + # Must specify the DM recipient's pubkey in a 'p' tag + self.add_pubkey_ref(self.recipient_pubkey) + + @property + def id(self) -> str: + if self.content is None: + raise Exception( + "NWCRequest `id` is undefined until its message is" + " encrypted and stored in the `content` field" + ) + return super().id diff --git a/cashu/nostr/key.py b/cashu/nostr/key.py index ecc45adda..628db3972 100644 --- a/cashu/nostr/key.py +++ b/cashu/nostr/key.py @@ -7,7 +7,7 @@ from hashlib import sha256 from .delegation import Delegation -from .event import EncryptedDirectMessage, Event, EventKind +from .event import EncryptedDirectMessage, Event, EventKind, NWCRequest from . import bech32 @@ -109,7 +109,10 @@ def sign_message_hash(self, hash: bytes) -> str: return sig.hex() def sign_event(self, event: Event) -> None: - if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: + if ( + event.kind in {EventKind.ENCRYPTED_DIRECT_MESSAGE, EventKind.NWC_REQUEST} + and event.content is None + ): self.encrypt_dm(event) if event.public_key is None: event.public_key = self.public_key.hex() diff --git a/cashu/nostr/nwc.py b/cashu/nostr/nwc.py new file mode 100644 index 000000000..09d8432ad --- /dev/null +++ b/cashu/nostr/nwc.py @@ -0,0 +1,357 @@ +import asyncio +import json +import os + +from typing import Any, List, Optional, Union +from enum import Enum +from pydantic import BaseModel +from urllib.parse import urlparse, parse_qs +from loguru import logger + +from .event import NWCRequest, EventKind +from .filter import Filter, Filters +from .client.client import NostrClient +from .key import PublicKey +from .message_type import ClientMessageType + + +class Nip47Method(Enum): + get_balance = "get_balance" + make_invoice = "make_invoice" + pay_invoice = "pay_invoice" + lookup_invoice = "lookup_invoice" + get_info = "get_info" + + +class Nip47GetInfoResponse(BaseModel): + alias: str + color: str + pubkey: str + network: str + block_height: int + block_hash: str + methods: List[str] + + +class Nip47GetBalanceResponse(BaseModel): + balance: int # in msats + + +class Nip47PayInvoiceRequest(BaseModel): + invoice: str + amount: Optional[int] = None + + +class Nip47PayInvoiceResponse(BaseModel): + preimage: str + + +class Nip47MakeInvoiceRequest(BaseModel): + amount: int + description: Optional[str] = None + description_hash: Optional[str] = None + expiry: Optional[int] = None + + +# Note: last I checked, Alby and Mutiny can't lookup by invoice, only by payment_hash +class Nip47LookupInvoiceRequest(BaseModel): + invoice: Optional[str] = None + payment_hash: Optional[str] = None + + +class Nip47TransactionType(Enum): + incoming = "incoming" + outgoing = "outgoing" + + +class Nip47Transaction(BaseModel): + type: Nip47TransactionType + payment_hash: str + amount: int + fees_paid: int + invoice: Optional[str] + description: Optional[str] + description_hash: Optional[str] + preimage: Optional[str] + created_at: int + settled_at: Optional[int] + expires_at: Optional[int] + + +class Nip47ErrorCode(Enum): + """https://github.com/nostr-protocol/nips/blob/master/47.md#error-codes""" + + RATE_LIMITED = "RATE_LIMITED" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE" + RESTRICTED = "RESTRICTED" + UNAUTHORIZED = "UNAUTHORIZED" + QUOTA_EXCEEDED = "QUOTA_EXCEEDED" + INTERNAL = "INTERNAL" + OTHER = "OTHER" + + +class Nip47Error(Exception): + def __init__(self, code: Nip47ErrorCode, message: str): + self.code = code + self.message = message + super().__init__(message) + + def __str__(self): + return f"NWC error: {self.code} - {self.message}" + + +class NWCOptions(BaseModel): + wallet_pubkey: str + relays: Optional[List[str]] + secret: Optional[str] + lud16: Optional[str] + pubkey: Optional[str] + + +class NWCClient(NostrClient): + """ + https://github.com/nostr-protocol/nips/blob/master/47.md#nip-47 + """ + + def __init__(self, nostrWalletConnectUrl: str): + assert nostrWalletConnectUrl is not None, "Nostr Wallet Connect URL is required" + options = NWCClient.parse_nwc_url(nostrWalletConnectUrl) + if options.relays is None: + raise ValueError("Missing relays in NWC URL") + if options.secret is None: + raise ValueError("Missing secret in NWC URL") + self.wallet_pubkey = PublicKey(raw_bytes=bytes.fromhex(options.wallet_pubkey)) + self.relays = options.relays + self.secret = options.secret + super().__init__(private_key=self.secret, relays=self.relays, connect=True) + + @staticmethod + def parse_nwc_url(url: str) -> NWCOptions: + """ + https://github.com/nostr-protocol/nips/blob/master/47.md#nostr-wallet-connect-uri + Args: + url: The Nostr Wallet Connect URL. ie. `nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c` + Returns: + The connection details. + """ + # Replace different protocol schemes with http for uniform parsing + url = ( + url.replace("nostrwalletconnect://", "http://") + .replace("nostr+walletconnect://", "http://") + .replace("nostrwalletconnect:", "http://") + .replace("nostr+walletconnect:", "http://") + ) + parsed = urlparse(url) + query = parse_qs(parsed.query) + relays = query.get("relay") + secret = query.get("secret") + wallet_pubkey_hex = parsed.hostname + options = NWCOptions( + relays=relays, wallet_pubkey=wallet_pubkey_hex, secret=secret[0] + ) + return options + + async def get_balance(self) -> Nip47GetBalanceResponse: + def result_validator(result: dict) -> bool: + valid = "balance" in result + if not valid: + logger.warning(f"Expected 'balance' in NWC response: {result}") + return valid + + res = await self.execute_nip47_request( + Nip47Method.get_balance, {}, result_validator + ) + return Nip47GetBalanceResponse(**res) + + async def get_info(self) -> Nip47GetInfoResponse: + def result_validator(result: dict) -> bool: + valid = all( + key in result + for key in [ + "methods", + ] + ) + return valid + + res = await self.execute_nip47_request( + Nip47Method.get_info, {}, result_validator + ) + return Nip47GetInfoResponse(**res) + + async def create_invoice( + self, request: Nip47MakeInvoiceRequest + ) -> Nip47Transaction: + def result_validator(result: dict) -> bool: + valid = all( + key in result + for key in [ + "invoice", + "payment_hash", + ] + ) + if not valid: + logger.warning( + f"Expected 'invoice' and 'payment_hash' in NWC response: {result}" + ) + return valid + + res = await self.execute_nip47_request( + Nip47Method.make_invoice, request.dict(), result_validator + ) + return Nip47Transaction(**res) + + async def lookup_invoice( + self, request: Nip47LookupInvoiceRequest + ) -> Nip47Transaction: + def result_validator(result: dict) -> bool: + valid = all( + key in result + for key in [ + "payment_hash", + "amount", + "fees_paid", + "created_at", + "expires_at", + ] + ) + if not valid: + logger.warning( + f"Expected 'payment_hash', 'amount', 'fees_paid', 'created_at', 'expires_at' in NWC response: {result}" + ) + return valid + + res = await self.execute_nip47_request( + Nip47Method.lookup_invoice, request.dict(), result_validator + ) + return Nip47Transaction(**res) + + async def pay_invoice( + self, request: Nip47PayInvoiceRequest + ) -> Nip47PayInvoiceResponse: + def result_validator(result: dict) -> bool: + valid = "preimage" in result + if not valid: + logger.warning(f"Expected 'preimage' in NWC response: {result}") + return valid + + res = await self.execute_nip47_request( + Nip47Method.pay_invoice, request.dict(), result_validator + ) + return Nip47PayInvoiceResponse(**res) + + async def subscribe_to_response( + self, + request_id: str, + ) -> asyncio.Future: + """ + Subscribe to kind 23195 events authored by the NWC wallet and tagged with request event's ID. + Args: + request_id: The ID of the request event. + Returns: + The response event's decrypted content. + Raises: + Nip47Error: If the response event contains an error. + """ + filters = Filters( + [ + Filter( + kinds=[EventKind.NWC_RESPONSE], + authors=[self.wallet_pubkey.hex()], + event_refs=[request_id], + ) + ] + ) + + # publish subscription + sub_id = os.urandom(4).hex() + self.relay_manager.add_subscription(sub_id, filters) + request = [ClientMessageType.REQUEST, sub_id] + request.extend(filters.to_json_array()) + message = json.dumps(request) + self.relay_manager.publish_message(message) + + logger.debug(f"Subscribed to filters: {filters.to_json_array()}") + future = asyncio.Future() + + # loop until we get a response + while any( + [r.subscriptions.get(sub_id) for r in self.relay_manager.relays.values()] + ): + while self.relay_manager.message_pool.has_events(): + event_msg = self.relay_manager.message_pool.get_event() + try: + decrypted_content = self.private_key.decrypt_message( + event_msg.event.content, event_msg.event.public_key + ) + response = json.loads(decrypted_content) + logger.debug(f"Got NWC response: {response}") + except Exception as e: + future.set_exception(e) + return future + + if response.get("error") is not None: + future.set_exception( + Nip47Error( + code=Nip47ErrorCode(response.get("error").get("code")), + message=response.get("error").get("message", None), + ) + ) + return future + future.set_result(response.get("result")) + # close this subscription + try: + [ + r.close_subscription(sub_id) + for r in self.relay_manager.relays.values() + ] + except KeyError: + # if subscription is already closed, + pass + if future.done(): + break + await asyncio.sleep(0.1) + return future + + async def execute_nip47_request( + self, method: Nip47Method, params: Any, result_validator + ) -> dict: + """ + Sends an NWC request and resolves the response. + Args: + method: The Nip47Method to execute. + params: The request parameters. + result_validator: A function that validates the response result. + Returns: + """ + logger.debug(f"executing NWC request: {method} with params: {params}") + + command = { + "method": method.value, + "params": params, + } + + nwc_request = NWCRequest( + cleartext_content=json.dumps(command), + recipient_pubkey=self.wallet_pubkey.hex(), + public_key=self.public_key.hex(), + ) + + self.private_key.sign_event(nwc_request) + + assert nwc_request.verify(), "Failed to sign NWC request" + + # subscribe to response before sending request + response_future = self.subscribe_to_response( + nwc_request.id, + ) + + # send request + request_json = nwc_request.to_message() + self.relay_manager.publish_message(request_json) + + # wait for and handle response + res = await asyncio.wait_for(response_future, timeout=10) + if not result_validator(res.result()): + raise Exception("Invalid NWC response") + return res.result()