Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mint: add Nostr Wallet Connect as a backend #565

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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 @@ -60,7 +60,7 @@ MINT_DATABASE=data/mint
# MINT_DATABASE=postgres://cashu:cashu@localhost:5432/cashu

# Funding source backends
# Supported: FakeWallet, LndRestWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated)
# Supported: FakeWallet, LndRestWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated), NWCWallet
MINT_BACKEND_BOLT11_SAT=FakeWallet
# Only works if a usd derivation path is set
# MINT_BACKEND_BOLT11_SAT=FakeWallet
Expand Down Expand Up @@ -92,6 +92,9 @@ MINT_BLINK_KEY=blink_abcdefgh
# Use with StrikeWallet for BTC, USD, and EUR
MINT_STRIKE_KEY=ABC123

# Use with NWCWallet for BTC
MINT_NWC_URL=nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c

# fee to reserve in percent of the amount
LIGHTNING_FEE_PERCENT=1.0
# minimum fee to reserve
Expand Down
1 change: 1 addition & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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
1 change: 1 addition & 0 deletions cashu/lightning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .fake import FakeWallet # noqa: F401
from .lnbits import LNbitsWallet # noqa: F401
from .lndrest import LndRestWallet # noqa: F401
from .nwc import NWCWallet # noqa: F401
from .strike import StrikeWallet # noqa: F401

backend_settings = [
Expand Down
176 changes: 176 additions & 0 deletions cashu/lightning/nwc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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,
PaymentStatus,
StatusResponse,
)

required_nip47_methods = [
"get_info",
"get_balance",
"make_invoice",
"pay_invoice",
"lookup_invoice",
]


class NWCWallet(LightningBackend):

supported_units = {Unit.sat}

def __init__(self, unit: Unit, **kwargs):
logger.debug(f"Initializing NWCWallet with unit: {unit}")
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=0,
)
res = await self.client.get_balance()
balance_msat = res.balance
return StatusResponse(balance=balance_msat // 1000, error_message=None)
except Nip47Error as exc:
return StatusResponse(
error_message=str(exc),
balance=0,
)
except Exception as exc:
return StatusResponse(
error_message=f"Failed to connect to lightning wallet via NWC due to: {exc}",
balance=0,
)

async def create_invoice(
self,
amount: Amount,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[str] = None,
) -> InvoiceResponse:
try:
res = await self.client.create_invoice(
request=Nip47MakeInvoiceRequest(amount=amount.amount * 1000)
)
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)
)
fees = invoice.fees_paid

return PaymentResponse(
ok=True,
checking_id=None,
fee=Amount(unit=Unit.msat, amount=fees),
preimage=pay_invoice_res.preimage,
)
except Nip47Error as exc:
return PaymentResponse(
ok=False,
error_message=str(exc),
)
except Exception as exc:
return PaymentResponse(
ok=False,
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 @@ -30,6 +30,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 @@ -123,3 +125,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
5 changes: 4 additions & 1 deletion cashu/nostr/key.py
Original file line number Diff line number Diff line change
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
Loading