From 61cf7def24fa7104d2d195f0cb5105ee8687773f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 22 May 2024 22:52:26 +0200 Subject: [PATCH] Multinut LND (#492) * amount in melt request * apply fee limit * more error handling * wip: signal flag in /info * clean up multinut * decode mypy error lndrest * fix test * fix tests * signal feature and blindmessages_deprecated * setting * fix blindedsignature method * fix tests * mint info file * test mpp with lnd regtest * nuts optionsl mint info * try to enable mpp with lnd * test mpp with third payment --- .github/workflows/regtest.yml | 1 + cashu/core/base.py | 86 ++++++++++++------ cashu/core/settings.py | 9 +- cashu/lightning/base.py | 10 ++- cashu/lightning/blink.py | 7 +- cashu/lightning/corelightningrest.py | 8 +- cashu/lightning/fake.py | 8 +- cashu/lightning/lnbits.py | 8 +- cashu/lightning/lndrest.py | 124 +++++++++++++++++++++++++- cashu/lightning/strike.py | 7 +- cashu/mint/ledger.py | 7 +- cashu/mint/router.py | 18 +++- cashu/mint/router_deprecated.py | 20 +++-- cashu/wallet/api/router.py | 4 +- cashu/wallet/cli/cli.py | 17 ++-- cashu/wallet/lightning/lightning.py | 6 +- cashu/wallet/mint_info.py | 38 ++++++++ cashu/wallet/wallet.py | 47 ++++++---- cashu/wallet/wallet_deprecated.py | 2 +- tests/conftest.py | 1 + tests/test_mint_init.py | 12 +-- tests/test_mint_lightning_blink.py | 27 ++++-- tests/test_mint_operations.py | 2 +- tests/test_mint_regtest.py | 4 +- tests/test_wallet.py | 4 +- tests/test_wallet_regtest.py | 8 +- tests/test_wallet_regtest_mpp.py | 127 +++++++++++++++++++++++++++ 27 files changed, 502 insertions(+), 110 deletions(-) create mode 100644 cashu/wallet/mint_info.py create mode 100644 tests/test_wallet_regtest_mpp.py 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 diff --git a/cashu/core/base.py b/cashu/core/base.py index 67326728..204d3ea0 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -161,13 +161,18 @@ 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) @@ -177,6 +182,28 @@ def p2pksigs(self) -> List[str]: 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) + + @property + def p2pksigs(self) -> List[str]: + assert self.witness, "Witness missing in output" + 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` @@ -314,7 +341,13 @@ 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 Nut15MppSupport(BaseModel): + method: str + unit: str + mpp: bool class GetInfoResponse_deprecated(BaseModel): @@ -329,19 +362,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 ------- @@ -425,6 +445,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): @@ -551,6 +572,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] = [] @@ -656,6 +683,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 @@ -670,10 +698,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." @@ -728,6 +765,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, *, @@ -808,12 +847,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( @@ -824,8 +857,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( @@ -833,13 +865,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 d010a76f..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.", ) @@ -171,6 +177,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: bool = Field(default=False) class CoreLightningRestFundingSource(MintSettings): diff --git a/cashu/lightning/base.py b/cashu/lightning/base.py index 083554fd..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): @@ -62,6 +67,7 @@ def __str__(self) -> str: class LightningBackend(ABC): + supports_mpp: bool = False supported_units: set[Unit] unit: Unit @@ -107,7 +113,7 @@ def get_payment_status( @abstractmethod async def get_payment_quote( self, - bolt11: str, + 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 6cbb7d10..d2fbbf31 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 ( @@ -316,8 +316,10 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: ) await asyncio.sleep(0.02) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: - invoice_obj = decode(bolt11) + 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." 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 564c000d..5a8bcdcf 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,8 +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: - invoice_obj = decode(bolt11) + 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: diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 174236ef..35894a30 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 ( @@ -167,8 +167,10 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: preimage=data["preimage"], ) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: - invoice_obj = decode(bolt11) + 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." 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 3c2e75ad..04d6bc39 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -4,13 +4,15 @@ import json from typing import AsyncGenerator, Dict, Optional +import bolt11 import httpx from bolt11 import ( + TagChar, decode, ) 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 ( @@ -27,6 +29,7 @@ class LndRestWallet(LightningBackend): """https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" + supports_mpp = settings.mint_lnd_enable_mpp supported_units = set([Unit.sat, Unit.msat]) unit = Unit.sat @@ -70,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 feature") async def status(self) -> StatusResponse: try: @@ -148,6 +153,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 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}" @@ -180,6 +195,91 @@ 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}", + json={"fee_limit": lnrpcFeeLimit}, + timeout=None, + ) + + 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 = { + "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() + 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"]) + 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}") @@ -270,13 +370,29 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: ) await asyncio.sleep(5) - async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: - invoice_obj = decode(bolt11) + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + # get amount from melt_quote or from bolt11 + amount = ( + Amount(Unit[melt_quote.unit], melt_quote.amount) + if melt_quote.amount + else None + ) + + invoice_obj = decode(melt_quote.request) 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.to(self.unit, round="up"), 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 55fafd6e..7d17e2c9 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -575,9 +575,10 @@ async def melt_quote( ) else: # not internal, get payment quote by backend - payment_quote = await self.backends[method][unit].get_payment_quote(request) - if not payment_quote.checking_id: - raise TransactionError("quote has no checking id") + payment_quote = await self.backends[method][unit].get_payment_quote( + 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 if not payment_quote.amount.unit == unit: raise TransactionError("payment quote amount units do not match") diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 85e8b063..6011e09d 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -62,7 +62,8 @@ async def info() -> GetInfoResponse: supported_dict = dict(supported=True) - mint_features: Dict[int, Dict[str, Any]] = { + supported_dict = dict(supported=True) + mint_features: Dict[int, Any] = { 4: dict( methods=method_settings[4], disabled=settings.mint_peg_out_only, @@ -79,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..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, @@ -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 @@ -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) 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 ebad528b..cd4accf7 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -177,16 +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.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] = 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) + quote = await wallet.request_melt(invoice, amount) logger.debug(f"Quote: {quote}") total_amount = quote.amount + quote.fee_reserve if not yes: @@ -209,7 +216,7 @@ async def pay(ctx: Context, invoice: str, 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: @@ -334,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/mint_info.py b/cashu/wallet/mint_info.py new file mode 100644 index 00000000..30ec3f6d --- /dev/null +++ b/cashu/wallet/mint_info.py @@ -0,0 +1,38 @@ +from typing import Any, Dict, List, Optional + +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: Optional[Dict[int, Any]] + + 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, 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 + + 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 58ed544a..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 @@ -518,11 +520,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(), @@ -536,7 +542,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, @@ -582,7 +588,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( @@ -987,7 +993,21 @@ async def split( send_proofs = new_proofs[len(frst_outputs) :] return keep_proofs, send_proofs - async def pay_lightning( + 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. + """ + 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." + ) + 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. @@ -1520,17 +1540,6 @@ async def invalidate( # ---------- TRANSACTION HELPERS ---------- - async def get_pay_amount_with_fees(self, invoice: str): - """ - Decodes the amount from a Lightning invoice and returns the - total amount (amount+fees) to be paid. - """ - melt_quote = await self.melt_quote(invoice) - 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 568538da..f300b1b4 100644 --- a/cashu/wallet/wallet_deprecated.py +++ b/cashu/wallet/wallet_deprecated.py @@ -298,7 +298,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/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_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_lightning_blink.py b/tests/test_mint_lightning_blink.py index decabefa..040e5374 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 # type: ignore @@ -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..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_pay_amount_with_fees(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( 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.py b/tests/test_wallet.py index 8e66f948..32847edb 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -271,7 +271,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: @@ -285,7 +285,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, 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, diff --git a/tests/test_wallet_regtest_mpp.py b/tests/test_wallet_regtest_mpp.py new file mode 100644 index 00000000..76280132 --- /dev/null +++ b/tests/test_wallet_regtest_mpp.py @@ -0,0 +1,127 @@ +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 three 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 + + 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"] + + 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=amount) + 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) + + # 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