diff --git a/.gitignore b/.gitignore index 21e0d7740..14d878e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,4 @@ tor.pid /test_data # MacOS -.DS_Store +.DS_Store \ No newline at end of file diff --git a/cashu/core/base.py b/cashu/core/base.py index 870e589a5..3b7d8bbf5 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -280,6 +280,17 @@ class MeltQuoteState(Enum): def __str__(self): return self.name +class PaymentQuoteKind(Enum): + # Regular payments + REGULAR = "REGULAR" + # Payments for which the request string did not specify an amount + AMOUNTLESS = "AMOUNTLESS" + # Payments for which this Mint is expect to pay only a part of the total amount + # of the request string + PARTIAL = "PARTIAL" + + def __str__(self) -> str: + return self.name class MeltQuote(LedgerEvent): quote: str @@ -298,6 +309,7 @@ class MeltQuote(LedgerEvent): outputs: Optional[List[BlindedMessage]] = None change: Optional[List[BlindedSignature]] = None mint: Optional[str] = None + quote_kind: PaymentQuoteKind = PaymentQuoteKind.REGULAR @classmethod def from_row(cls, row: Row): @@ -339,6 +351,7 @@ def from_row(cls, row: Row): change=change, expiry=expiry, payment_preimage=payment_preimage, + quote_kind=PaymentQuoteKind(row.get("kind", "REGULAR")), # type: ignore ) @classmethod diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 8b0c3cc23..deeb3e7ed 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -80,12 +80,25 @@ def __init__(self, detail): super().__init__(detail, code=self.code) -class TransactionAmountExceedsLimitError(TransactionError): +class TransactionAmountExceedsLimitError(CashuError): code = 11006 def __init__(self, detail): super().__init__(detail, code=self.code) +class AmountlessInvoiceNotSupportedError(TransactionError): + detail = "Amountless invoice is not supported" + code = 11007 + + def __init__(self): + super().__init__(detail=self.detail, code=self.code) + +class IncorrectRequestAmountError(TransactionError): + detail = "Amount in request does not equal invoice" + code = 11008 + + def __init__(self): + super().__init__(detail=self.detail, code=self.code) class TransactionDuplicateInputsError(TransactionError): detail = "Duplicate inputs provided" diff --git a/cashu/core/mint_info.py b/cashu/core/mint_info.py index 2dc040aa9..6f1fd969a 100644 --- a/cashu/core/mint_info.py +++ b/cashu/core/mint_info.py @@ -6,7 +6,7 @@ from .base import Method, Unit from .models import MintInfoContact, MintInfoProtectedEndpoint, Nut15MppSupport -from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MPP_NUT, WEBSOCKETS_NUT +from .nuts.nuts import BLIND_AUTH_NUT, CLEAR_AUTH_NUT, MELT_NUT, MPP_NUT, WEBSOCKETS_NUT class MintInfo(BaseModel): @@ -48,6 +48,22 @@ def supports_mpp(self, method: str, unit: Unit) -> bool: return False + def supports_amountless(self, method: str, unit: Unit) -> bool: + if not self.nuts: + return False + nut_5 = self.nuts.get(MELT_NUT) + if not nut_5 or not nut_5.get("methods"): + return False + for entry in nut_5["methods"]: + if ( + entry["method"] == method + and entry["unit"] == str(unit) + and entry.get("amountless", None) + ): + return True + + return False + def supports_websocket_mint_quote(self, method: Method, unit: Unit) -> bool: if not self.nuts or not self.supports_nut(WEBSOCKETS_NUT): return False diff --git a/cashu/core/models.py b/cashu/core/models.py index ca73e8108..f257fa34e 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -31,6 +31,7 @@ class MeltMethodSetting(BaseModel): unit: str min_amount: Optional[int] = None max_amount: Optional[int] = None + amountless: Optional[bool] = None class MintInfoContact(BaseModel): @@ -199,9 +200,12 @@ class PostMintResponse_deprecated(BaseModel): class PostMeltRequestOptionMpp(BaseModel): amount: int = Field(gt=0) # input amount +class PostMeltRequestOptionAmountless(BaseModel): + amount_msat: int = Field(gt=0) # amount to pay to the amountless request class PostMeltRequestOptions(BaseModel): - mpp: Optional[PostMeltRequestOptionMpp] + mpp: Optional[PostMeltRequestOptionMpp] = None + amountless: Optional[PostMeltRequestOptionAmountless] = None class PostMeltQuoteRequest(BaseModel): @@ -218,6 +222,13 @@ def is_mpp(self) -> bool: else: return False + @property + def is_amountless(self) -> bool: + if self.options and self.options.amountless: + return True + else: + return False + @property def mpp_amount(self) -> int: if self.is_mpp and self.options and self.options.mpp: diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index f8bd925ee..237084311 100644 --- a/cashu/lightning/base.py +++ b/cashu/lightning/base.py @@ -7,6 +7,7 @@ from ..core.base import ( Amount, MeltQuote, + PaymentQuoteKind, Unit, ) from ..core.models import PostMeltQuoteRequest @@ -26,6 +27,7 @@ class PaymentQuoteResponse(BaseModel): checking_id: str amount: Amount fee: Amount + kind: PaymentQuoteKind = PaymentQuoteKind.REGULAR class InvoiceResponse(BaseModel): @@ -110,6 +112,7 @@ def __str__(self) -> str: class LightningBackend(ABC): supports_mpp: bool = False + supports_amountless: bool = False supports_incoming_payment_stream: bool = False supported_units: set[Unit] supports_description: bool = False diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py index 61006a28b..423a75422 100644 --- a/cashu/lightning/blink.py +++ b/cashu/lightning/blink.py @@ -455,10 +455,12 @@ async def get_payment_quote( raise e invoice_obj = decode(bolt11) - assert invoice_obj.amount_msat, "invoice has no amount." - amount_msat = int(invoice_obj.amount_msat) + if not invoice_obj.amount_msat: + raise Exception("Invoice with no amount") + amount_msat = int(invoice_obj.amount_msat) + # we take the highest: fee_msat_response, or BLINK_MAX_FEE_PERCENT, or MINIMUM_FEE_MSAT msat # Note: fees with BLINK_MAX_FEE_PERCENT are rounded to the nearest 1000 msat fees_amount_msat: int = ( diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 7c265e1b4..5d4b589f8 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -11,7 +11,8 @@ ) from loguru import logger -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PaymentQuoteKind, Unit +from ..core.errors import IncorrectRequestAmountError from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest from ..core.settings import settings @@ -45,6 +46,7 @@ class CLNRestWallet(LightningBackend): supported_units = {Unit.sat, Unit.msat} unit = Unit.sat supports_mpp = settings.mint_clnrest_enable_mpp + supports_amountless: bool = True supports_incoming_payment_stream: bool = True supports_description: bool = True @@ -179,13 +181,6 @@ async def pay_invoice( error_message=str(exc), ) - if not invoice.amount_msat or invoice.amount_msat <= 0: - error_message = "0 amount invoices are not allowed" - return PaymentResponse( - result=PaymentResult.FAILED, - error_message=error_message, - ) - quote_amount_msat = Amount(Unit[quote.unit], quote.amount).to(Unit.msat).amount fee_limit_percent = fee_limit_msat / quote_amount_msat * 100 post_data = { @@ -195,18 +190,40 @@ async def pay_invoice( # with fee < 5000 millisatoshi (which is default value of exemptfee) } - # Handle Multi-Mint payout where we must only pay part of the invoice amount logger.trace(f"{quote_amount_msat = }, {invoice.amount_msat = }") - if quote_amount_msat != invoice.amount_msat: - logger.trace("Detected Multi-Nut payment") - if self.supports_mpp: - post_data["partial_msat"] = quote_amount_msat - else: - error_message = "mint does not support MPP" - logger.error(error_message) - return PaymentResponse( - result=PaymentResult.FAILED, error_message=error_message - ) + + # Handle AMOUNTLESS, PARTIAL and REGULAR payment quotes + match quote.quote_kind: + case PaymentQuoteKind.REGULAR: + logger.debug("Paying REGULAR quote") + if not invoice.amount_msat: + error_message = "amountless invoices are not allowed" + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=error_message, + ) + case PaymentQuoteKind.AMOUNTLESS: + logger.debug("Paying AMOUNTLESS quote") + if self.supports_amountless: + post_data["amount_msat"] = quote_amount_msat + else: + error_message = "mint does not support amountless invoices" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + case PaymentQuoteKind.PARTIAL: + logger.debug("Paying PARTIAL/MPP quote") + # Handle Multi-Mint payout where we must only pay part of the invoice amount + if self.supports_mpp: + post_data["partial_msat"] = quote_amount_msat + else: + error_message = "mint does not support MPP" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + r = await self.client.post("/v1/pay", data=post_data, timeout=None) if r.is_error or "message" in r.json(): @@ -341,12 +358,29 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: + invoice_obj = decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." - assert invoice_obj.amount_msat > 0, "invoice has 0 amount." - amount_msat = ( - melt_quote.mpp_amount if melt_quote.is_mpp else (invoice_obj.amount_msat) - ) + + kind = PaymentQuoteKind.REGULAR + # Detect and handle amountless/partial/normal request + amount_msat = 0 + if melt_quote.is_amountless: + # Check that the user isn't doing something cheeky + if invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.amountless.amount_msat # type: ignore + kind = PaymentQuoteKind.AMOUNTLESS + elif melt_quote.is_mpp: + # Check that the user isn't doing something cheeky + if not invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.mpp.amount # type: ignore + kind = PaymentQuoteKind.PARTIAL + else: + if not invoice_obj.amount_msat: + raise Exception("request has no amount and is not specified as amountless") + 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) @@ -354,4 +388,5 @@ async def get_payment_quote( checking_id=invoice_obj.payment_hash, fee=fees.to(self.unit, round="up"), amount=amount.to(self.unit, round="up"), + kind=kind ) diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 54344a813..744fccddb 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -17,7 +17,10 @@ encode, ) -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PaymentQuoteKind, Unit +from ..core.errors import ( + IncorrectRequestAmountError, +) from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest from ..core.settings import settings @@ -59,6 +62,7 @@ class FakeWallet(LightningBackend): supports_incoming_payment_stream: bool = True supports_description: bool = True + supports_amountless: bool = True def __init__(self, unit: Unit = Unit.sat, **kwargs): self.assert_unit_supported(unit) @@ -195,6 +199,7 @@ async def pay_invoice(self, quote: MeltQuote, fee_limit: int) -> PaymentResponse await asyncio.sleep(settings.fakewallet_delay_outgoing_payment) if settings.fakewallet_pay_invoice_state: + print(settings.fakewallet_pay_invoice_state) if settings.fakewallet_pay_invoice_state == "SETTLED": self.update_balance(invoice, incoming=False) return PaymentResponse( @@ -250,15 +255,35 @@ async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: invoice_obj = decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." - if self.unit == Unit.sat or self.unit == Unit.msat: + # Payment quote is determined and saved in the response + kind = PaymentQuoteKind.REGULAR + + # Detect and handle amountless/partial/normal request + amount_msat = 0 + if melt_quote.is_amountless: + # Check that the user isn't doing something cheeky + if invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.amountless.amount_msat # type: ignore + kind = PaymentQuoteKind.AMOUNTLESS + elif melt_quote.is_mpp: + # Check that the user isn't doing something cheeky + if not invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.mpp.amount # type: ignore + kind = PaymentQuoteKind.PARTIAL + else: + if not invoice_obj.amount_msat: + raise Exception("request has no amount and is not specified as amountless") amount_msat = int(invoice_obj.amount_msat) + + if self.unit == Unit.sat or self.unit == Unit.msat: fees_msat = fee_reserve(amount_msat) fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) elif self.unit == Unit.usd or self.unit == Unit.eur: - amount_usd = math.ceil(invoice_obj.amount_msat / 1e9 * self.fake_btc_price) + amount_usd = math.ceil(amount_msat / 1e9 * self.fake_btc_price) amount = Amount(unit=self.unit, amount=amount_usd) fees = Amount(unit=self.unit, amount=2) else: @@ -268,6 +293,7 @@ async def get_payment_quote( checking_id=invoice_obj.payment_hash, fee=fees.to(self.unit, round="up"), amount=amount.to(self.unit, round="up"), + kind=kind, ) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index bc9d4e9f3..4ae4402eb 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -217,8 +217,12 @@ async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: invoice_obj = decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." + + if not invoice_obj.amount_msat: + raise Exception("request has no amount") + 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) diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index b298fcbf3..88b22a296 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -16,7 +16,8 @@ import cashu.lightning.lnd_grpc.protos.lightning_pb2_grpc as lightningstub import cashu.lightning.lnd_grpc.protos.router_pb2 as routerrpc import cashu.lightning.lnd_grpc.protos.router_pb2_grpc as routerstub -from cashu.core.base import Amount, MeltQuote, Unit +from cashu.core.base import Amount, MeltQuote, PaymentQuoteKind, Unit +from cashu.core.errors import IncorrectRequestAmountError from cashu.core.helpers import fee_reserve from cashu.core.settings import settings from cashu.lightning.base import ( @@ -51,6 +52,7 @@ class LndRPCWallet(LightningBackend): supports_mpp = settings.mint_lnd_enable_mpp + supports_amountless: bool = True supports_incoming_payment_stream = True supported_units = {Unit.sat, Unit.msat} supports_description: bool = True @@ -157,30 +159,52 @@ 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 and self.supports_mpp: - return await self.pay_partial_invoice( - quote, Amount(Unit.sat, quote.amount), fee_limit_msat + + # set the fee limit for the payment + send_request = None + #invoice = bolt11.decode(quote.request) + feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat) + + match quote.quote_kind: + case PaymentQuoteKind.PARTIAL: + if self.supports_mpp: + return await self.pay_partial_invoice( + quote, Amount(Unit.sat, quote.amount), fee_limit_msat + ) + else: + error_message = "MPP Payments are not enabled" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + case PaymentQuoteKind.AMOUNTLESS: + if self.supports_amountless: + # amount of the quote converted to msat + send_request = lnrpc.SendRequest( + payment_request=quote.request, + amt_msat=Amount(Unit[quote.unit], quote.amount).to(Unit.msat, round="up").amount, + fee_limit=feelimit + ) + else: + error_message = "Amountless payments are not enabled" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + case PaymentQuoteKind.REGULAR: + send_request = lnrpc.SendRequest( + payment_request=quote.request, + fee_limit=feelimit ) # set the fee limit for the payment - feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat) r = None try: async with grpc.aio.secure_channel( self.endpoint, self.combined_creds ) as channel: lnstub = lightningstub.LightningStub(channel) - r = await lnstub.SendPaymentSync( - lnrpc.SendRequest( - payment_request=quote.request, - fee_limit=feelimit, - ) - ) + r = await lnstub.SendPaymentSync(send_request) except AioRpcError as e: error_message = f"SendPaymentSync failed: {e}" return PaymentResponse( @@ -395,13 +419,26 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - # get amount from melt_quote or from bolt11 - amount_msat = melt_quote.mpp_amount if melt_quote.is_mpp else None - invoice_obj = bolt11.decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." - - if amount_msat is None: + + kind = PaymentQuoteKind.REGULAR + # Detect and handle amountless/partial/normal request + amount_msat = 0 + if melt_quote.is_amountless: + # Check that the user isn't doing something cheeky + if invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.amountless.amount_msat # type: ignore + kind = PaymentQuoteKind.AMOUNTLESS + elif melt_quote.is_mpp: + # Check that the user isn't doing something cheeky + if not invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.mpp.amount # type: ignore + kind = PaymentQuoteKind.PARTIAL + else: + if not invoice_obj.amount_msat: + raise Exception("request has no amount and is not specified as amountless") amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) @@ -413,4 +450,5 @@ async def get_payment_quote( checking_id=invoice_obj.payment_hash, fee=fees.to(self.unit, round="up"), amount=amount.to(self.unit, round="up"), + kind=kind, ) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 04d7c30b3..23de5cb5b 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -12,7 +12,10 @@ ) from loguru import logger -from ..core.base import Amount, MeltQuote, Unit +from ..core.base import Amount, MeltQuote, PaymentQuoteKind, Unit +from ..core.errors import ( + IncorrectRequestAmountError, +) from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest from ..core.settings import settings @@ -49,6 +52,7 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" supports_mpp = settings.mint_lnd_enable_mpp + supports_amountless: bool = True supports_incoming_payment_stream = True supported_units = {Unit.sat, Unit.msat} supports_description: bool = True @@ -186,23 +190,42 @@ 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 and self.supports_mpp: - 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}" + fee_limit_dict = dict() + fee_limit_dict["fixed_msat"] = f"{fee_limit_msat}" + + post_data = {"payment_request": quote.request, "fee_limit": fee_limit_dict} + #invoice = bolt11.decode(quote.request) + match quote.quote_kind: + case PaymentQuoteKind.PARTIAL: + if self.supports_mpp: + return await self.pay_partial_invoice( + quote, Amount(Unit.sat, quote.amount), fee_limit_msat + ) + else: + error_message = "MPP Payments are not enabled" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + case PaymentQuoteKind.AMOUNTLESS: + if self.supports_amountless: + # amount of the quote converted to msat + post_data["amt_msat"] = Amount(Unit[quote.unit], quote.amount).to(Unit.msat, round="up").amount + else: + error_message = "Amountless payments are not enabled" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + case _: + pass + r = await self.client.post( url="/v1/channels/transactions", - json={"payment_request": quote.request, "fee_limit": lnrpcFeeLimit}, + json=post_data, timeout=None, ) @@ -439,12 +462,26 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - amount_msat = melt_quote.mpp_amount if melt_quote.is_mpp else None - invoice_obj = decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." - if amount_msat is None: + kind = PaymentQuoteKind.REGULAR + # Detect and handle amountless/partial/normal request + amount_msat = 0 + if melt_quote.is_amountless: + # Check that the user isn't doing something cheeky + if invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.amountless.amount_msat # type: ignore + kind = PaymentQuoteKind.AMOUNTLESS + elif melt_quote.is_mpp: + # Check that the user isn't doing something cheeky + if not invoice_obj.amount_msat: + raise IncorrectRequestAmountError() + amount_msat = melt_quote.options.mpp.amount # type: ignore + kind = PaymentQuoteKind.PARTIAL + else: + if not invoice_obj.amount_msat: + raise Exception("request has no amount and is not specified as amountless") amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) @@ -456,4 +493,5 @@ async def get_payment_quote( checking_id=invoice_obj.payment_hash, fee=fees.to(self.unit, round="up"), amount=amount.to(self.unit, round="up"), + kind=kind, ) diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 28f23562c..3e45bfdc0 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -568,8 +568,8 @@ async def store_melt_quote( await (conn or db).execute( f""" INSERT INTO {db.table_with_schema('melt_quotes')} - (quote, method, request, checking_id, unit, amount, fee_reserve, state, paid, created_time, paid_time, fee_paid, proof, outputs, change, expiry) - VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :paid, :created_time, :paid_time, :fee_paid, :proof, :outputs, :change, :expiry) + (quote, method, request, checking_id, unit, amount, fee_reserve, state, paid, created_time, paid_time, fee_paid, proof, outputs, change, expiry, kind) + VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :paid, :created_time, :paid_time, :fee_paid, :proof, :outputs, :change, :expiry, :kind) """, { "quote": quote.quote, @@ -594,6 +594,7 @@ async def store_melt_quote( "expiry": db.to_timestamp( db.timestamp_from_seconds(quote.expiry) or "" ), + "kind": str(quote.quote_kind) }, ) diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 3a8ed654b..774f733a2 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -93,6 +93,7 @@ def create_mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]: if settings.mint_max_melt_bolt11_sat: melt_setting.max_amount = settings.mint_max_melt_bolt11_sat melt_setting.min_amount = 0 + melt_setting.amountless = unit_dict[unit].supports_amountless melt_method_settings.append(melt_setting) mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] = { diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index b04e0369b..3db96bb4e 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -30,6 +30,7 @@ from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( + AmountlessInvoiceNotSupportedError, CashuError, LightningError, LightningPaymentFailedError, @@ -529,8 +530,12 @@ def create_internal_melt_quote( if not mint_quote.checking_id: raise TransactionError("mint quote has no checking id") + + # Kind of payment quote: REGULAR, PARTIAL, AMOUNTLESS if melt_quote.is_mpp: raise TransactionError("internal payments do not support mpp") + elif melt_quote.is_amountless: + raise TransactionError("internal payments cannot have amountless request strings") internal_fee = Amount(unit, 0) # no internal fees amount = Amount(unit, mint_quote.amount) @@ -604,16 +609,15 @@ async def melt_quote( # and therefore respond with internal transaction fees (0 for now) mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) if mint_quote and mint_quote.unit == melt_quote.unit: - # check if the melt quote is partial and error if it is. - # it's just not possible to handle this case - if melt_quote.is_mpp: - raise TransactionError("internal mpp not allowed.") payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) else: # not internal # verify that the backend supports mpp if the quote request has an amount if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: raise TransactionError("backend does not support mpp.") + # verify that the backend supports amountless if the payment string does not have an amount + if melt_quote.is_amountless and not self.backends[method][unit].supports_amountless: + raise AmountlessInvoiceNotSupportedError() # get payment quote by backend payment_quote = await self.backends[method][unit].get_payment_quote( melt_quote=melt_quote @@ -633,8 +637,7 @@ async def melt_quote( # We assume that the request is a bolt11 invoice, this works since we # support only the bol11 method for now. invoice_obj = bolt11.decode(melt_quote.request) - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") + # we set the expiry of this quote to the expiry of the bolt11 invoice expiry = None if invoice_obj.expiry is not None: @@ -651,6 +654,7 @@ async def melt_quote( fee_reserve=payment_quote.fee.to(unit).amount, created_time=int(time.time()), expiry=expiry, + quote_kind=payment_quote.kind, ) await self.crud.store_melt_quote(quote=quote, db=self.db) await self.events.submit(quote) @@ -791,10 +795,7 @@ async def melt_mint_settle_internally( # verify amounts from bolt11 invoice bolt11_request = melt_quote.request - invoice_obj = bolt11.decode(bolt11_request) - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") if not mint_quote.amount == melt_quote.amount: raise TransactionError("amounts do not match") if not bolt11_request == mint_quote.request: diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 8824e0e21..125efcbfb 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -3,7 +3,13 @@ from sqlalchemy import RowMapping -from ..core.base import MeltQuoteState, MintKeyset, MintQuoteState, Proof +from ..core.base import ( + MeltQuoteState, + MintKeyset, + MintQuoteState, + PaymentQuoteKind, + Proof, +) from ..core.crypto.keys import derive_keyset_id, derive_keyset_id_deprecated from ..core.db import Connection, Database from ..core.settings import settings @@ -930,7 +936,6 @@ async def add_missing_id_to_proofs_and_promises(db: Database, conn: Connection): await drop_balance_views(db, conn) await create_balance_views(db, conn) - async def m027_add_balance_to_keysets_and_log_table(db: Database): async with db.connect() as conn: await conn.execute( @@ -968,3 +973,12 @@ async def m027_add_balance_to_keysets_and_log_table(db: Database): ); """ ) + +async def m028_add_payment_quote_kind(db: Database): + async with db.connect() as conn: + await conn.execute( + f""" + ALTER TABLE {db.table_with_schema('melt_quotes')} + ADD COLUMN kind TEXT NOT NULL DEFAULT '{str(PaymentQuoteKind.REGULAR)}' + """ + ) \ No newline at end of file diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index ff097306a..f4df4f002 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -37,6 +37,7 @@ PostMeltQuoteRequest, PostMeltQuoteResponse, PostMeltRequest, + PostMeltRequestOptionAmountless, PostMeltRequestOptionMpp, PostMeltRequestOptions, PostMeltResponse_deprecated, @@ -438,14 +439,23 @@ async def melt_quote( ) -> PostMeltQuoteResponse: """Checks whether the Lightning payment is internal.""" invoice_obj = bolt11.decode(payment_request) - assert invoice_obj.amount_msat, "invoice must have amount" + + assert amount_msat or invoice_obj.amount_msat, ( + "No amount found. Either the invoice has an amount or the amount must be specified." + ) - # add mpp amount for partial melts + # Add melt options: detect whether this should be an amountless option + # or a MPP option melt_options = None if amount_msat: - melt_options = PostMeltRequestOptions( - mpp=PostMeltRequestOptionMpp(amount=amount_msat) - ) + if invoice_obj.amount_msat: + melt_options = PostMeltRequestOptions( + mpp=PostMeltRequestOptionMpp(amount=amount_msat) + ) + else: + melt_options = PostMeltRequestOptions( + amountless=PostMeltRequestOptionAmountless(amount_msat=amount_msat) + ) payload = PostMeltQuoteRequest( unit=unit.name, request=payment_request, options=melt_options @@ -464,7 +474,7 @@ async def melt_quote( ) quote_id = f"deprecated_{uuid.uuid4()}" amount_sat = ( - amount_msat // 1000 if amount_msat else invoice_obj.amount_msat // 1000 + amount_msat // 1000 if amount_msat else (invoice_obj.amount_msat or 0) // 1000 ) return PostMeltQuoteResponse( quote=quote_id, diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 6ffeb97b5..9395af638 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -4,6 +4,7 @@ import time from typing import Callable, Dict, List, Optional, Tuple, Union +import bolt11 from bip32 import BIP32 from loguru import logger @@ -743,8 +744,13 @@ async def melt_quote( """ Fetches a melt quote from the mint and either uses the amount in the invoice or the amount provided. """ - if amount_msat and not self.mint_info.supports_mpp("bolt11", self.unit): - raise Exception("Mint does not support MPP, cannot specify amount.") + if amount_msat: + invoice_obj = bolt11.decode(invoice) + logger.debug(self.mint_info.supports_amountless("bolt11", self.unit)) + if not invoice_obj.amount_msat and not self.mint_info.supports_amountless("bolt11", self.unit): + raise Exception("Mint does not support amountless invoices, cannot pay this invoice.") + if invoice_obj.amount_msat and not self.mint_info.supports_mpp("bolt11", self.unit): + raise Exception("Mint does not support MPP, cannot specify amount.") melt_quote_resp = await super().melt_quote(invoice, self.unit, amount_msat) logger.debug( f"Mint wants {self.unit.str(melt_quote_resp.fee_reserve)} as fee reserve." @@ -833,7 +839,8 @@ async def melt( proofs (List[Proof]): List of proofs to be spent. invoice (str): Lightning invoice to be paid. fee_reserve_sat (int): Amount of fees to be reserved for the payment. - + quote_id (str): ID of the melt quote to pay. + """ # Make sure we're operating on an independent copy of proofs diff --git a/tests/conftest.py b/tests/conftest.py index fdfd10e04..eb1eadd78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from cashu.mint import migrations as migrations_mint from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet SERVER_PORT = 3337 SERVER_ENDPOINT = f"http://localhost:{SERVER_PORT}" @@ -147,3 +148,14 @@ def mint(): yield server server.stop() + +@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 + await wallet.db.engine.dispose() diff --git a/tests/helpers.py b/tests/helpers.py index 879c9c292..c6c0eb472 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -60,6 +60,8 @@ async def get_random_invoice_data(): wallet_class = getattr(wallets_module, settings.mint_backend_bolt11_sat) WALLET = wallet_class() is_fake: bool = WALLET.__class__.__name__ == "FakeWallet" +is_cln: bool = WALLET.__class__.__name__ == "CLNRestWallet" +is_lnd: bool = WALLET.__class__.__name__ == "LndRPCWallet" or WALLET.__class__.__name__ == "LndRestWallet" is_regtest: bool = not is_fake is_deprecated_api_only = settings.debug_mint_only_deprecated is_github_actions = os.getenv("GITHUB_ACTIONS") == "true" diff --git a/tests/mint/test_mint_amountless.py b/tests/mint/test_mint_amountless.py new file mode 100644 index 000000000..59e77c715 --- /dev/null +++ b/tests/mint/test_mint_amountless.py @@ -0,0 +1,57 @@ +import pytest + +from cashu.core.models import ( + PostMeltQuoteRequest, + PostMeltRequestOptionAmountless, + PostMeltRequestOptions, +) +from tests.helpers import assert_err, get_real_invoice, is_cln, is_fake, is_lnd + +invoice_no_amount = "lnbcrt1pnusdsqpp5fcxhgur2eewvsfy52q8xwanrjdglnf7htacp0ldeeakz6j62rj8sdqqcqzzsxqyz5vqsp5qk6l5dwhldy3gqjnr4806mtg22e25ekud4vdlf3p0hk89ud93lxs9qxpqysgq72fmgd460q04mvr5jetw7wys0vnt6ydl58gcg4jdy5jwx5d7epx8tr04et7a5yskwg4le54wrn6u6k0jjfehkc8n5spxkwxum239zxcqpuzakn" +normal_invoice = "lnbcrt10u1pnuakkapp5sgc2whvdcsl53cpmyvpvslrlgc3h9al42xpayw86ykl8nhp2j69sdqqcqzzsxqyz5vqsp52w4vs63hx264tqu3pq2dtkwg6c8eummmjsel8r46adp3ascthgvs9qxpqysgqdjjexqh6acf77gpvkf3usjs0t30w0ru8e2v6pv42j7tcdy5tjxtrkqak8wp6mnrslnrkxqfv4pxjapylnn37m367zsqx4uvzsa79dkqpzdg2ex" + +@pytest.mark.asyncio +@pytest.mark.skipif( + not (is_cln or is_lnd or is_fake), + reason="Only run when amountless is supported", +) +async def test_get_quote_for_amountless_invoice(wallet, ledger): + # Get an amountless invoice + invoice = invoice_no_amount if is_fake else get_real_invoice(0)['payment_request'] + + request = PostMeltQuoteRequest( + unit='sat', + request=invoice, + options=PostMeltRequestOptions( + amountless=PostMeltRequestOptionAmountless( + amount_msat=1000, + ) + ), + ) + + response = await ledger.melt_quote(request) + print(f"amountless quote: {response = }") + assert response.unit == 'sat' + assert response.amount == 1 + +@pytest.mark.asyncio +@pytest.mark.skipif( + not (is_cln or is_lnd or is_fake), + reason="Only run when amountless is supported", +) +async def test_get_amountless_quote_for_non_amountless_invoice(wallet, ledger): + # Get normal invoice + invoice = normal_invoice if is_fake else get_real_invoice(1000)['payment_request'] + + request = PostMeltQuoteRequest( + unit='sat', + request=invoice, + options=PostMeltRequestOptions( + amountless=PostMeltRequestOptionAmountless( + amount_msat=1000, + ) + ), + ) + + await assert_err(ledger.melt_quote(request), "Amount in request does not equal invoice") + \ No newline at end of file diff --git a/tests/wallet/test_wallet_amountless.py b/tests/wallet/test_wallet_amountless.py new file mode 100644 index 000000000..fe46615bb --- /dev/null +++ b/tests/wallet/test_wallet_amountless.py @@ -0,0 +1,83 @@ +import pytest +import pytest_asyncio + +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT, settings +from tests.helpers import ( + assert_err, + get_real_invoice, + is_cln, + is_fake, + is_lnd, + pay_if_regtest, +) + +invoice_no_amount = "lnbcrt1pnusdsqpp5fcxhgur2eewvsfy52q8xwanrjdglnf7htacp0ldeeakz6j62rj8sdqqcqzzsxqyz5vqsp5qk6l5dwhldy3gqjnr4806mtg22e25ekud4vdlf3p0hk89ud93lxs9qxpqysgq72fmgd460q04mvr5jetw7wys0vnt6ydl58gcg4jdy5jwx5d7epx8tr04et7a5yskwg4le54wrn6u6k0jjfehkc8n5spxkwxum239zxcqpuzakn" +normal_invoice = "lnbcrt10u1pnuakkapp5sgc2whvdcsl53cpmyvpvslrlgc3h9al42xpayw86ykl8nhp2j69sdqqcqzzsxqyz5vqsp52w4vs63hx264tqu3pq2dtkwg6c8eummmjsel8r46adp3ascthgvs9qxpqysgqdjjexqh6acf77gpvkf3usjs0t30w0ru8e2v6pv42j7tcdy5tjxtrkqak8wp6mnrslnrkxqfv4pxjapylnn37m367zsqx4uvzsa79dkqpzdg2ex" + +@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( + not (is_cln or is_lnd or is_fake), + reason="only run this test on fake, lnd or cln" +) +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_amountless_bolt11_invoice(wallet: Wallet): + # make sure wallet knows the backend supports mpp + assert wallet.mint_info.supports_amountless("bolt11", wallet.unit) + + # top up wallet + topup_mint_quote = await wallet.request_mint(128) + + await pay_if_regtest(topup_mint_quote.request) + proofs = await wallet.mint(128, quote_id=topup_mint_quote.quote) + assert wallet.balance == 128 + + amountless_invoice = invoice_no_amount if is_fake else get_real_invoice(0)['payment_request'] + + melt_quote = await wallet.melt_quote(amountless_invoice, 100*1000) + assert melt_quote.amount == 100 + + result = await wallet.melt(proofs, amountless_invoice, melt_quote.fee_reserve, melt_quote.quote) + assert result.state == "PAID" or result.state == "PENDING" + +@pytest.mark.asyncio +@pytest.mark.skipif( + not (is_cln or is_lnd or is_fake) or settings.debug_mint_only_deprecated, + reason="only run for backends where amountless is supported" +) +async def test_pay_amountless_invoice_without_specified_amount(wallet: Wallet): + # make sure wallet knows the backend supports mpp + assert wallet.mint_info.supports_amountless("bolt11", wallet.unit) + + # We get an amountless invoice + invoice = invoice_no_amount if is_fake else get_real_invoice(0)['payment_request'] + + # This should not succeed. + assert_err(wallet.melt_quote(invoice), "No amount found. Either the invoice has an amount or the amount must be specified.") + +@pytest.mark.asyncio +@pytest.mark.skipif( + is_cln or is_lnd or is_fake, + reason="only run for backends where amountless is not supported" +) +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_unsupported_amountless_bolt11_invoice(wallet: Wallet): + amountless_invoice = invoice_no_amount if is_fake else get_real_invoice(0)['payment_request'] + assert_err(wallet.melt_quote(amountless_invoice, 100*1000), "Mint does not support amountless invoices, cannot pay this invoice.") + diff --git a/tests/wallet/test_wallet_cli.py b/tests/wallet/test_wallet_cli.py index 25fdf2ef9..ed6e9160d 100644 --- a/tests/wallet/test_wallet_cli.py +++ b/tests/wallet/test_wallet_cli.py @@ -17,6 +17,7 @@ pay_if_regtest, ) +invoice_no_amount = "lnbcrt1pnusdsqpp5fcxhgur2eewvsfy52q8xwanrjdglnf7htacp0ldeeakz6j62rj8sdqqcqzzsxqyz5vqsp5qk6l5dwhldy3gqjnr4806mtg22e25ekud4vdlf3p0hk89ud93lxs9qxpqysgq72fmgd460q04mvr5jetw7wys0vnt6ydl58gcg4jdy5jwx5d7epx8tr04et7a5yskwg4le54wrn6u6k0jjfehkc8n5spxkwxum239zxcqpuzakn" @pytest.fixture(autouse=True, scope="session") def cli_prefix(): @@ -585,3 +586,14 @@ def test_send_with_lock(mint, cli_prefix): assert "cashuB" in token_str, "output does not have a token" token = TokenV4.deserialize(token_str).to_tokenv3() assert pubkey in token.token[0].proofs[0].secret + +def test_pay_amountless_invoice(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "pay", invoice_no_amount if is_fake else get_real_invoice(0)["payment_request"], "10"] + ) + if result.exception: + assert str(result.exception) == "Mint does not support amountless invoices, cannot pay this invoice." + else: + assert "Invoice paid" in result.output or "Invoice pending" in result.output \ No newline at end of file diff --git a/tests/wallet/test_wallet_regtest_mpp.py b/tests/wallet/test_wallet_regtest_mpp.py index 480f3f4ed..2b463a733 100644 --- a/tests/wallet/test_wallet_regtest_mpp.py +++ b/tests/wallet/test_wallet_regtest_mpp.py @@ -161,7 +161,7 @@ async def test_regtest_internal_mpp_melt_quotes(wallet: Wallet, ledger: Ledger): # try and create a multi-part melt quote await assert_err( - wallet.melt_quote(mint_quote.request, 100 * 1000), "internal mpp not allowed" + wallet.melt_quote(mint_quote.request, 100*1000), "internal payments do not support mpp" )