From 25d5d13ffa63d1e2c8fc67ec10e0874bc9396176 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 8 Mar 2024 12:54:12 +0100 Subject: [PATCH 01/17] amount in melt request --- cashu/core/base.py | 1 + cashu/lightning/base.py | 1 + cashu/lightning/lndrest.py | 90 +++++++++++++++++++++++++++++++++++++- cashu/mint/ledger.py | 8 +++- cashu/wallet/cli/cli.py | 6 ++- cashu/wallet/wallet.py | 16 ++++--- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index d9f1dc0a..065ff6db 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -408,6 +408,7 @@ class PostMeltQuoteRequest(BaseModel): request: str = Field( ..., max_length=settings.mint_max_request_length ) # output payment request + amount: Optional[int] = Field(default=None, gt=0) # input amount class PostMeltQuoteResponse(BaseModel): diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 089a0290..ed03f638 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -103,6 +103,7 @@ def get_payment_status( async def get_payment_quote( self, bolt11: str, + amount: Optional[Amount] = None, ) -> PaymentQuoteResponse: pass diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 3aa61a0a..278b23ad 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -4,8 +4,10 @@ import json from typing import AsyncGenerator, Dict, Optional +import bolt11 import httpx from bolt11 import ( + TagChar, decode, ) from loguru import logger @@ -145,6 +147,16 @@ async def create_invoice( async def pay_invoice( self, quote: MeltQuote, fee_limit_msat: int ) -> PaymentResponse: + # if the amount of the melt quote is different from the request + # call pay_partial_invoice instead + invoice = bolt11.decode(quote.request) + if invoice.amount_msat: + amount_msat = int(invoice.amount_msat) + if amount_msat != quote.amount * 1000: + return await self.pay_partial_invoice( + quote, Amount(Unit.sat, quote.amount), fee_limit_msat + ) + # set the fee limit for the payment lnrpcFeeLimit = dict() lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" @@ -177,6 +189,71 @@ async def pay_invoice( 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 + lnrpcFeeLimit = dict() + lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" + invoice = bolt11.decode(quote.request) + + invoice_amount = invoice.amount_msat + assert invoice_amount, "invoice has no amount." + total_amount_msat = int(invoice_amount) + + payee = invoice.tags.get(TagChar.payee) + assert payee + pubkey = str(payee.data) + + payer_addr_tag = invoice.tags.get(bolt11.TagChar("s")) + assert payer_addr_tag + payer_addr = str(payer_addr_tag.data) + + # get the route + r = await self.client.post( + url=f"/v1/graph/routes/{pubkey}/{amount.to(Unit.sat).amount}", + timeout=None, + ) + + data = r.json() + + # We need to set the mpp_record for a partial payment + mpp_record = { + "mpp_record": { + "payment_addr": base64.b64encode(bytes.fromhex(payer_addr)).decode(), + "total_amt_msat": total_amount_msat, + } + } + + # add the mpp_record to the last hop + rout_nr = 0 + data["routes"][rout_nr]["hops"][-1].update(mpp_record) + + # send to route + r = await self.client.post( + url="/v2/router/route/send", + json={ + "payment_hash": base64.b64encode( + bytes.fromhex(invoice.payment_hash) + ).decode(), + "route": data["routes"][rout_nr], + }, + timeout=None, + ) + + data = r.json() + ok = data.get("status") == "SUCCEEDED" + checking_id = invoice.payment_hash + fee_msat = int(data["route"]["total_fees_msat"]) + preimage = base64.b64decode(data["preimage"]).hex() + return PaymentResponse( + ok=ok, + checking_id=checking_id, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, + preimage=preimage, + error_message=None, + ) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get(url=f"/v1/invoice/{checking_id}") @@ -261,13 +338,22 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: ) await asyncio.sleep(5) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, bolt11: str, amount: Optional[Amount] = None + ) -> PaymentQuoteResponse: invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." - amount_msat = int(invoice_obj.amount_msat) + + 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, amount=amount ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index ce1ce7b1..2875d9c3 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -472,6 +472,11 @@ async def melt_quote( if mint_quote: # internal transaction, validate and return amount from # associated mint quote and demand zero fees + if ( + melt_quote.amount + and Amount(unit, mint_quote.amount).to(unit).amount != melt_quote.amount + ): + raise TransactionError("internal amounts do not match") assert ( Amount(unit, mint_quote.amount).to(Unit.msat).amount == invoice_obj.amount_msat @@ -495,8 +500,9 @@ async def melt_quote( ) else: # not internal, get quote by backend + amount = Amount(unit, melt_quote.amount) if melt_quote.amount else None payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote.request + melt_quote.request, amount=amount ) assert payment_quote.checking_id, "quote has no checking id" diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 10251477..47949fff 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -9,6 +9,7 @@ from operator import itemgetter from os import listdir from os.path import isdir, join +from typing import Optional import click from click import Context @@ -176,16 +177,17 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): @cli.command("pay", help="Pay Lightning invoice.") @click.argument("invoice", type=str) +@click.argument("amount", type=int, required=False) @click.option( "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool ) @click.pass_context @coro -async def pay(ctx: Context, invoice: str, yes: bool): +async def pay(ctx: Context, invoice: str, amount: Optional[int], yes: bool): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() print_balance(ctx) - quote = await wallet.get_pay_amount_with_fees(invoice) + quote = await wallet.get_pay_amount_with_fees(invoice, amount) logger.debug(f"Quote: {quote}") total_amount = quote.amount + quote.fee_reserve if not yes: diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 9dd07b82..06d2aec5 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -527,11 +527,15 @@ def _mintrequest_include_fields(outputs: List[BlindedMessage]): @async_set_httpx_client @async_ensure_mint_loaded - async def melt_quote(self, payment_request: str) -> PostMeltQuoteResponse: + async def melt_quote( + self, payment_request: str, amount: Optional[int] = None + ) -> PostMeltQuoteResponse: """Checks whether the Lightning payment is internal.""" invoice_obj = bolt11.decode(payment_request) assert invoice_obj.amount_msat, "invoice must have amount" - payload = PostMeltQuoteRequest(unit=self.unit.name, request=payment_request) + payload = PostMeltQuoteRequest( + unit=self.unit.name, request=payment_request, amount=amount + ) resp = await self.httpx.post( join(self.url, "/v1/melt/quote/bolt11"), json=payload.dict(), @@ -545,7 +549,7 @@ async def melt_quote(self, payment_request: str) -> PostMeltQuoteResponse: quote_id = "deprecated_" + str(uuid.uuid4()) return PostMeltQuoteResponse( quote=quote_id, - amount=invoice_obj.amount_msat // 1000, + amount=amount or invoice_obj.amount_msat // 1000, fee_reserve=ret.fee or 0, paid=False, expiry=invoice_obj.expiry, @@ -1513,12 +1517,14 @@ async def invalidate( # ---------- TRANSACTION HELPERS ---------- - async def get_pay_amount_with_fees(self, invoice: str): + async def get_pay_amount_with_fees( + self, invoice: str, amount: Optional[int] = None + ) -> PostMeltQuoteResponse: """ Decodes the amount from a Lightning invoice and returns the total amount (amount+fees) to be paid. """ - melt_quote = await self.melt_quote(invoice) + melt_quote = await self.melt_quote(invoice, amount) logger.debug( f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve." ) From 829d10b5e9d7ee0eb25e4996db0ff296f8dde27d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 9 Mar 2024 16:05:21 +0100 Subject: [PATCH 02/17] apply fee limit --- cashu/lightning/lndrest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 278b23ad..109d04f0 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -212,6 +212,7 @@ async def pay_partial_invoice( # get the route r = await self.client.post( url=f"/v1/graph/routes/{pubkey}/{amount.to(Unit.sat).amount}", + json={"fee_limit": lnrpcFeeLimit}, timeout=None, ) From 1b525ba40f4c80d2c585da6c3c6f8be300d0434a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 9 Mar 2024 16:14:45 +0100 Subject: [PATCH 03/17] more error handling --- cashu/lightning/lndrest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 109d04f0..eb62da4e 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -217,6 +217,15 @@ async def pay_partial_invoice( ) data = r.json() + if r.is_error or data.get("message"): + error_message = data.get("message") or r.text + return PaymentResponse( + ok=False, + checking_id=None, + fee=None, + preimage=None, + error_message=error_message, + ) # We need to set the mpp_record for a partial payment mpp_record = { @@ -243,6 +252,16 @@ async def pay_partial_invoice( ) data = r.json() + if r.is_error or data.get("message"): + error_message = data.get("message") or r.text + return PaymentResponse( + ok=False, + checking_id=None, + fee=None, + preimage=None, + error_message=error_message, + ) + ok = data.get("status") == "SUCCEEDED" checking_id = invoice.payment_hash fee_msat = int(data["route"]["total_fees_msat"]) From 7cc948df1db91102fb957941212377d222a2b9ff Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:19:10 +0100 Subject: [PATCH 04/17] wip: signal flag in /info --- cashu/lightning/base.py | 1 + cashu/lightning/lndrest.py | 1 + cashu/mint/router.py | 10 +++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index ed03f638..b7ea6f86 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -63,6 +63,7 @@ def __str__(self) -> str: class LightningBackend(ABC): units: set[Unit] + supports_mpp: bool = False def assert_unit_supported(self, unit: Unit): if unit not in self.units: diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index eb62da4e..fbf7a30e 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -30,6 +30,7 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" units = set([Unit.sat, Unit.msat]) + supports_mpp = True def __init__(self): endpoint = settings.mint_lnd_rest_endpoint diff --git a/cashu/mint/router.py b/cashu/mint/router.py index d0cac90a..73d0aab5 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -41,12 +41,16 @@ async def info() -> GetInfoResponse: logger.trace("> GET /v1/info") # determine all method-unit pairs - method_unit_pairs: List[List[str]] = [] + method_unit_pairs: List[List[Any]] = [] for method, unit_dict in ledger.backends.items(): for unit in unit_dict.keys(): - method_unit_pairs.append([method.name, unit.name]) - supported_dict = dict(supported=True) + # settings for each method-unit pair + method_unit_settings = dict( + mpp=ledger.backends[method][unit].supports_mpp, + ) + method_unit_pairs.append([method.name, unit.name, method_unit_settings]) + supported_dict = dict(supported=True) mint_features: Dict[int, Dict[str, Any]] = { 4: dict( methods=method_unit_pairs, From 1a75192a99c359f984c3181d2636c70a7b317156 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Mar 2024 12:51:32 +0100 Subject: [PATCH 05/17] clean up multinut --- cashu/core/base.py | 13 +- cashu/core/settings.py | 1 + cashu/lightning/base.py | 10 +- cashu/lightning/blink.py | 7 +- cashu/lightning/corelightningrest.py | 7 +- cashu/lightning/fake.py | 7 +- cashu/lightning/lnbits.py | 7 +- cashu/lightning/lndrest.py | 15 ++- cashu/lightning/strike.py | 7 +- cashu/mint/ledger.py | 3 +- cashu/wallet/api/router.py | 4 +- cashu/wallet/cli/cli.py | 18 ++- cashu/wallet/lightning/lightning.py | 6 +- cashu/wallet/wallet.py | 181 +++++++++++++-------------- cashu/wallet/wallet_deprecated.py | 2 +- tests/test_mint_lightning_blink.py | 27 +++- tests/test_mint_operations.py | 2 +- tests/test_wallet.py | 4 +- 18 files changed, 184 insertions(+), 137 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 88099a62..7cf0598f 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -101,12 +101,12 @@ class Proof(BaseModel): time_created: Union[None, str] = "" time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - mint_id: Union[ - None, str - ] = None # holds the id of the mint operation that created this proof - melt_id: Union[ - None, str - ] = None # holds the id of the melt operation that destroyed this proof + mint_id: Union[None, str] = ( + None # holds the id of the mint operation that created this proof + ) + melt_id: Union[None, str] = ( + None # holds the id of the melt operation that destroyed this proof + ) def __init__(self, **data): super().__init__(**data) @@ -307,6 +307,7 @@ class MintMeltMethodSetting(BaseModel): unit: str min_amount: Optional[int] = None max_amount: Optional[int] = None + mpp: Optional[bool] = None class GetInfoResponse(BaseModel): diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 1c0ee68b..83ef9f07 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -179,6 +179,7 @@ class LndRestFundingSource(MintSettings): mint_lnd_rest_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_admin_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_invoice_macaroon: Optional[str] = Field(default=None) + mint_lnd_enable_mpp_experimental: bool = Field(default=False) class CoreLightningRestFundingSource(MintSettings): diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 6d7ddd99..8d35128e 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -3,7 +3,12 @@ from pydantic import BaseModel -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import ( + Amount, + MeltQuote, + PostMeltQuoteRequest, + Unit, +) class StatusResponse(BaseModel): @@ -108,8 +113,7 @@ def get_payment_status( @abstractmethod async def get_payment_quote( self, - bolt11: str, - amount: Optional[Amount] = None, + melt_quote: PostMeltQuoteRequest, ) -> PaymentQuoteResponse: pass diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 5c9f0dc4..e7ed2c60 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -11,7 +11,7 @@ ) from loguru import logger -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.settings import settings from .base import ( InvoiceResponse, @@ -375,7 +375,10 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: preimage=preimage, ) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + bolt11 = melt_quote.request variables = { "input": { "paymentRequest": bolt11, diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index da36cdc1..d1ffb23b 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -10,7 +10,7 @@ ) from loguru import logger -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -312,7 +312,10 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: ) await asyncio.sleep(0.02) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + bolt11 = melt_quote.request invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index f4c0f018..6ae85784 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -15,7 +15,7 @@ encode, ) -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -152,7 +152,10 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # amount = invoice_obj.amount_msat # return InvoiceQuoteResponse(checking_id="", amount=amount) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + bolt11 = melt_quote.request invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 96dff6bb..b24cf74b 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -6,7 +6,7 @@ decode, ) -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -157,7 +157,10 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: preimage=data["preimage"], ) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + bolt11 = melt_quote.request invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 970dc0bd..eab97b15 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -12,7 +12,7 @@ ) from loguru import logger -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.helpers import fee_reserve from ..core.settings import settings from .base import ( @@ -29,7 +29,7 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" - supports_mpp = True + supports_mpp = settings.mint_lnd_enable_mpp_experimental supported_units = set([Unit.sat, Unit.msat]) unit = Unit.sat @@ -73,6 +73,8 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.client = httpx.AsyncClient( base_url=self.endpoint, headers=self.auth, verify=self.cert ) + if self.supports_mpp: + logger.info("LNDRestWallet enabling MPP experimental feature") async def status(self) -> StatusResponse: try: @@ -363,8 +365,15 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: await asyncio.sleep(5) async def get_payment_quote( - self, bolt11: str, amount: Optional[Amount] = None + self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: + # get amount from melt_quote or from bolt11 + amount = ( + Amount(Unit(melt_quote.unit), melt_quote.amount) + if melt_quote.amount + else None + ) + invoice_obj = decode(bolt11) assert invoice_obj.amount_msat, "invoice has no amount." diff --git a/cashu/lightning/strike.py b/cashu/lightning/strike.py index 1824c790..7149a582 100644 --- a/cashu/lightning/strike.py +++ b/cashu/lightning/strike.py @@ -4,7 +4,7 @@ import httpx -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from ..core.settings import settings from .base import ( InvoiceResponse, @@ -118,7 +118,10 @@ async def create_invoice( error_message=None, ) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + bolt11 = melt_quote.request try: r = await self.client.post( url=f"{self.endpoint}/v1/payment-quotes/lightning", diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d66ed4f4..2cd21e8d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -505,9 +505,8 @@ async def melt_quote( ) else: # not internal, get quote by backend - amount = Amount(unit, melt_quote.amount) if melt_quote.amount else None payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote.request, amount=amount + melt_quote=melt_quote ) assert payment_quote.checking_id, "quote has no checking id" # make sure the backend returned the amount with a correct unit diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index bf8a0576..611ceaa9 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -189,7 +189,7 @@ async def swap( # pay invoice from outgoing mint await outgoing_wallet.load_proofs(reload=True) - quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) + quote = await outgoing_wallet.request_melt(invoice.bolt11) total_amount = quote.amount + quote.fee_reserve if outgoing_wallet.available_balance < total_amount: raise Exception("balance too low") @@ -197,7 +197,7 @@ async def swap( _, send_proofs = await outgoing_wallet.split_to_send( outgoing_wallet.proofs, total_amount, set_reserved=True ) - await outgoing_wallet.pay_lightning( + await outgoing_wallet.melt( send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote ) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 671ec7cb..aa638485 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -177,17 +177,23 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool): @cli.command("pay", help="Pay Lightning invoice.") @click.argument("invoice", type=str) -@click.argument("amount", type=int, required=False) +@click.argument( + "amount", + type=int, + required=False, +) @click.option( "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool ) @click.pass_context @coro -async def pay(ctx: Context, invoice: str, amount: Optional[int], yes: bool): +async def pay( + ctx: Context, invoice: str, amount: Optional[int] = None, yes: bool = False +): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() await print_balance(ctx) - quote = await wallet.get_pay_amount_with_fees(invoice, amount) + quote = await wallet.request_melt(invoice, amount) logger.debug(f"Quote: {quote}") total_amount = quote.amount + quote.fee_reserve if not yes: @@ -210,7 +216,7 @@ async def pay(ctx: Context, invoice: str, amount: Optional[int], yes: bool): return _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) try: - melt_response = await wallet.pay_lightning( + melt_response = await wallet.melt( send_proofs, invoice, quote.fee_reserve, quote.quote ) except Exception as e: @@ -335,14 +341,14 @@ async def swap(ctx: Context): invoice = await incoming_wallet.request_mint(amount) # pay invoice from outgoing mint - quote = await outgoing_wallet.get_pay_amount_with_fees(invoice.bolt11) + quote = await outgoing_wallet.request_melt(invoice.bolt11) total_amount = quote.amount + quote.fee_reserve if outgoing_wallet.available_balance < total_amount: raise Exception("balance too low") _, send_proofs = await outgoing_wallet.split_to_send( outgoing_wallet.proofs, total_amount, set_reserved=True ) - await outgoing_wallet.pay_lightning( + await outgoing_wallet.melt( send_proofs, invoice.bolt11, quote.fee_reserve, quote.quote ) diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index c4b17d0b..6b23be5e 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -55,7 +55,7 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: Returns: bool: True if successful """ - quote = await self.get_pay_amount_with_fees(pr) + quote = await self.request_melt(pr) total_amount = quote.amount + quote.fee_reserve assert total_amount > 0, "amount is not positive" if self.available_balance < total_amount: @@ -63,9 +63,7 @@ async def pay_invoice(self, pr: str) -> PaymentResponse: return PaymentResponse(ok=False) _, send_proofs = await self.split_to_send(self.proofs, total_amount) try: - resp = await self.pay_lightning( - send_proofs, pr, quote.fee_reserve, quote.quote - ) + resp = await self.melt(send_proofs, pr, quote.fee_reserve, quote.quote) if resp.change: fees_paid_sat = quote.fee_reserve - sum_promises(resp.change) else: diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 0e924a16..c91c0f6e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -595,7 +595,7 @@ def _meltrequest_include_fields( if resp.status_code == 404: invoice = await get_lightning_invoice(id=quote, db=self.db) assert invoice, f"no invoice found for id {quote}" - ret: PostMeltResponse_deprecated = await self.pay_lightning_deprecated( + ret: PostMeltResponse_deprecated = await self.melt_deprecated( proofs=proofs, outputs=outputs, invoice=invoice.bolt11 ) return PostMeltResponse( @@ -820,82 +820,6 @@ async def load_keysets(self) -> None: for keyset in keysets: self.keysets[keyset.id] = keyset - async def request_mint(self, amount: int) -> Invoice: - """Request a Lightning invoice for minting tokens. - - Args: - amount (int): Amount for Lightning invoice in satoshis - - Returns: - Invoice: Lightning invoice - """ - invoice = await super().mint_quote(amount) - await store_lightning_invoice(db=self.db, invoice=invoice) - return invoice - - async def mint( - self, - amount: int, - id: str, - split: Optional[List[int]] = None, - ) -> List[Proof]: - """Mint tokens of a specific amount after an invoice has been paid. - - Args: - amount (int): Total amount of tokens to be minted - id (str): Id for looking up the paid Lightning invoice. - split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. - - Raises: - Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. - Exception: Raises exception if no proofs have been provided - - Returns: - List[Proof]: Newly minted proofs. - """ - # specific split - if split: - logger.trace(f"Mint with split: {split}") - assert sum(split) == amount, "split must sum to amount" - allowed_amounts = [2**i for i in range(settings.max_order)] - for a in split: - if a not in allowed_amounts: - raise Exception( - f"Can only mint amounts with 2^n up to {2**settings.max_order}." - ) - - # if no split was specified, we use the canonical split - amounts = split or amount_split(amount) - - # quirk: we skip bumping the secret counter in the database since we are - # not sure if the minting will succeed. If it succeeds, we will bump it - # in the next step. - secrets, rs, derivation_paths = await self.generate_n_secrets( - len(amounts), skip_bump=True - ) - await self._check_used_secrets(secrets) - outputs, rs = self._construct_outputs(amounts, secrets, rs) - - # will raise exception if mint is unsuccessful - promises = await super().mint(outputs, id) - - promises_keyset_id = promises[0].id - await bump_secret_derivation( - db=self.db, keyset_id=promises_keyset_id, by=len(amounts) - ) - proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) - - if id: - await update_lightning_invoice( - db=self.db, id=id, paid=True, time_paid=int(time.time()) - ) - # store the mint_id in proofs - async with self.db.connect() as conn: - for p in proofs: - p.mint_id = id - await update_proof(p, mint_id=id, conn=conn) - return proofs - async def redeem( self, proofs: List[Proof], @@ -991,7 +915,95 @@ async def split( send_proofs = new_proofs[len(frst_outputs) :] return keep_proofs, send_proofs - async def pay_lightning( + async def request_mint(self, amount: int) -> Invoice: + """Request a Lightning invoice for minting tokens. + + Args: + amount (int): Amount for Lightning invoice in satoshis + + Returns: + Invoice: Lightning invoice + """ + invoice = await super().mint_quote(amount) + await store_lightning_invoice(db=self.db, invoice=invoice) + return invoice + + async def mint( + self, + amount: int, + id: str, + split: Optional[List[int]] = None, + ) -> List[Proof]: + """Mint tokens of a specific amount after an invoice has been paid. + + Args: + amount (int): Total amount of tokens to be minted + id (str): Id for looking up the paid Lightning invoice. + split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. + + Raises: + Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. + Exception: Raises exception if no proofs have been provided + + Returns: + List[Proof]: Newly minted proofs. + """ + # specific split + if split: + logger.trace(f"Mint with split: {split}") + assert sum(split) == amount, "split must sum to amount" + allowed_amounts = [2**i for i in range(settings.max_order)] + for a in split: + if a not in allowed_amounts: + raise Exception( + f"Can only mint amounts with 2^n up to {2**settings.max_order}." + ) + + # if no split was specified, we use the canonical split + amounts = split or amount_split(amount) + + # quirk: we skip bumping the secret counter in the database since we are + # not sure if the minting will succeed. If it succeeds, we will bump it + # in the next step. + secrets, rs, derivation_paths = await self.generate_n_secrets( + len(amounts), skip_bump=True + ) + await self._check_used_secrets(secrets) + outputs, rs = self._construct_outputs(amounts, secrets, rs) + + # will raise exception if mint is unsuccessful + promises = await super().mint(outputs, id) + + promises_keyset_id = promises[0].id + await bump_secret_derivation( + db=self.db, keyset_id=promises_keyset_id, by=len(amounts) + ) + proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) + + if id: + await update_lightning_invoice( + db=self.db, id=id, paid=True, time_paid=int(time.time()) + ) + # store the mint_id in proofs + async with self.db.connect() as conn: + for p in proofs: + p.mint_id = id + await update_proof(p, mint_id=id, conn=conn) + return proofs + + async def request_melt( + self, invoice: str, amount: Optional[int] = None + ) -> PostMeltQuoteResponse: + """ + Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. + """ + melt_quote = await self.melt_quote(invoice, amount) + logger.debug( + f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve." + ) + return melt_quote + + async def melt( self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str ) -> PostMeltResponse: """Pays a lightning invoice and returns the status of the payment. @@ -1524,19 +1536,6 @@ async def invalidate( # ---------- TRANSACTION HELPERS ---------- - async def get_pay_amount_with_fees( - self, invoice: str, amount: Optional[int] = None - ) -> PostMeltQuoteResponse: - """ - Decodes the amount from a Lightning invoice and returns the - total amount (amount+fees) to be paid. - """ - melt_quote = await self.melt_quote(invoice, amount) - logger.debug( - f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve." - ) - return melt_quote - async def split_to_send( self, proofs: List[Proof], diff --git a/cashu/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py index db5e927a..ea84aa2a 100644 --- a/cashu/wallet/wallet_deprecated.py +++ b/cashu/wallet/wallet_deprecated.py @@ -300,7 +300,7 @@ def _mintrequest_include_fields(outputs: List[BlindedMessage]): @async_set_httpx_client @async_ensure_mint_loaded_deprecated - async def pay_lightning_deprecated( + async def melt_deprecated( self, proofs: List[Proof], invoice: str, outputs: Optional[List[BlindedMessage]] ): """ diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py index 85312e15..f4d882f3 100644 --- a/tests/test_mint_lightning_blink.py +++ b/tests/test_mint_lightning_blink.py @@ -2,7 +2,7 @@ import respx from httpx import Response -from cashu.core.base import Amount, MeltQuote, Unit +from cashu.core.base import Amount, MeltQuote, PostMeltQuoteRequest, Unit from cashu.core.settings import settings from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet @@ -192,7 +192,10 @@ async def test_blink_get_payment_quote(): # response says 1 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 5 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - quote = await blink.get_payment_quote(payment_request) + melt_quote_request = PostMeltQuoteRequest( + unit=Unit.sat.name, request=payment_request + ) + quote = await blink.get_payment_quote(melt_quote_request) assert quote.checking_id == payment_request assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.fee == Amount(Unit.sat, 5) # sat @@ -200,7 +203,10 @@ async def test_blink_get_payment_quote(): # response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - quote = await blink.get_payment_quote(payment_request) + melt_quote_request = PostMeltQuoteRequest( + unit=Unit.sat.name, request=payment_request + ) + quote = await blink.get_payment_quote(melt_quote_request) assert quote.checking_id == payment_request assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.fee == Amount(Unit.sat, 10) # sat @@ -208,7 +214,10 @@ async def test_blink_get_payment_quote(): # response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - quote = await blink.get_payment_quote(payment_request_4973) + melt_quote_request_4973 = PostMeltQuoteRequest( + unit=Unit.sat.name, request=payment_request_4973 + ) + quote = await blink.get_payment_quote(melt_quote_request_4973) assert quote.checking_id == payment_request_4973 assert quote.amount == Amount(Unit.sat, 4973) # sat assert quote.fee == Amount(Unit.sat, 25) # sat @@ -216,7 +225,10 @@ async def test_blink_get_payment_quote(): # response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - quote = await blink.get_payment_quote(payment_request_1) + melt_quote_request_1 = PostMeltQuoteRequest( + unit=Unit.sat.name, request=payment_request_1 + ) + quote = await blink.get_payment_quote(melt_quote_request_1) assert quote.checking_id == payment_request_1 assert quote.amount == Amount(Unit.sat, 1) # sat assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat @@ -228,7 +240,10 @@ async def test_blink_get_payment_quote_backend_error(): # response says error but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}} respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - quote = await blink.get_payment_quote(payment_request) + melt_quote_request = PostMeltQuoteRequest( + unit=Unit.sat.name, request=payment_request + ) + quote = await blink.get_payment_quote(melt_quote_request) assert quote.checking_id == payment_request assert quote.amount == Amount(Unit.sat, 1000) # sat assert quote.fee == Amount(Unit.sat, 5) # sat diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index faa58df5..22c44f18 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -73,7 +73,7 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger): invoice_dict = get_real_invoice(64) invoice_payment_request = invoice_dict["payment_request"] - mint_quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) + mint_quote = await wallet1.get_melt_quote(invoice_payment_request) 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( diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 78195f6d..60401a5e 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -274,7 +274,7 @@ async def test_melt(wallet1: Wallet): invoice_payment_hash = str(invoice.payment_hash) invoice_payment_request = invoice.bolt11 - quote = await wallet1.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet1.request_melt(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve if is_regtest: @@ -288,7 +288,7 @@ async def test_melt(wallet1: Wallet): _, send_proofs = await wallet1.split_to_send(wallet1.proofs, total_amount) - melt_response = await wallet1.pay_lightning( + melt_response = await wallet1.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, From 1101e3cb6116c4e6bc57b9a63c2aa29c19d6b3e9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Mar 2024 12:56:38 +0100 Subject: [PATCH 06/17] decode mypy error lndrest --- cashu/lightning/corelightningrest.py | 3 +-- cashu/lightning/fake.py | 3 +-- cashu/lightning/lnbits.py | 3 +-- cashu/lightning/lndrest.py | 4 ++-- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index d1ffb23b..868da6f9 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -315,8 +315,7 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - bolt11 = melt_quote.request - invoice_obj = decode(bolt11) + invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 6ae85784..59cf983a 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -155,8 +155,7 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - bolt11 = melt_quote.request - invoice_obj = decode(bolt11) + invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." if self.unit == Unit.sat: diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index b24cf74b..e7862892 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -160,8 +160,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - bolt11 = melt_quote.request - invoice_obj = decode(bolt11) + invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index eab97b15..0afb9c4e 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -158,7 +158,7 @@ async def pay_invoice( invoice = bolt11.decode(quote.request) if invoice.amount_msat: amount_msat = int(invoice.amount_msat) - if amount_msat != quote.amount * 1000: + if amount_msat != quote.amount * 1000 and self.supports_mpp: return await self.pay_partial_invoice( quote, Amount(Unit.sat, quote.amount), fee_limit_msat ) @@ -374,7 +374,7 @@ async def get_payment_quote( else None ) - invoice_obj = decode(bolt11) + invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." if amount: From c5832efbcc3c50642ac97a8267dbb0044c4533d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:14:14 +0100 Subject: [PATCH 07/17] fix test --- tests/test_mint_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mint_operations.py b/tests/test_mint_operations.py index 22c44f18..df773583 100644 --- a/tests/test_mint_operations.py +++ b/tests/test_mint_operations.py @@ -73,7 +73,7 @@ async def test_melt_external(wallet1: Wallet, ledger: Ledger): invoice_dict = get_real_invoice(64) invoice_payment_request = invoice_dict["payment_request"] - mint_quote = await wallet1.get_melt_quote(invoice_payment_request) + mint_quote = await wallet1.melt_quote(invoice_payment_request) 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( From 079251fbc1a2408270b7d5d6730ba37ee7b67beb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 13:07:01 +0200 Subject: [PATCH 08/17] fix tests --- tests/test_mint_init.py | 12 ++++++------ tests/test_mint_regtest.py | 4 ++-- tests/test_wallet_regtest.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_mint_init.py b/tests/test_mint_init.py index e7d23413..3b3c75c8 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -249,11 +249,11 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led invoice_payment_request = str(invoice_dict["payment_request"]) # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task( - wallet.pay_lightning( + wallet.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, @@ -294,11 +294,11 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led invoice_payment_request = str(invoice_dict["payment_request"]) # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task( - wallet.pay_lightning( + wallet.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, @@ -344,11 +344,11 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led preimage_hash = invoice_obj.payment_hash # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task( - wallet.pay_lightning( + wallet.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, diff --git a/tests/test_mint_regtest.py b/tests/test_mint_regtest.py index 043843c4..e065eaac 100644 --- a/tests/test_mint_regtest.py +++ b/tests/test_mint_regtest.py @@ -41,12 +41,12 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): invoice_payment_request = str(invoice_dict["payment_request"]) # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote)) # asyncio.create_task( - # wallet.pay_lightning( + # wallet.melt( # proofs=send_proofs, # invoice=invoice_payment_request, # fee_reserve_sat=quote.fee_reserve, diff --git a/tests/test_wallet_regtest.py b/tests/test_wallet_regtest.py index 7a8c61cb..d4451182 100644 --- a/tests/test_wallet_regtest.py +++ b/tests/test_wallet_regtest.py @@ -43,11 +43,11 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): invoice_payment_request = str(invoice_dict["payment_request"]) # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task( - wallet.pay_lightning( + wallet.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, @@ -83,11 +83,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger): preimage_hash = invoice_obj.payment_hash # wallet pays the invoice - quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + quote = await wallet.melt_quote(invoice_payment_request) total_amount = quote.amount + quote.fee_reserve _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) asyncio.create_task( - wallet.pay_lightning( + wallet.melt( proofs=send_proofs, invoice=invoice_payment_request, fee_reserve_sat=quote.fee_reserve, From e5fa3cc945d7ed2b36720a6375d1f496b9e2f732 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 14:48:27 +0200 Subject: [PATCH 09/17] signal feature and blindmessages_deprecated --- cashu/core/base.py | 81 ++++++++++++++++++--------------- cashu/core/settings.py | 2 +- cashu/lightning/lndrest.py | 4 +- cashu/mint/router.py | 21 +++++++-- cashu/mint/router_deprecated.py | 6 +-- 5 files changed, 67 insertions(+), 47 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 37900e7f..624dd18f 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -161,13 +161,35 @@ def htlcpreimage(self) -> Union[str, None]: return HTLCWitness.from_witness(self.witness).preimage +class Proofs(BaseModel): + # NOTE: not used in Pydantic validation + __root__: List[Proof] + + class BlindedMessage(BaseModel): """ Blinded message or blinded secret or "output" which is to be signed by the mint """ amount: int - id: str + id: str # Keyset id + B_: str # Hex-encoded blinded message + witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) + + @property + def p2pksigs(self) -> List[str]: + assert self.witness, "Witness missing in output" + return P2PKWitness.from_witness(self.witness).signatures + + +class BlindedMessage_Deprecated(BaseModel): + """ + Deprecated: BlindedMessage for v0 protocol (deprecated api routes) have no id field. + + Blinded message or blinded secret or "output" which is to be signed by the mint + """ + + amount: int B_: str # Hex-encoded blinded message witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) @@ -187,14 +209,10 @@ class BlindedSignature(BaseModel): C_: str # Hex-encoded signature dleq: Optional[DLEQ] = None # DLEQ proof - @classmethod - def from_row(cls, row: Row): - return cls( - id=row["id"], - amount=row["amount"], - C_=row["c_"], - dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]), - ) + +class BlindedMessages(BaseModel): + # NOTE: not used in Pydantic validation + __root__: List[BlindedMessage] = [] # ------- LIGHTNING INVOICE ------- @@ -304,7 +322,6 @@ class MintMeltMethodSetting(BaseModel): unit: str min_amount: Optional[int] = None max_amount: Optional[int] = None - mpp: Optional[bool] = None class GetInfoResponse(BaseModel): @@ -315,7 +332,7 @@ class GetInfoResponse(BaseModel): description_long: Optional[str] = None contact: Optional[List[List[str]]] = None motd: Optional[str] = None - nuts: Optional[Dict[int, Dict[str, Any]]] = None + nuts: Optional[Dict[int, Any]] = None class GetInfoResponse_deprecated(BaseModel): @@ -330,19 +347,6 @@ class GetInfoResponse_deprecated(BaseModel): parameter: Optional[dict] = None -class BlindedMessage_Deprecated(BaseModel): - # Same as BlindedMessage, but without the id field - amount: int - B_: str # Hex-encoded blinded message - id: Optional[str] = None - witness: Union[str, None] = None # witnesses (used for P2PK with SIG_ALL) - - @property - def p2pksigs(self) -> List[str]: - assert self.witness, "Witness missing in output" - return P2PKWitness.from_witness(self.witness).signatures - - # ------- API: KEYS ------- @@ -658,6 +662,7 @@ def __init__( valid_to=None, first_seen=None, active=True, + use_deprecated_id=False, # BACKWARDS COMPATIBILITY < 0.15.0 ): self.valid_from = valid_from self.valid_to = valid_to @@ -672,10 +677,19 @@ def __init__( else: self.id = id + # BEGIN BACKWARDS COMPATIBILITY < 0.15.0 + if use_deprecated_id: + logger.warning( + "Using deprecated keyset id derivation for backwards compatibility <" + " 0.15.0" + ) + self.id = derive_keyset_id_deprecated(self.public_keys) + # END BACKWARDS COMPATIBILITY < 0.15.0 + self.unit = Unit[unit] logger.trace(f"Derived keyset id {self.id} from public keys.") - if id and id != self.id: + if id and id != self.id and use_deprecated_id: logger.warning( f"WARNING: Keyset id {self.id} does not match the given id {id}." " Overwriting." @@ -730,6 +744,8 @@ class MintKeyset: first_seen: Optional[str] = None version: Optional[str] = None + duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0 + def __init__( self, *, @@ -810,12 +826,6 @@ def generate_keys(self): assert self.seed, "seed not set" assert self.derivation_path, "derivation path not set" - # we compute the keyset id from the public keys only if it is not - # loaded from the database. This is to allow for backwards compatibility - # with old keysets with new id's and vice versa. This code can be removed - # if there are only new keysets in the mint (> 0.15.0) - id_in_db = self.id - if self.version_tuple < (0, 12): # WARNING: Broken key derivation for backwards compatibility with < 0.12 self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12( @@ -826,8 +836,7 @@ def generate_keys(self): f"WARNING: Using weak key derivation for keyset {self.id} (backwards" " compatibility < 0.12)" ) - # load from db or derive - self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore + self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore elif self.version_tuple < (0, 15): self.private_keys = derive_keys_sha256(self.seed, self.derivation_path) logger.trace( @@ -835,13 +844,11 @@ def generate_keys(self): " compatibility < 0.15)" ) self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - # load from db or derive - self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore + self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore else: self.private_keys = derive_keys(self.seed, self.derivation_path) self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - # load from db or derive - self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore + self.id = derive_keyset_id(self.public_keys) # type: ignore # ------- TOKEN ------- diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 612f3451..9e505c41 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -171,7 +171,7 @@ class LndRestFundingSource(MintSettings): mint_lnd_rest_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_admin_macaroon: Optional[str] = Field(default=None) mint_lnd_rest_invoice_macaroon: Optional[str] = Field(default=None) - mint_lnd_enable_mpp_experimental: bool = Field(default=False) + mint_lnd_enable_mpp: bool = Field(default=False) class CoreLightningRestFundingSource(MintSettings): diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index a1be061d..a4aa359f 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -29,7 +29,7 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" - supports_mpp = settings.mint_lnd_enable_mpp_experimental + supports_mpp = settings.mint_lnd_enable_mpp supported_units = set([Unit.sat, Unit.msat]) unit = Unit.sat @@ -74,7 +74,7 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): base_url=self.endpoint, headers=self.auth, verify=self.cert ) if self.supports_mpp: - logger.info("LNDRestWallet enabling MPP experimental feature") + logger.info("LNDRestWallet enabling MPP feature") async def status(self) -> StatusResponse: try: diff --git a/cashu/mint/router.py b/cashu/mint/router.py index f497c0a7..de99467c 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -49,9 +49,7 @@ async def info() -> GetInfoResponse: method_settings[nut] = [] for method, unit_dict in ledger.backends.items(): for unit in unit_dict.keys(): - setting = MintMeltMethodSetting( - method=method.name, unit=unit.name, mpp=unit_dict[unit].supports_mpp - ) + setting = MintMeltMethodSetting(method=method.name, unit=unit.name) if nut == 4 and settings.mint_max_peg_in: setting.max_amount = settings.mint_max_peg_in @@ -65,7 +63,7 @@ async def info() -> GetInfoResponse: supported_dict = dict(supported=True) supported_dict = dict(supported=True) - mint_features: Dict[int, Dict[str, Any]] = { + mint_features: Dict[int, Any] = { 4: dict( methods=method_settings[4], disabled=settings.mint_peg_out_only, @@ -82,6 +80,21 @@ async def info() -> GetInfoResponse: 12: supported_dict, } + # signal which method-unit pairs support MPP + for method, unit_dict in ledger.backends.items(): + for unit in unit_dict.keys(): + logger.trace( + f"method={method.name} unit={unit} supports_mpp={unit_dict[unit].supports_mpp}" + ) + if unit_dict[unit].supports_mpp: + mint_features.setdefault(15, []).append( + { + "method": method.name, + "unit": unit.name, + "mpp": True, + } + ) + return GetInfoResponse( name=settings.mint_info_name, pubkey=ledger.pubkey.serialize().hex() if ledger.pubkey else None, diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index 5e5bdd50..4f6e1c32 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -179,7 +179,7 @@ async def mint_deprecated( # BEGIN BACKWARDS COMPATIBILITY < 0.15 # Mint expects "id" in outputs to know which keyset to use to sign them. outputs: list[BlindedMessage] = [ - BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"})) for o in payload.outputs ] # END BACKWARDS COMPATIBILITY < 0.15 @@ -223,7 +223,7 @@ async def melt_deprecated( # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs if payload.outputs: outputs: list[BlindedMessage] = [ - BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"})) for o in payload.outputs ] else: @@ -295,7 +295,7 @@ async def split_deprecated( assert payload.outputs, Exception("no outputs provided.") # BEGIN BACKWARDS COMPATIBILITY < 0.14: add "id" to outputs outputs: list[BlindedMessage] = [ - BlindedMessage(id=o.id or ledger.keyset.id, **o.dict(exclude={"id"})) + BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"})) for o in payload.outputs ] # END BACKWARDS COMPATIBILITY < 0.14 From 79b3afad0c20c8432bb4c8c600cdd357c0d7ee9f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 15:35:26 +0200 Subject: [PATCH 10/17] setting --- cashu/core/settings.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 9e505c41..f28bb4b4 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -89,6 +89,7 @@ class MintLimits(MintSettings): ) mint_max_request_length: int = Field( default=1000, + gt=0, title="Maximum request length", description="Maximum length of REST API request arrays.", ) @@ -100,16 +101,21 @@ class MintLimits(MintSettings): ) mint_max_peg_in: int = Field( default=None, + gt=0, title="Maximum peg-in", description="Maximum amount for a mint operation.", ) mint_max_peg_out: int = Field( default=None, + gt=0, title="Maximum peg-out", description="Maximum amount for a melt operation.", ) mint_max_balance: int = Field( - default=None, title="Maximum mint balance", description="Maximum mint balance." + default=None, + gt=0, + title="Maximum mint balance", + description="Maximum mint balance.", ) From 2307423de7a00ba78730d77ff6f3bafe1259d3b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 15:40:29 +0200 Subject: [PATCH 11/17] fix blindedsignature method --- cashu/core/base.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 624dd18f..217ac17a 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -199,6 +199,11 @@ def p2pksigs(self) -> List[str]: return P2PKWitness.from_witness(self.witness).signatures +class BlindedMessages(BaseModel): + # NOTE: not used in Pydantic validation + __root__: List[BlindedMessage] = [] + + class BlindedSignature(BaseModel): """ Blinded signature or "promise" which is the signature on a `BlindedMessage` @@ -209,10 +214,14 @@ class BlindedSignature(BaseModel): C_: str # Hex-encoded signature dleq: Optional[DLEQ] = None # DLEQ proof - -class BlindedMessages(BaseModel): - # NOTE: not used in Pydantic validation - __root__: List[BlindedMessage] = [] + @classmethod + def from_row(cls, row: Row): + return cls( + id=row["id"], + amount=row["amount"], + C_=row["c_"], + dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]), + ) # ------- LIGHTNING INVOICE ------- From 546584beef9c5b1b6467e0f7eaf281d5b18ff87d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 15:52:40 +0200 Subject: [PATCH 12/17] fix tests --- cashu/core/base.py | 6 ++++++ cashu/mint/router_deprecated.py | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 217ac17a..fcc63d99 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -566,6 +566,12 @@ class PostRestoreRequest(BaseModel): ) +class PostRestoreRequest_Deprecated(BaseModel): + outputs: List[BlindedMessage_Deprecated] = Field( + ..., max_items=settings.mint_max_request_length + ) + + class PostRestoreResponse(BaseModel): outputs: List[BlindedMessage] = [] signatures: List[BlindedSignature] = [] diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index 4f6e1c32..049bc528 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -20,7 +20,7 @@ PostMintQuoteRequest, PostMintRequest_deprecated, PostMintResponse_deprecated, - PostRestoreRequest, + PostRestoreRequest_Deprecated, PostRestoreResponse, PostSplitRequest_Deprecated, PostSplitResponse_Deprecated, @@ -372,7 +372,15 @@ async def check_spendable_deprecated( ), deprecated=True, ) -async def restore(payload: PostRestoreRequest) -> PostRestoreResponse: +async def restore(payload: PostRestoreRequest_Deprecated) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") - outputs, promises = await ledger.restore(payload.outputs) + if payload.outputs: + outputs: list[BlindedMessage] = [ + BlindedMessage(id=ledger.keyset.id, **o.dict(exclude={"id"})) + for o in payload.outputs + ] + else: + outputs = [] + + outputs, promises = await ledger.restore(outputs) return PostRestoreResponse(outputs=outputs, signatures=promises) From 7a985133f4310a61d20f9597e1aea249b9ca789d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 16:52:41 +0200 Subject: [PATCH 13/17] mint info file --- cashu/wallet/mint_info.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 cashu/wallet/mint_info.py diff --git a/cashu/wallet/mint_info.py b/cashu/wallet/mint_info.py new file mode 100644 index 00000000..50609330 --- /dev/null +++ b/cashu/wallet/mint_info.py @@ -0,0 +1,35 @@ +from typing import Any, Dict, List, Optional + +from ..core.base import GetInfoResponse + + +class MintInfo: + name: Optional[str] = None + pubkey: Optional[str] = None + version: Optional[str] = None + description: Optional[str] = None + description_long: Optional[str] = None + contact: Optional[List[List[str]]] = None + motd: Optional[str] = None + nuts: Optional[Dict[int, Any]] = None + + def __init__(self, info_response: GetInfoResponse): + self.name = info_response.name + self.pubkey = info_response.pubkey + self.version = info_response.version + self.description = info_response.description + self.description_long = info_response.description_long + self.contact = info_response.contact + self.motd = info_response.motd + self.nuts = info_response.nuts + + def __str__(self): + return f"{self.name} ({self.description})" + + def supports_nut(self, nut: int) -> bool: + if self.nuts is None: + return False + return nut in self.nuts + + def supports_mpp(self) -> bool: + return self.supports_nut(15) From 1c04e6aa533679477ec3a11e0266f3ee0c4e7e90 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 22:15:14 +0200 Subject: [PATCH 14/17] test mpp with lnd regtest --- cashu/core/base.py | 6 ++ cashu/lightning/lndrest.py | 2 +- cashu/wallet/mint_info.py | 49 ++++++------- cashu/wallet/wallet.py | 10 ++- tests/conftest.py | 1 + tests/test_wallet_regtest_mpp.py | 115 +++++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 tests/test_wallet_regtest_mpp.py diff --git a/cashu/core/base.py b/cashu/core/base.py index fcc63d99..204d3ea0 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -344,6 +344,12 @@ class GetInfoResponse(BaseModel): nuts: Optional[Dict[int, Any]] = None +class Nut15MppSupport(BaseModel): + method: str + unit: str + mpp: bool + + class GetInfoResponse_deprecated(BaseModel): name: Optional[str] = None pubkey: Optional[str] = None diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index a4aa359f..04d6bc39 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -375,7 +375,7 @@ async def get_payment_quote( ) -> PaymentQuoteResponse: # get amount from melt_quote or from bolt11 amount = ( - Amount(Unit(melt_quote.unit), melt_quote.amount) + Amount(Unit[melt_quote.unit], melt_quote.amount) if melt_quote.amount else None ) diff --git a/cashu/wallet/mint_info.py b/cashu/wallet/mint_info.py index 50609330..5133a08f 100644 --- a/cashu/wallet/mint_info.py +++ b/cashu/wallet/mint_info.py @@ -1,27 +1,19 @@ from typing import Any, Dict, List, Optional -from ..core.base import GetInfoResponse - - -class MintInfo: - name: Optional[str] = None - pubkey: Optional[str] = None - version: Optional[str] = None - description: Optional[str] = None - description_long: Optional[str] = None - contact: Optional[List[List[str]]] = None - motd: Optional[str] = None - nuts: Optional[Dict[int, Any]] = None - - def __init__(self, info_response: GetInfoResponse): - self.name = info_response.name - self.pubkey = info_response.pubkey - self.version = info_response.version - self.description = info_response.description - self.description_long = info_response.description_long - self.contact = info_response.contact - self.motd = info_response.motd - self.nuts = info_response.nuts +from pydantic import BaseModel + +from ..core.base import Nut15MppSupport, Unit + + +class MintInfo(BaseModel): + name: Optional[str] + pubkey: Optional[str] + version: Optional[str] + description: Optional[str] + description_long: Optional[str] + contact: Optional[List[List[str]]] + motd: Optional[str] + nuts: Dict[int, Any] def __str__(self): return f"{self.name} ({self.description})" @@ -31,5 +23,14 @@ def supports_nut(self, nut: int) -> bool: return False return nut in self.nuts - def supports_mpp(self) -> bool: - return self.supports_nut(15) + def supports_mpp(self, method: str, unit: Unit) -> bool: + nut_15 = self.nuts.get(15) + if not nut_15 or not self.supports_nut(15): + return False + + for entry in nut_15: + entry_obj = Nut15MppSupport.parse_obj(entry) + if entry_obj.method == method and entry_obj.unit == unit.name: + return True + + return False diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index cc9dfc1a..f0f3e573 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -71,6 +71,7 @@ ) from . import migrations from .htlc import WalletHTLC +from .mint_info import MintInfo from .p2pk import WalletP2PK from .secrets import WalletSecrets from .wallet_deprecated import LedgerAPIDeprecated @@ -130,7 +131,7 @@ class LedgerAPI(LedgerAPIDeprecated, object): keysets: Dict[str, WalletKeyset] # holds keysets mint_keyset_ids: List[str] # holds active keyset ids of the mint unit: Unit - mint_info: GetInfoResponse # holds info about mint + mint_info: MintInfo # holds info about mint tor: TorProxy db: Database httpx: httpx.AsyncClient @@ -269,9 +270,10 @@ async def _load_mint_keysets(self) -> List[str]: logger.debug(f"Mint keysets: {self.mint_keyset_ids}") return self.mint_keyset_ids - async def _load_mint_info(self) -> GetInfoResponse: + async def _load_mint_info(self) -> MintInfo: """Loads the mint info from the mint.""" - self.mint_info = await self._get_info() + mint_info_resp = await self._get_info() + self.mint_info = MintInfo(**mint_info_resp.dict()) logger.debug(f"Mint info: {self.mint_info}") return self.mint_info @@ -997,6 +999,8 @@ async def request_melt( """ Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. """ + if amount and not self.mint_info.supports_mpp("bolt11", self.unit): + raise Exception("Mint does not support MPP, cannot specify amount.") melt_quote = await self.melt_quote(invoice, amount) logger.debug( f"Mint wants {self.unit.str(melt_quote.fee_reserve)} as fee reserve." diff --git a/tests/conftest.py b/tests/conftest.py index e6f31cab..f3a9a6b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ settings.mint_private_key = "TEST_PRIVATE_KEY" settings.mint_seed_decryption_key = "" settings.mint_max_balance = 0 +settings.mint_lnd_enable_mpp = True assert "test" in settings.cashu_dir shutil.rmtree(settings.cashu_dir, ignore_errors=True) diff --git a/tests/test_wallet_regtest_mpp.py b/tests/test_wallet_regtest_mpp.py new file mode 100644 index 00000000..35a8cb48 --- /dev/null +++ b/tests/test_wallet_regtest_mpp.py @@ -0,0 +1,115 @@ +import asyncio +from typing import List + +import pytest +import pytest_asyncio + +from cashu.core.base import Method, Proof +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + get_real_invoice, + is_fake, + pay_if_regtest, +) + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger): + # make sure that mpp is supported by the bolt11-sat backend + if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp: + pytest.skip("backend does not support mpp") + + # make sure wallet knows the backend supports mpp + assert wallet.mint_info.supports_mpp("bolt11", wallet.unit) + + # top up wallet twice so we have enough for two payments + topup_invoice = await wallet.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + proofs1 = await wallet.mint(128, id=topup_invoice.id) + assert wallet.balance == 128 + + topup_invoice = await wallet.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + proofs2 = await wallet.mint(128, id=topup_invoice.id) + assert wallet.balance == 256 + + # this is the invoice we want to pay in two parts + invoice_dict = get_real_invoice(64) + invoice_payment_request = invoice_dict["payment_request"] + + async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): + await asyncio.sleep(delay) + # wallet pays 32 sat of the invoice + quote = await wallet.melt_quote(invoice_payment_request, amount=32) + assert quote.amount == amount + await wallet.melt( + proofs, + invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + + # call pay_mpp twice in parallel to pay the full invoice + # we delay the second payment so that the wallet doesn't derive the same blindedmessages twice due to a race condition + await asyncio.gather(pay_mpp(32, proofs1), pay_mpp(32, proofs2, delay=0.5)) + + assert wallet.balance <= 256 - 64 + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger): + # make sure that mpp is supported by the bolt11-sat backend + if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp: + pytest.skip("backend does not support mpp") + + # make sure wallet knows the backend supports mpp + assert wallet.mint_info.supports_mpp("bolt11", wallet.unit) + + # top up wallet twice so we have enough for two payments + topup_invoice = await wallet.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + proofs1 = await wallet.mint(128, id=topup_invoice.id) + assert wallet.balance == 128 + + topup_invoice = await wallet.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + proofs2 = await wallet.mint(128, id=topup_invoice.id) + assert wallet.balance == 256 + + # this is the invoice we want to pay in two parts + invoice_dict = get_real_invoice(64) + invoice_payment_request = invoice_dict["payment_request"] + + async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): + await asyncio.sleep(delay) + # wallet pays 32 sat of the invoice + quote = await wallet.melt_quote(invoice_payment_request, amount=32) + assert quote.amount == amount + await wallet.melt( + proofs, + invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + + # instead: call pay_mpp twice in the background, sleep for a bit, then check if the payment was successful (it should not be) + asyncio.create_task(pay_mpp(32, proofs1)) + asyncio.create_task(pay_mpp(16, proofs2, delay=0.5)) + await asyncio.sleep(2) + + assert wallet.balance == 256 From 87b624706be54bc37b66aa3011a856d2b89250d2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 22:21:33 +0200 Subject: [PATCH 15/17] nuts optionsl mint info --- cashu/wallet/mint_info.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cashu/wallet/mint_info.py b/cashu/wallet/mint_info.py index 5133a08f..30ec3f6d 100644 --- a/cashu/wallet/mint_info.py +++ b/cashu/wallet/mint_info.py @@ -13,7 +13,7 @@ class MintInfo(BaseModel): description_long: Optional[str] contact: Optional[List[List[str]]] motd: Optional[str] - nuts: Dict[int, Any] + nuts: Optional[Dict[int, Any]] def __str__(self): return f"{self.name} ({self.description})" @@ -24,6 +24,8 @@ def supports_nut(self, nut: int) -> bool: return nut in self.nuts def supports_mpp(self, method: str, unit: Unit) -> bool: + if not self.nuts: + return False nut_15 = self.nuts.get(15) if not nut_15 or not self.supports_nut(15): return False From 4c174b556fdcccc167e39eab5289fd1314f79781 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 22:23:19 +0200 Subject: [PATCH 16/17] try to enable mpp with lnd --- .github/workflows/regtest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 8beb7d83..247dd028 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -65,6 +65,7 @@ jobs: MINT_LND_REST_ENDPOINT: https://localhost:8081/ MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon + MINT_LND_ENABLE_MPP: true # LND_GRPC_ENDPOINT: localhost # LND_GRPC_PORT: 10009 # LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert From eacd1577fe1db7197cb331288206d6a992c5e0e8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 22:44:59 +0200 Subject: [PATCH 17/17] test mpp with third payment --- tests/test_wallet_regtest_mpp.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_wallet_regtest_mpp.py b/tests/test_wallet_regtest_mpp.py index 35a8cb48..76280132 100644 --- a/tests/test_wallet_regtest_mpp.py +++ b/tests/test_wallet_regtest_mpp.py @@ -80,7 +80,7 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger # make sure wallet knows the backend supports mpp assert wallet.mint_info.supports_mpp("bolt11", wallet.unit) - # top up wallet twice so we have enough for two payments + # top up wallet twice so we have enough for three payments topup_invoice = await wallet.request_mint(128) pay_if_regtest(topup_invoice.bolt11) proofs1 = await wallet.mint(128, id=topup_invoice.id) @@ -91,6 +91,11 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger proofs2 = await wallet.mint(128, id=topup_invoice.id) assert wallet.balance == 256 + topup_invoice = await wallet.request_mint(128) + pay_if_regtest(topup_invoice.bolt11) + proofs3 = await wallet.mint(128, id=topup_invoice.id) + assert wallet.balance == 384 + # this is the invoice we want to pay in two parts invoice_dict = get_real_invoice(64) invoice_payment_request = invoice_dict["payment_request"] @@ -98,7 +103,7 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): await asyncio.sleep(delay) # wallet pays 32 sat of the invoice - quote = await wallet.melt_quote(invoice_payment_request, amount=32) + quote = await wallet.melt_quote(invoice_payment_request, amount=amount) assert quote.amount == amount await wallet.melt( proofs, @@ -112,4 +117,11 @@ async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): asyncio.create_task(pay_mpp(16, proofs2, delay=0.5)) await asyncio.sleep(2) - assert wallet.balance == 256 + # payment is still pending because the full amount has not been paid + assert wallet.balance == 384 + + # send the remaining 16 sat to complete the payment + asyncio.create_task(pay_mpp(16, proofs3, delay=0.5)) + await asyncio.sleep(2) + + assert wallet.balance <= 384 - 64