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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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://<wallet_pubkey>?relay=<relay_url>&secret=<secret_key>

# Use with LNbitsWallet
MINT_LNBITS_ENDPOINT=https://legend.lnbits.com
MINT_LNBITS_KEY=yourkeyasdasdasd
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion cashu/lightning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
188 changes: 188 additions & 0 deletions cashu/lightning/nwc.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions cashu/mint/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions cashu/nostr/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class EventKind(IntEnum):
CONTACTS = 3
ENCRYPTED_DIRECT_MESSAGE = 4
DELETE = 5
NWC_REQUEST = 23194
NWC_RESPONSE = 23195


@dataclass
Expand Down Expand Up @@ -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
7 changes: 5 additions & 2 deletions cashu/nostr/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
Loading