bounty #136 — M7: the wallet half. One wallet class now serves the xmr rail too. Before: cashu/wallet/v1_api.py hardcoded the wire method 'bolt11' in the mint-quote, mint, melt-quote and melt URLs, and decoded the payment request as a bolt11 invoice, so a wallet with unit='xmr' could not talk to a Monero mint at all (the mint answers: 400 no support for method 'bolt11' with unit 'xmr'). After: one mapping unit -> wire method, used in every URL, in every stored quote, and in the melt-request handling. Files touched by this patch: cashu/core/base.py, cashu/wallet/v1_api.py, cashu/wallet/wallet.py Proof: /mnt/c/Users/choka/AppData/Local/hermes/profiles/entrepreneur/workspace/w42_m7_e2e.py, 17/17 checks, transcript in m7_e2e.out ====================================================================== --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -328,7 +328,7 @@ def from_resp_wallet(cls, melt_quote_resp, mint: str): return cls( quote=melt_quote_resp.quote, - method=Method.bolt11.name, + method=unit_to_method(melt_quote_resp.unit), request=melt_quote_resp.request, checking_id="", unit=melt_quote_resp.unit, @@ -513,7 +513,7 @@ return cls( quote=mint_quote_resp.quote, - method=Method.bolt11.name, + method=unit_to_method(mint_quote_resp.unit), request=mint_quote_resp.request, checking_id="", unit=mint_quote_resp.unit, @@ -838,6 +838,17 @@ xmr = 1 +def unit_to_method(unit: Union["Unit", str]) -> str: + """NUT-04/05/06 wire method that serves `unit`. + + bitcoin (`sat`) is served by the `bolt11` method, monero (`xmr`) by the `xmr` method. + The wallet uses this everywhere a URL path or a stored quote needs the method, so that + one wallet class serves every rail a unit-generic mint advertises in /v1/info. + """ + name = unit.name if isinstance(unit, Unit) else str(unit) + return Method.xmr.name if name == Unit.xmr.name else Method.bolt11.name + + class WalletKeyset: """ Contains the keyset from the wallets's perspective. diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index 18089b5..2633541 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -11,9 +11,11 @@ from ..core.base import ( AuthProof, BlindedMessage, BlindedSignature, + Method, Proof, Unit, WalletKeyset, + unit_to_method, ) from ..core.crypto.keys import is_supported_keyset_version from ..core.crypto.secp import PublicKey @@ -371,18 +373,19 @@ class LedgerAPI(SupportsAuth): Raises: Exception: If the mint request fails """ - logger.trace("Requesting mint: POST /v1/mint/bolt11") + method = unit_to_method(unit) + logger.trace(f"Requesting mint quote: POST /v1/mint/quote/{method}") payload = PostMintQuoteRequest( unit=unit.name, amount=amount, description=memo, pubkey=pubkey ) resp = await self._request( POST, - "mint/quote/bolt11", + f"mint/quote/{method}", json=payload.model_dump(), ) # if mint doesn't support v1 endpoint, fail explicitly - self.raise_on_unsupported_version(resp, "POST /v1/mint/quote/bolt11") + self.raise_on_unsupported_version(resp, f"POST /v1/mint/quote/{method}") return_dict = resp.json() return PostMintQuoteResponse.model_validate(return_dict) @@ -398,8 +401,9 @@ class LedgerAPI(SupportsAuth): Returns: PostMintQuoteResponse: Mint Quote Response """ - resp = await self._request(GET, f"mint/quote/bolt11/{quote}") - self.raise_on_unsupported_version(resp, f"GET /v1/mint/quote/bolt11/{quote}") + method = unit_to_method(self.unit) + resp = await self._request(GET, f"mint/quote/{method}/{quote}") + self.raise_on_unsupported_version(resp, f"GET /v1/mint/quote/{method}/{quote}") return_dict = resp.json() return PostMintQuoteResponse.model_validate(return_dict) @@ -424,7 +428,8 @@ class LedgerAPI(SupportsAuth): outputs_payload = PostMintRequest( outputs=outputs, quote=quote, signature=signature ) - logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11") + method = unit_to_method(self.unit) + logger.trace(f"Checking payment. POST /v1/mint/{method}") def _mintrequest_include_fields(outputs: List[BlindedMessage]): """strips away fields from the model that aren't necessary for the /mint""" @@ -442,14 +447,14 @@ class LedgerAPI(SupportsAuth): ) # type: ignore resp = await self._request( POST, - "mint/bolt11", + f"mint/{method}", json=payload, # type: ignore ) # fail explicitly if mint doesn't support v1 mint endpoint - self.raise_on_unsupported_version(resp, f"POST /v1/mint/{quote}") + self.raise_on_unsupported_version(resp, f"POST /v1/mint/{method}") response_dict = resp.json() - logger.trace(f"Lightning invoice checked. POST {self.api_prefix}/mint/bolt11") + logger.trace(f"Payment checked. POST {self.api_prefix}/mint/{method}") promises = PostMintResponse.model_validate(response_dict).signatures return promises @@ -458,16 +463,23 @@ class LedgerAPI(SupportsAuth): async def melt_quote( self, payment_request: str, unit: Unit, amount_msat: 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" + """Checks whether the payment request is payable and asks the mint for a quote. - # add mpp amount for partial melts + On the bolt11 rail `payment_request` is a Lightning invoice and mpp is available. + On the xmr rail it is a Monero address (optionally `addr?amount=N`) and there is no + invoice to decode: the amount comes from the request itself. + """ + method = unit_to_method(unit) melt_options = None - if amount_msat: - melt_options = PostMeltRequestOptions( - mpp=PostMeltRequestOptionMpp(amount=amount_msat) - ) + if method == Method.bolt11.name: + invoice_obj = bolt11.decode(payment_request) + assert invoice_obj.amount_msat, "invoice must have amount" + + # add mpp amount for partial melts + if amount_msat: + melt_options = PostMeltRequestOptions( + mpp=PostMeltRequestOptionMpp(amount=amount_msat) + ) payload = PostMeltQuoteRequest( unit=unit.name, request=payment_request, options=melt_options @@ -475,12 +487,12 @@ class LedgerAPI(SupportsAuth): resp = await self._request( POST, - "melt/quote/bolt11", + f"melt/quote/{method}", json=payload.model_dump(), ) # if mint doesn't support v1 melt-quote endpoint, fail explicitly - self.raise_on_unsupported_version(resp, "POST /v1/melt/quote") + self.raise_on_unsupported_version(resp, f"POST /v1/melt/quote/{method}") return_dict = resp.json() return PostMeltQuoteResponse.model_validate(return_dict) @@ -495,7 +507,8 @@ class LedgerAPI(SupportsAuth): Returns: PostMeltQuoteResponse: Melt Quote Response """ - resp = await self._request(GET, f"melt/quote/bolt11/{quote}") + method = unit_to_method(self.unit) + resp = await self._request(GET, f"melt/quote/{method}/{quote}") self.raise_on_error_request(resp) return_dict = resp.json() return PostMeltQuoteResponse.model_validate(return_dict) @@ -533,7 +546,7 @@ class LedgerAPI(SupportsAuth): resp = await self._request( POST, - "melt/bolt11", + f"melt/{unit_to_method(self.unit)}", json=payload.model_dump( include=_meltrequest_include_fields(proofs, outputs) ), # type: ignore diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 282635d..fd63895 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -21,6 +21,7 @@ from ..core.base import ( Unit, WalletKeyset, WalletMint, + unit_to_method, ) from ..core.crypto import b_dhke from ..core.crypto.keys import is_supported_keyset_version @@ -830,7 +831,7 @@ class Wallet( 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( - Method.bolt11.name, self.unit + unit_to_method(self.unit), self.unit ): raise Exception("Mint does not support MPP, cannot specify amount.") melt_quote_resp = await super().melt_quote(invoice, self.unit, amount_msat)