diff --git a/cashu/core/base.py b/cashu/core/base.py index 104a7818..173a1711 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -208,7 +208,7 @@ class MeltQuote(BaseModel): checking_id: str unit: str amount: int - fee_reserve: Optional[int] = None + fee_reserve: int paid: bool @@ -427,6 +427,7 @@ class Unit(Enum): sat = 0 msat = 1 usd = 2 + cheese = 3 def str(self, amount: int) -> str: if self == Unit.sat: @@ -435,10 +436,16 @@ def str(self, amount: int) -> str: return f"{amount} msat" elif self == Unit.usd: return f"${amount/100:.2f} USD" + elif self == Unit.cheese: + return f"E{amount/100:.2f} Chuck E Cheese Tokens" else: raise Exception("Invalid unit") +class Method(Enum): + bolt11 = 0 + + class WalletKeyset: """ Contains the keyset from the wallets's perspective. @@ -486,9 +493,9 @@ def __init__( self.unit = Unit[unit] def serialize(self): - return json.dumps({ - amount: key.serialize().hex() for amount, key in self.public_keys.items() - }) + return json.dumps( + {amount: key.serialize().hex() for amount, key in self.public_keys.items()} + ) @classmethod def from_row(cls, row: Row): @@ -567,8 +574,8 @@ def __init__( # infer unit from derivation path if not unit: logger.warning( - f"Unit for keyset {self.id} not set – attempting to parse from" - " derivation path" + f"Unit for keyset {self.derivation_path} not set – attempting to parse" + " from derivation path" ) try: self.unit = Unit( @@ -587,6 +594,8 @@ def __init__( if self.seed and self.derivation_path: self.generate_keys() + logger.debug(f"Keyset id: {self.id} ({self.unit.name})") + @property def public_keys_hex(self) -> Dict[int, str]: assert self.public_keys, "public keys not set" diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 933948d0..c4108772 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -60,6 +60,8 @@ class MintSettings(CashuSettings): mint_lnbits_endpoint: str = Field(default=None) mint_lnbits_key: str = Field(default=None) + mint_strike_key: str = Field(default=None) + class FakeWalletSettings(MintSettings): fakewallet_brr: bool = Field(default=True) diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index 8b3fbc61..6d6cc3e0 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -4,6 +4,7 @@ from .fake import FakeWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 +from .strike import StrikeUSDWallet # noqa: F401 if settings.mint_lightning_backend is None: raise Exception("MINT_LIGHTNING_BACKEND not configured") diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 17e681c8..60d5771e 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -1,12 +1,25 @@ from abc import ABC, abstractmethod -from typing import Coroutine, Optional +from typing import Coroutine, Optional, Union from pydantic import BaseModel +from ..core.base import Method, Unit + class StatusResponse(BaseModel): error_message: Optional[str] - balance_msat: int + balance: Union[int, float] + + +class InvoiceQuoteResponse(BaseModel): + checking_id: str + amount: int + + +class PaymentQuoteResponse(BaseModel): + checking_id: str + amount: int + fee: int class InvoiceResponse(BaseModel): @@ -48,7 +61,10 @@ def __str__(self) -> str: return "unknown (should never happen)" -class Wallet(ABC): +class LightningWallet(ABC): + unit: Unit + method: Method + @abstractmethod def status(self) -> Coroutine[None, None, StatusResponse]: pass @@ -80,6 +96,20 @@ def get_payment_status( ) -> Coroutine[None, None, PaymentStatus]: pass + @abstractmethod + async def get_payment_quote( + self, + bolt11: str, + ) -> PaymentQuoteResponse: + pass + + # @abstractmethod + # async def get_invoice_quote( + # self, + # bolt11: str, + # ) -> InvoiceQuoteResponse: + # pass + # @abstractmethod # def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # pass diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 41bca08d..fea46b8a 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -11,16 +11,16 @@ from ..core.settings import settings from .base import ( InvoiceResponse, + LightningWallet, PaymentResponse, PaymentStatus, StatusResponse, Unsupported, - Wallet, ) from .macaroon import load_macaroon -class CoreLightningRestWallet(Wallet): +class CoreLightningRestWallet(LightningWallet): def __init__(self): macaroon = settings.mint_corelightning_rest_macaroon assert macaroon, "missing cln-rest macaroon" diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 64b86dee..796ce27b 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import math import random from datetime import datetime from os import urandom @@ -14,19 +15,26 @@ encode, ) +from ..core.base import Method, Unit +from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( + InvoiceQuoteResponse, InvoiceResponse, + LightningWallet, + PaymentQuoteResponse, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) -class FakeWallet(Wallet): +class FakeWallet(LightningWallet): """https://github.com/lnbits/lnbits""" + method = Method.bolt11 + unit = Unit.sat + queue: asyncio.Queue[Bolt11] = asyncio.Queue(0) payment_secrets: Dict[str, str] = dict() paid_invoices: Set[str] = set() @@ -40,7 +48,7 @@ class FakeWallet(Wallet): ).hex() async def status(self) -> StatusResponse: - return StatusResponse(error_message=None, balance_msat=1337) + return StatusResponse(error_message=None, balance=1337) async def create_invoice( self, @@ -125,3 +133,18 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: value: Bolt11 = await self.queue.get() yield value.payment_hash + + async def get_invoice_quote(self, bolt11: str) -> InvoiceQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount = invoice_obj.amount_msat + return InvoiceQuoteResponse(checking_id="", amount=amount) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fee_sat = math.ceil(fees_msat / 1000) + amount_sat = math.ceil(amount_msat / 1000) + return PaymentQuoteResponse(checking_id="", fee=fee_sat, amount=amount_sat) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 0f12b0b9..a8d5e33a 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -6,14 +6,14 @@ from ..core.settings import settings from .base import ( InvoiceResponse, + LightningWallet, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) -class LNbitsWallet(Wallet): +class LNbitsWallet(LightningWallet): """https://github.com/lnbits/lnbits""" def __init__(self): diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index f3f6c8bf..39c257c5 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -10,15 +10,15 @@ from ..core.settings import settings from .base import ( InvoiceResponse, + LightningWallet, PaymentResponse, PaymentStatus, StatusResponse, - Wallet, ) from .macaroon import load_macaroon -class LndRestWallet(Wallet): +class LndRestWallet(LightningWallet): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" def __init__(self): diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py new file mode 100644 index 00000000..1aca6803 --- /dev/null +++ b/cashu/lightning/strike.py @@ -0,0 +1,207 @@ +# type: ignore +import secrets +from typing import Dict, Optional + +import httpx + +from ..core.base import Method, Unit +from ..core.settings import settings +from .base import ( + InvoiceResponse, + LightningWallet, + PaymentQuoteResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, +) + + +class StrikeUSDWallet(LightningWallet): + """https://github.com/lnbits/lnbits""" + + method = Method.bolt11 + unit = Unit.usd + + def __init__(self): + self.endpoint = "https://api.strike.me" + + # bearer auth with settings.mint_strike_key + bearer_auth = { + "Authorization": f"Bearer {settings.mint_strike_key}", + } + self.client = httpx.AsyncClient( + verify=not settings.debug, + headers=bearer_auth, + ) + + async def status(self) -> StatusResponse: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/balances", timeout=15) + r.raise_for_status() + except Exception as exc: + return StatusResponse( + error_message=f"Failed to connect to {self.endpoint} due to: {exc}", + balance=0, + ) + + try: + data = r.json() + except Exception: + return StatusResponse( + error_message=( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'" + ), + balance=0, + ) + + for balance in data: + if balance["currency"] == "USD": + return StatusResponse(error_message=None, balance=balance["total"]) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + ) -> InvoiceResponse: + 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", + "amount": {"amount": str(amount / 100), "currency": "USD"}, + } + try: + r = await self.client.post(url=f"{self.endpoint}/v1/invoices", json=payload) + r.raise_for_status() + except Exception: + return InvoiceResponse( + paid=False, + checking_id=None, + payment_request=None, + error_message=r.json()["detail"], + ) + + quote = r.json() + invoice_id = quote.get("invoiceId") + + try: + payload = {"descriptionHash": secrets.token_hex(32)} + r2 = await self.client.post( + f"{self.endpoint}/v1/invoices/{invoice_id}/quote", json=payload + ) + except Exception: + return InvoiceResponse( + paid=False, + checking_id=None, + payment_request=None, + error_message=r.json()["detail"], + ) + + data2 = r2.json() + payment_request = data2.get("lnInvoice") + assert payment_request, "Did not receive an invoice" + checking_id = invoice_id + return InvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=payment_request, + error_message=None, + ) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + try: + r = await self.client.post( + url=f"{self.endpoint}/v1/payment-quotes/lightning", + json={"sourceCurrency": "USD", "lnInvoice": bolt11}, + timeout=None, + ) + r.raise_for_status() + except Exception: + error_message = r.json()["data"]["message"] + raise Exception(error_message) + data = r.json() + + amount_cent = int(float(data.get("amount").get("amount")) * 100) + quote = PaymentQuoteResponse( + amount=amount_cent, id=data.get("paymentQuoteId"), fee=0 + ) + return quote + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + try: + r = await self.client.patch( + url=f"{self.endpoint}/v1/payment-quotes/{bolt11}/execute", + timeout=None, + ) + r.raise_for_status() + except Exception: + error_message = r.json()["data"]["message"] + return PaymentResponse(None, None, None, None, error_message) + + data = r.json() + states = {"PENDING": None, "COMPLETED": True, "FAILED": False} + if states[data.get("state")]: + return PaymentResponse( + ok=True, checking_id="", fee_msat=0, error_message=None + ) + else: + return PaymentResponse( + ok=False, checking_id="", fee_msat=0, error_message=None + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/invoices/{checking_id}") + r.raise_for_status() + except Exception: + return PaymentStatus(paid=None) + data = r.json() + states = {"PENDING": None, "UNPAID": None, "PAID": True, "CANCELLED": False} + return PaymentStatus(paid=states[data["state"]]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.client.get(url=f"{self.endpoint}/v1/payments/{checking_id}") + r.raise_for_status() + except Exception: + return PaymentStatus(paid=None) + data = r.json() + if "paid" not in data and "details" not in data: + return PaymentStatus(paid=None) + + return PaymentStatus( + paid=data["paid"], + fee_msat=data["details"]["fee"], + preimage=data["preimage"], + ) + + # async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + # url = f"{self.endpoint}/api/v1/payments/sse" + + # while True: + # try: + # async with requests.stream("GET", url) as r: + # async for line in r.aiter_lines(): + # if line.startswith("data:"): + # try: + # data = json.loads(line[5:]) + # except json.decoder.JSONDecodeError: + # continue + + # if type(data) is not dict: + # continue + + # yield data["payment_hash"] # payment_hash + + # except: + # pass + + # print("lost connection to lnbits /payments/sse, retrying in 5 seconds") + # await asyncio.sleep(5) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 9866bf7e..2c9ae4c2 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,7 +1,7 @@ import asyncio import copy import math -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Mapping, Optional, Set, Tuple import bolt11 from loguru import logger @@ -11,12 +11,14 @@ BlindedMessage, BlindedSignature, MeltQuote, + Method, MintKeyset, MintQuote, PostMeltQuoteRequest, PostMeltQuoteResponse, PostMintQuoteRequest, Proof, + Unit, ) from ..core.crypto import b_dhke from ..core.crypto.keys import derive_keyset_id_deprecated, derive_pubkey, random_hash @@ -32,14 +34,19 @@ from ..core.helpers import sum_proofs from ..core.settings import settings from ..core.split import amount_split -from ..lightning.base import Wallet +from ..lightning.base import ( + InvoiceResponse, + LightningWallet, + PaymentQuoteResponse, + PaymentStatus, +) from ..mint.crud import LedgerCrudSqlite from .conditions import LedgerSpendingConditions -from .lightning import LedgerLightning from .verification import LedgerVerification -class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerLightning): +class Ledger(LedgerVerification, LedgerSpendingConditions): + backends: Mapping[Method, Mapping[Unit, LightningWallet]] = {} locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks proofs_pending_lock: asyncio.Lock = ( asyncio.Lock() @@ -50,7 +57,7 @@ def __init__( self, db: Database, seed: str, - lightning: Wallet, + backends: Mapping[Method, Mapping[Unit, LightningWallet]], derivation_path="", crud=LedgerCrudSqlite(), ): @@ -60,7 +67,7 @@ def __init__( self.db = db self.crud = crud - self.lightning = lightning + self.backends = backends self.pubkey = derive_pubkey(self.master_key) # ------- KEYS ------- @@ -271,8 +278,6 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: Returns: Tuple[str, str]: Bolt11 invoice and a id (for looking it up later) """ - assert quote_request.unit == "sat", "only sat supported" - logger.trace("called request_mint") if settings.mint_max_peg_in and quote_request.amount > settings.mint_max_peg_in: raise NotAllowedError( @@ -280,22 +285,54 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: ) if settings.mint_peg_out_only: raise NotAllowedError("Mint does not allow minting new tokens.") - - logger.trace(f"requesting invoice for {quote_request.amount} satoshis") - invoice_response = await self._request_lightning_invoice(quote_request.amount) + unit = Unit[quote_request.unit] + method = Method["bolt11"] + requested_amount_sat = quote_request.amount + if unit == Unit.msat: + requested_amount_sat = math.ceil(quote_request.amount / 1000) + logger.trace(f"requesting invoice for {requested_amount_sat} satoshis") + invoice_response: InvoiceResponse = await self.backends[method][ + unit + ].create_invoice(requested_amount_sat) logger.trace( f"got invoice {invoice_response.payment_request} with check id" f" {invoice_response.checking_id}" ) + # if unit == Unit.sat: + + # elif unit == Unit.msat: + # amount_sat = math.ceil(quote_request.amount / 1000) + # logger.trace(f"requesting invoice for {amount_sat} satoshis") + # invoice_response = await self._request_lightning_invoice(amount_sat) + # logger.trace( + # f"got invoice {invoice_response.payment_request} with check id" + # f" {invoice_response.checking_id}" + # ) + # elif unit == Unit.usd: + # invoice_response = InvoiceResponse( + # ok=True, + # checking_id="USD checking ID", + # payment_request="lnbc1230n1pj4k54wdq4gdshx6r4ypjx2ur0wd5hgsp5m7gsgz8v9k8dlvzkrwcwps30xmxt4pag36pn96nxf5twald9vp5qpp59laagur6w8shykfh05mdyl3e7k304gmn8zp3yjj8429ur2uq6znqwuul0t3kl7g38htuue82gwe3s9fh37j6rza8ja7w35lnn4up5t9jqls32vjqsy4tywh479wusv9xwj92hphane9p0wppjk4n4ms6nasquzj6ug", # noqa + # ) + # elif unit == Unit.cheese: + # invoice_response = InvoiceResponse( + # ok=True, + # checking_id="USD checking ID", + # payment_request="lnbc1230n1pj4k54wdq4gdshx6r4ypjx2ur0wd5hgsp5m7gsgz8v9k8dlvzkrwcwps30xmxt4pag36pn96nxf5twald9vp5qpp59laagur6w8shykfh05mdyl3e7k304gmn8zp3yjj8429ur2uq6znqwuul0t3kl7g38htuue82gwe3s9fh37j6rza8ja7w35lnn4up5t9jqls32vjqsy4tywh479wusv9xwj92hphane9p0wppjk4n4ms6nasquzj6ug", # noqa + # ) + # else: + # raise NotAllowedError(f"Mint quote: {quote_request.unit} unit not allowed.") + assert ( invoice_response.payment_request and invoice_response.checking_id - ), LightningError("could not fetch invoice from Lightning backend") + ), LightningError("could not fetch bolt11 payment request from backend") + quote = MintQuote( quote=random_hash(), method="bolt11", request=invoice_response.payment_request, checking_id=invoice_response.checking_id, - unit="sat", + unit=quote_request.unit, amount=quote_request.amount, issued=False, paid=False, @@ -344,21 +381,33 @@ async def mint( quote.amount == sum_amount_outputs ), "amount to mint does not match quote amount" - # Lightning - assert quote.unit == "sat", "only sat supported" assert quote.method == "bolt11", "only bolt11 supported" - + unit = Unit[quote.unit] + method = Method["bolt11"] if not quote.paid: logger.debug(f"Lightning: checking invoice {quote.checking_id}") - status = await self.lightning.get_invoice_status(quote.checking_id) + status: PaymentStatus = await self.backends[method][ + unit + ].get_invoice_status(quote.checking_id) assert status.paid, "invoice not paid" await self.crud.update_mint_quote_paid( quote_id=quote_id, paid=True, db=self.db ) - - # # will raise an exception if the invoice is not paid or tokens are - # # already issued or the requested amount is too high - # await self._check_lightning_invoice(amount=sum_amount_outputs, id=quote_id) + # # Lightning + # if unit == Unit.sat: + # if not quote.paid: + # logger.debug(f"Lightning: checking invoice {quote.checking_id}") + # status = await self.lightning[unit].get_invoice_status(quote.checking_id) + # assert status.paid, "invoice not paid" + # await self.crud.update_mint_quote_paid( + # quote_id=quote_id, paid=True, db=self.db + # ) + # elif unit == Unit.usd: + # logger.debug("USD mint: paid") + # elif unit == Unit.cheese: + # logger.debug("Cheese mint: paid") + # else: + # raise NotAllowedError(f"Mint: {quote.unit} unit not supported.") logger.trace(f"crud: setting invoice {id} as issued") await self.crud.update_mint_quote_issued( @@ -375,26 +424,55 @@ async def melt_quote( ) -> PostMeltQuoteResponse: invoice_obj = bolt11.decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." - assert melt_quote.unit == "sat", "only sat supported" - # Lightning - fee_reserve_sat = await self._get_lightning_fees(melt_quote.request) + unit = Unit[melt_quote.unit] + method = Method["bolt11"] + payment_quote: PaymentQuoteResponse = await self.backends[method][ + unit + ].get_payment_quote(melt_quote.request) + + if unit == Unit.msat: + payment_quote.amount = payment_quote.amount * 1000 + payment_quote.fee = payment_quote.fee * 1000 + + # amount = invoice_amount_sat + # checking_id = invoice_obj.payment_hash + + # if unit == Unit.sat: + # # Lightning + # fee_reserve_sat = await self._get_lightning_fees(melt_quote.request) + # amount = invoice_amount_sat + # checking_id = invoice_obj.payment_hash + # elif unit == Unit.usd: + # fee_reserve_sat = 0 + # amount = int(invoice_amount_sat / 3400) + # checking_id = invoice_obj.payment_hash + # elif unit == Unit.cheese: + # fee_reserve_sat = 2 + # amount = int(invoice_amount_sat / 2) + # checking_id = invoice_obj.payment_hash + # elif unit == Unit.msat: + # fee_reserve_sat = 0 + # amount = invoice_amount_sat * 1000 + # checking_id = invoice_obj.payment_hash + # else: + # raise NotAllowedError(f"Melt quote: {melt_quote.unit} unit not supported.") # NOTE: We do not store the fee reserve in the database. quote = MeltQuote( quote=random_hash(), - method="bolt11", - request=melt_quote.request, - checking_id=invoice_obj.payment_hash, + method="bolt11", # TODO: remove unnecessary fields + request=melt_quote.request, # TODO: remove unnecessary fields + checking_id=payment_quote.checking_id, unit=melt_quote.unit, - amount=int(invoice_obj.amount_msat / 1000), + amount=payment_quote.amount, paid=False, - fee_reserve=fee_reserve_sat, + fee_reserve=payment_quote.fee, ) await self.crud.store_melt_quote(quote=quote, db=self.db) return PostMeltQuoteResponse( quote=quote.quote, amount=quote.amount, - fee_reserve=fee_reserve_sat, + fee_reserve=quote.fee_reserve, ) async def melt( @@ -422,7 +500,17 @@ async def melt( melt_quote = await self.crud.get_melt_quote(quote_id=quote, db=self.db) assert melt_quote, "quote not found" assert melt_quote.method == "bolt11", "only bolt11 supported" - assert melt_quote.unit == "sat", "only sat supported" + + method = Method["bolt11"] + unit = Unit[melt_quote.unit] + # make sure that the outputs (for fee return) are in the same unit as the quote + if outputs: + outputs_unit = self.keysets[outputs[0].id].unit + assert outputs_unit + assert melt_quote.unit == outputs_unit.name, ( + f"output unit {outputs_unit.name} does not match quote unit" + f" {melt_quote.unit}" + ) assert not melt_quote.paid, "melt quote already paid" bolt11_request = melt_quote.request total_provided = sum_proofs(proofs) @@ -460,26 +548,20 @@ async def melt( assert mint_quote.method == melt_quote.method, "methods do not match" assert not mint_quote.paid, "mint quote already paid" assert not mint_quote.issued, "mint quote already issued" - # we can handle this transaction internally + logger.info( + f"Settling bolt11 payment internally: {melt_quote.quote} ->" + f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" + ) + # we handle this transaction internally await self.crud.update_mint_quote_paid( quote_id=mint_quote.quote, paid=True, db=self.db ) else: - # we need to pay the lightning invoice - logger.debug(f"Lightning: get fees for {bolt11_request}") - reserve_fees_sat = await self._get_lightning_fees(bolt11_request) - # verify overspending attempt - assert ( - total_provided >= invoice_amount + reserve_fees_sat - ), TransactionError( - "provided proofs not enough for Lightning payment. Provided:" - f" {total_provided}, needed: {invoice_amount + reserve_fees_sat}" - ) - + # TODO: Check if melt_quote.fee_reserve is always the correct unit! logger.debug(f"Lightning: pay invoice {bolt11_request}") - payment = await self._pay_lightning_invoice( - bolt11_request, reserve_fees_sat * 1000 + payment = await self.backends[method][unit].pay_invoice( + bolt11_request, melt_quote.fee_reserve * 1000 ) logger.trace("paid lightning invoice") @@ -487,15 +569,53 @@ async def melt( f"Melt status: {payment.ok}: preimage: {payment.preimage}," f" fee_msat: {payment.fee_msat}" ) - if not payment.ok: raise LightningError("Lightning payment unsuccessful.") - if payment.fee_msat: fees_paid = math.ceil(payment.fee_msat / 1000) if payment.preimage: payment_proof = payment.preimage + # if outputs_unit == Unit.sat: + # # we need to pay the lightning invoice + # logger.debug(f"Lightning: get fees for {bolt11_request}") + # reserve_fees_sat = await self._get_lightning_fees(bolt11_request) + # # verify overspending attempt + # assert ( + # total_provided >= invoice_amount + reserve_fees_sat + # ), TransactionError( + # "provided proofs not enough for Lightning payment. Provided:" + # f" {total_provided}, needed:" + # f" {invoice_amount + reserve_fees_sat}" + # ) + + # logger.debug(f"Lightning: pay invoice {bolt11_request}") + # payment = await self._pay_lightning_invoice( + # bolt11_request, reserve_fees_sat * 1000 + # ) + # logger.trace("paid lightning invoice") + + # logger.debug( + # f"Melt status: {payment.ok}: preimage: {payment.preimage}," + # f" fee_msat: {payment.fee_msat}" + # ) + + # if not payment.ok: + # raise LightningError("Lightning payment unsuccessful.") + + # if payment.fee_msat: + # fees_paid = math.ceil(payment.fee_msat / 1000) + # if payment.preimage: + # payment_proof = payment.preimage + # elif outputs_unit == Unit.usd: + # payment_proof = "paid USD" + # elif outputs_unit == Unit.cheese or True: + # payment_proof = "paid Cheese" + # else: + # raise LightningError( + # f"Melt: {outputs_unit.name} unit not supported." + # ) + # melt successful, invalidate proofs await self._invalidate_proofs(proofs) @@ -510,7 +630,7 @@ async def melt( output_amount=invoice_amount, output_fee_paid=fees_paid, outputs=outputs, - keyset=keyset, + keyset=self.keysets[outputs[0].id], ) except Exception as e: diff --git a/cashu/mint/lightning.py b/cashu/mint/lightning.py index bd04b35f..ea4fd9b8 100644 --- a/cashu/mint/lightning.py +++ b/cashu/mint/lightning.py @@ -1,165 +1,167 @@ -import math - -import bolt11 -from loguru import logger - -from ..core.db import Database -from ..core.errors import ( - LightningError, -) -from ..core.helpers import fee_reserve -from ..lightning.base import InvoiceResponse, PaymentResponse, Wallet -from ..mint.crud import LedgerCrud -from .protocols import SupportLightning, SupportsDb - - -class LedgerLightning(SupportLightning, SupportsDb): - """Lightning functions for the ledger.""" - - lightning: Wallet - crud: LedgerCrud - db: Database - - async def _request_lightning_invoice(self, amount: int) -> InvoiceResponse: - """Generate a Lightning invoice using the funding source backend. - - Args: - amount (int): Amount of invoice (in Satoshis) - - Raises: - Exception: Error with funding source. - - Returns: - Tuple[str, str]: Bolt11 invoice and payment id (for lookup) - """ - logger.trace( - "_request_lightning_invoice: Requesting Lightning invoice for" - f" {amount} satoshis." - ) - status = await self.lightning.status() - logger.trace( - "_request_lightning_invoice: Lightning wallet balance:" - f" {status.balance_msat}" - ) - if status.error_message: - raise LightningError( - f"Lightning wallet not responding: {status.error_message}" - ) - payment = await self.lightning.create_invoice(amount, "Cashu deposit") - logger.trace( - f"_request_lightning_invoice: Lightning invoice: {payment.payment_request}" - ) - - if not payment.ok: - raise LightningError(f"Lightning wallet error: {payment.error_message}") - assert payment.payment_request and payment.checking_id, LightningError( - "could not fetch invoice from Lightning backend" - ) - return payment - - # async def _check_lightning_invoice( - # self, *, amount: int, id: str, conn: Optional[Connection] = None - # ) -> PaymentStatus: - # """Checks with the Lightning backend whether an invoice with `id` was paid. - - # Args: - # amount (int): Amount of the outputs the wallet wants in return (in Satoshis). - # id (str): Id to look up Lightning invoice by. - - # Raises: - # Exception: Invoice not found. - # Exception: Tokens for invoice already issued. - # Exception: Amount larger than invoice amount. - # Exception: Invoice not paid yet - # e: Update database and pass through error. - - # Returns: - # bool: True if invoice has been paid, else False - # """ - # invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice( - # id=id, db=self.db, conn=conn - # ) - # if invoice is None: - # raise LightningError("invoice not found.") - # if invoice.issued: - # raise LightningError("tokens already issued for this invoice.") - # if amount > invoice.amount: - # raise LightningError( - # f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" - # ) - # assert invoice.payment_hash, "invoice has no payment hash." - # # set this invoice as issued - # await self.crud.update_lightning_invoice( - # id=id, issued=True, db=self.db, conn=conn - # ) - - # try: - # status = await self.lightning.get_invoice_status(invoice.payment_hash) - # if status.paid: - # return status - # else: - # raise InvoiceNotPaidError() - # except Exception as e: - # # unset issued - # await self.crud.update_lightning_invoice( - # id=id, issued=False, db=self.db, conn=conn - # ) - # raise e - - async def _get_lightning_fees(self, pr: str) -> int: - """Returns the fee reserve (in sat) that a wallet must add to its proofs - in order to pay a Lightning invoice. - - Args: - pr (str): Bolt11 encoded payment request. Lightning invoice. - - Returns: - int: Fee in Satoshis. - """ - - amount_msat = 0 - decoded_invoice = bolt11.decode(pr) - assert decoded_invoice.amount_msat, "invoice has no amount." - amount_msat = int(decoded_invoice.amount_msat) - logger.trace( - f"get_melt_fees: checking lightning invoice: {decoded_invoice.payment_hash}" - ) - - # hack: check if it's internal, if it exists, it will return paid = False, - # if id does not exist (not internal), it returns paid = None - # NOTE: This only works with LNbits so we're getting rid of it - # payment = await self.lightning.get_invoice_status(decoded_invoice.payment_hash) - # logger.trace(f"get_melt_fees: paid: {payment.paid}") - # internal = payment.paid is False - - fees_msat = fee_reserve(amount_msat) - fee_sat = math.ceil(fees_msat / 1000) - return fee_sat - - async def _pay_lightning_invoice( - self, invoice: str, fee_limit_msat: int - ) -> PaymentResponse: - """Pays a Lightning invoice via the funding source backend. - - Args: - invoice (str): Bolt11 Lightning invoice - fee_limit_msat (int): Maximum fee reserve for payment (in Millisatoshi) - - Raises: - Exception: Funding source error. - - Returns: - Tuple[bool, string, int]: Returns payment status, preimage of invoice, paid fees (in Millisatoshi) - """ - status = await self.lightning.status() - if status.error_message: - raise LightningError( - f"Lightning wallet not responding: {status.error_message}" - ) - payment = await self.lightning.pay_invoice( - invoice, fee_limit_msat=fee_limit_msat - ) - logger.trace(f"_pay_lightning_invoice: Lightning payment status: {payment.ok}") - # make sure that fee is positive and not None - payment.fee_msat = abs(payment.fee_msat) if payment.fee_msat else 0 - return payment +# import math +# from typing import Dict + +# import bolt11 +# from loguru import logger + +# from ..core.base import Unit +# from ..core.db import Database +# from ..core.errors import ( +# LightningError, +# ) +# from ..core.helpers import fee_reserve +# from ..lightning.base import InvoiceResponse, LightningWallet, PaymentResponse +# from ..mint.crud import LedgerCrud +# from .protocols import SupportLightning, SupportsDb + + +# class LedgerLightning(SupportLightning, SupportsDb): +# """Lightning functions for the ledger.""" + +# lightning: Dict[Unit, LightningWallet] +# crud: LedgerCrud +# db: Database + +# async def _request_lightning_invoice(self, amount: int) -> InvoiceResponse: +# """Generate a Lightning invoice using the funding source backend. + +# Args: +# amount (int): Amount of invoice (in Satoshis) + +# Raises: +# Exception: Error with funding source. + +# Returns: +# Tuple[str, str]: Bolt11 invoice and payment id (for lookup) +# """ +# logger.trace( +# "_request_lightning_invoice: Requesting Lightning invoice for" +# f" {amount} satoshis." +# ) +# status = await self.lightning.status() +# logger.trace( +# "_request_lightning_invoice: Lightning wallet balance:" +# f" {status.balance_msat}" +# ) +# if status.error_message: +# raise LightningError( +# f"Lightning wallet not responding: {status.error_message}" +# ) +# payment = await self.lightning.create_invoice(amount, "Cashu deposit") +# logger.trace( +# f"_request_lightning_invoice: Lightning invoice: {payment.payment_request}" +# ) + +# if not payment.ok: +# raise LightningError(f"Lightning wallet error: {payment.error_message}") +# assert payment.payment_request and payment.checking_id, LightningError( +# "could not fetch invoice from Lightning backend" +# ) +# return payment + +# # async def _check_lightning_invoice( +# # self, *, amount: int, id: str, conn: Optional[Connection] = None +# # ) -> PaymentStatus: +# # """Checks with the Lightning backend whether an invoice with `id` was paid. + +# # Args: +# # amount (int): Amount of the outputs the wallet wants in return (in Satoshis). +# # id (str): Id to look up Lightning invoice by. + +# # Raises: +# # Exception: Invoice not found. +# # Exception: Tokens for invoice already issued. +# # Exception: Amount larger than invoice amount. +# # Exception: Invoice not paid yet +# # e: Update database and pass through error. + +# # Returns: +# # bool: True if invoice has been paid, else False +# # """ +# # invoice: Union[Invoice, None] = await self.crud.get_lightning_invoice( +# # id=id, db=self.db, conn=conn +# # ) +# # if invoice is None: +# # raise LightningError("invoice not found.") +# # if invoice.issued: +# # raise LightningError("tokens already issued for this invoice.") +# # if amount > invoice.amount: +# # raise LightningError( +# # f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" +# # ) +# # assert invoice.payment_hash, "invoice has no payment hash." +# # # set this invoice as issued +# # await self.crud.update_lightning_invoice( +# # id=id, issued=True, db=self.db, conn=conn +# # ) + +# # try: +# # status = await self.lightning.get_invoice_status(invoice.payment_hash) +# # if status.paid: +# # return status +# # else: +# # raise InvoiceNotPaidError() +# # except Exception as e: +# # # unset issued +# # await self.crud.update_lightning_invoice( +# # id=id, issued=False, db=self.db, conn=conn +# # ) +# # raise e + +# async def _get_lightning_fees(self, pr: str) -> int: +# """Returns the fee reserve (in sat) that a wallet must add to its proofs +# in order to pay a Lightning invoice. + +# Args: +# pr (str): Bolt11 encoded payment request. Lightning invoice. + +# Returns: +# int: Fee in Satoshis. +# """ + +# amount_msat = 0 +# decoded_invoice = bolt11.decode(pr) +# assert decoded_invoice.amount_msat, "invoice has no amount." +# amount_msat = int(decoded_invoice.amount_msat) +# logger.trace( +# f"get_melt_fees: checking lightning invoice: {decoded_invoice.payment_hash}" +# ) + +# # hack: check if it's internal, if it exists, it will return paid = False, +# # if id does not exist (not internal), it returns paid = None +# # NOTE: This only works with LNbits so we're getting rid of it +# # payment = await self.lightning.get_invoice_status(decoded_invoice.payment_hash) +# # logger.trace(f"get_melt_fees: paid: {payment.paid}") +# # internal = payment.paid is False + +# fees_msat = fee_reserve(amount_msat) +# fee_sat = math.ceil(fees_msat / 1000) +# return fee_sat + +# async def _pay_lightning_invoice( +# self, invoice: str, fee_limit_msat: int +# ) -> PaymentResponse: +# """Pays a Lightning invoice via the funding source backend. + +# Args: +# invoice (str): Bolt11 Lightning invoice +# fee_limit_msat (int): Maximum fee reserve for payment (in Millisatoshi) + +# Raises: +# Exception: Funding source error. + +# Returns: +# Tuple[bool, string, int]: Returns payment status, preimage of invoice, paid fees (in Millisatoshi) +# """ +# status = await self.lightning.status() +# if status.error_message: +# raise LightningError( +# f"Lightning wallet not responding: {status.error_message}" +# ) +# payment = await self.lightning.pay_invoice( +# invoice, fee_limit_msat=fee_limit_msat +# ) +# logger.trace(f"_pay_lightning_invoice: Lightning payment status: {payment.ok}") +# # make sure that fee is positive and not None +# payment.fee_msat = abs(payment.fee_msat) if payment.fee_msat else 0 +# return payment diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py index f1fc1480..11a16ac9 100644 --- a/cashu/mint/protocols.py +++ b/cashu/mint/protocols.py @@ -1,8 +1,8 @@ from typing import Dict, Protocol -from ..core.base import MintKeyset +from ..core.base import MintKeyset, Unit from ..core.db import Database -from ..lightning.base import Wallet +from ..lightning.base import LightningWallet from ..mint.crud import LedgerCrud @@ -12,7 +12,7 @@ class SupportsKeysets(Protocol): class SupportLightning(Protocol): - lightning: Wallet + lightning: Dict[Unit, LightningWallet] class SupportsDb(Protocol): diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 91524b83..35d7c903 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -6,6 +6,7 @@ from loguru import logger +from ..core.base import Method, Unit from ..core.db import Database from ..core.migrations import migrate_databases from ..core.settings import settings @@ -19,15 +20,19 @@ wallets_module = importlib.import_module("cashu.lightning") lightning_backend = getattr(wallets_module, settings.mint_lightning_backend)() +strike_backend = getattr(wallets_module, "StrikeUSDWallet")() assert settings.mint_private_key is not None, "No mint private key set." +backends = { + Method.bolt11: {Unit.sat: lightning_backend, Unit.usd: strike_backend}, +} ledger = Ledger( db=Database("mint", settings.mint_database), seed=settings.mint_private_key, derivation_path=settings.mint_derivation_path, - lightning=lightning_backend, + backends=backends, crud=LedgerCrudSqlite(), ) @@ -56,15 +61,21 @@ async def start_mint_init(): for derivation_path in settings.mint_derivation_path_list: await ledger.activate_keyset(derivation_path) - logger.info(f"Using backend: {settings.mint_lightning_backend}") - status = await ledger.lightning.status() - if status.error_message: - logger.warning( - f"The backend for {ledger.lightning.__class__.__name__} isn't" - f" working properly: '{status.error_message}'", - RuntimeWarning, - ) - logger.info(f"Lightning balance: {status.balance_msat} msat") + for method in ledger.backends: + for unit in ledger.backends[method]: + logger.info( + f"Using {ledger.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit '{unit.name}'" + ) + status = await ledger.backends[method][unit].status() + if status.error_message: + logger.warning( + "The backend for" + f" {ledger.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'", + RuntimeWarning, + ) + logger.info(f"Backend balance: {status.balance} {unit.name}") logger.info(f"Data dir: {settings.cashu_dir}") logger.info("Mint started.") diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 7496e0cb..763e11a7 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -424,7 +424,7 @@ async def mint_quote(self, amount) -> Invoice: Exception: If the mint request fails """ logger.trace("Requesting mint: GET /v1/mint/bolt11") - payload = PostMintQuoteRequest(unit="sat", amount=amount) + payload = PostMintQuoteRequest(unit=self.unit.name, amount=amount) resp = await self.httpx.post( join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict() ) @@ -1008,7 +1008,7 @@ def verify_proofs_dleq(self, proofs: List[Proof]): ): raise Exception("DLEQ proof invalid.") else: - logger.debug("DLEQ proof valid.") + logger.trace("DLEQ proof valid.") async def _construct_proofs( self, diff --git a/tests/conftest.py b/tests/conftest.py index 2a81bf17..38f5b499 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,14 @@ import shutil import time from pathlib import Path +from typing import Mapping import pytest import pytest_asyncio import uvicorn from uvicorn import Config, Server +from cashu.core.base import Method, Unit from cashu.core.db import Database from cashu.core.migrations import migrate_databases from cashu.core.settings import settings @@ -67,11 +69,14 @@ async def start_mint_init(ledger: Ledger): if os.path.exists(db_file): os.remove(db_file) + backends: Mapping = { + Method.bolt11: {Unit.sat: FakeWallet()}, + } ledger = Ledger( db=Database(database_name, settings.mint_database), seed=settings.mint_private_key, derivation_path=settings.mint_derivation_path, - lightning=FakeWallet(), + backends=backends, crud=LedgerCrudSqlite(), ) await start_mint_init(ledger) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c2f5394..ae9eb2c7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,7 +58,7 @@ def test_info_with_mint(cli_prefix): [*cli_prefix, "info", "--mint"], ) assert result.exception is None - print("INFO -M") + print("INFO --MINT") print(result.output) assert "Mint name" in result.output assert result.exit_code == 0 @@ -71,7 +71,7 @@ def test_info_with_mnemonic(cli_prefix): [*cli_prefix, "info", "--mnemonic"], ) assert result.exception is None - print("INFO -M") + print("INFO --MNEMONIC") print(result.output) assert "Mnemonic" in result.output assert result.exit_code == 0 diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index 7d6c8afe..78dfd062 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -31,8 +31,8 @@ async def test_melt(wallet1: Wallet, ledger: Ledger): await wallet1.mint(64, id=invoice.id) assert wallet1.balance == 128 mint_quote = await wallet1.get_pay_amount_with_fees(invoice.bolt11) - mint_fees = await ledger._get_lightning_fees(invoice.bolt11) - assert mint_fees == mint_quote.fee_reserve + # mint_fees = await ledger._get_lightning_fees(invoice.bolt11) + # assert mint_fees == mint_quote.fee_reserve total_amount = mint_quote.amount + mint_quote.fee_reserve keep_proofs, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) melt_quote = await ledger.melt_quote(