diff --git a/cashu/core/models.py b/cashu/core/models.py index e96f4ea06..369f81390 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -279,6 +279,7 @@ class PostMeltRequest(BaseModel): outputs: Union[List[BlindedMessage], None] = Field( None, max_length=settings.mint_max_request_length ) + prefer_async: Optional[bool] = Field(default=None) class PostMeltResponse_deprecated(BaseModel): diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fcf756a94..717b4ab88 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -399,11 +399,11 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: if status.settled: # change state to paid in one transaction, it could have been marked paid # by the invoice listener in the mean time - async with self.db.get_connection( - lock_table="mint_quotes", - lock_select_statement="quote = :quote", - lock_parameters={"quote": quote_id}, - ) as conn: + async with self.db.get_connection( + lock_table="mint_quotes", + lock_select_statement="quote = :quote", + lock_parameters={"quote": quote_id}, + ) as conn: quote = await self.crud.get_mint_quote( quote_id=quote_id, db=self.db, conn=conn ) @@ -821,6 +821,36 @@ async def melt_mint_settle_internally( return melt_quote + async def async_melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice asynchronously. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Returns: + PostMeltQuoteResponse: Melt quote response with pending state. + """ + # get melt quote + melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote: + raise TransactionError("melt quote not found") + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") + + # Launch actual melt task + asyncio.create_task(self.melt(proofs=proofs, quote=quote, outputs=outputs)) + + melt_quote.state = MeltQuoteState.pending + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + async def melt( self, *, @@ -862,8 +892,8 @@ async def melt( # _verify_outputs checks if all outputs have the same unit await self._verify_outputs( outputs, skip_amount_check=True, expected_unit=unit - ) - + ) + # verify SIG_ALL signatures message_to_sign = ( "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote @@ -1049,15 +1079,15 @@ async def swap( await self.db_write._verify_spent_proofs_and_set_pending( proofs, keysets=self.keysets ) - try: - Ys = [p.Y for p in proofs] - lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} - ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) - async with self.db.get_connection( - lock_table="proofs_pending", - lock_select_statement=f"y IN ({ys_list})", - lock_parameters=lock_parameters, - ) as conn: + try: + Ys = [p.Y for p in proofs] + lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} + ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) + async with self.db.get_connection( + lock_table="proofs_pending", + lock_select_statement=f"y IN ({ys_list})", + lock_parameters=lock_parameters, + ) as conn: await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) # Calculate fees diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 55f0439eb..059b267a0 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -333,9 +333,14 @@ async def melt(request: Request, payload: PostMeltRequest) -> PostMeltQuoteRespo Requests tokens to be destroyed and sent out via Lightning. """ logger.trace(f"> POST /v1/melt/bolt11: {payload}") - resp = await ledger.melt( - proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs - ) + if payload.prefer_async: + resp = await ledger.async_melt( + proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs + ) + else: + resp = await ledger.melt( + proofs=payload.inputs, quote=payload.quote, outputs=payload.outputs + ) logger.trace(f"< POST /v1/melt/bolt11: {resp}") return resp diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 2c766e490..068279977 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -261,11 +261,24 @@ async def cli( @click.option( "--yes", "-y", default=False, is_flag=True, help="Skip confirmation.", type=bool ) +@click.option( + "--async", + "-a", + "prefer_async", + default=False, + is_flag=True, + help="Pay asynchronously.", + type=bool, +) @click.pass_context @coro @init_auth_wallet async def pay( - ctx: Context, invoice: str, amount: Optional[int] = None, yes: bool = False + ctx: Context, + invoice: str, + amount: Optional[int] = None, + yes: bool = False, + prefer_async: bool = False, ): wallet: Wallet = ctx.obj["WALLET"] await wallet.load_mint() @@ -422,7 +435,11 @@ async def pay( try: melt_response = await wallet.melt( - send_proofs, invoice, quote.fee_reserve, quote.quote + send_proofs, + invoice, + quote.fee_reserve, + quote.quote, + prefer_async=prefer_async, ) except Exception as e: print(f" Error paying invoice: {e}") diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index 6c7a9f2d5..27ed02672 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -486,12 +486,15 @@ async def melt( quote: str, proofs: List[Proof], outputs: Optional[List[BlindedMessage]], + prefer_async: Optional[bool] = None, ) -> PostMeltQuoteResponse: """ Accepts proofs and a lightning invoice to pay in exchange. """ - payload = PostMeltRequest(quote=quote, inputs=proofs, outputs=outputs) + payload = PostMeltRequest( + quote=quote, inputs=proofs, outputs=outputs, prefer_async=prefer_async + ) def _meltrequest_include_fields( proofs: List[Proof], outputs: List[BlindedMessage] diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 1b63b8eab..f788935b5 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -828,7 +828,12 @@ async def get_melt_quote(self, quote: str) -> Optional[MeltQuote]: return melt_quote async def melt( - self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str + self, + proofs: List[Proof], + invoice: str, + fee_reserve_sat: int, + quote_id: str, + prefer_async: Optional[bool] = None, ) -> PostMeltQuoteResponse: """Pays a lightning invoice and returns the status of the payment. @@ -836,7 +841,7 @@ 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. - + prefer_async (Optional[bool]): Whether to pay asynchronously. """ # Make sure we're operating on an independent copy of proofs @@ -858,7 +863,9 @@ async def melt( await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id) proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id) try: - melt_quote_resp = await super().melt(quote_id, proofs, change_outputs) + melt_quote_resp = await super().melt( + quote_id, proofs, change_outputs, prefer_async=prefer_async + ) except Exception as e: logger.debug(f"Mint error: {e}") # remove the melt_id in proofs and set reserved to False diff --git a/tests/mint/test_async_melt.py b/tests/mint/test_async_melt.py new file mode 100644 index 000000000..a7af55173 --- /dev/null +++ b/tests/mint/test_async_melt.py @@ -0,0 +1,72 @@ +import asyncio + +import httpx +import pytest + +from cashu.core.base import MeltQuoteState +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import is_fake, pay_if_regtest + +BASE_URL = "http://localhost:3337" + +@pytest.mark.asyncio +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +async def test_async_melt(ledger): + # This test uses direct API calls because the wallet fixture issue + # for async melt is complex in this setup. + from cashu.wallet.wallet import Wallet + wallet = await Wallet.with_db(url=SERVER_ENDPOINT, db="test_data/wallet_async_melt", name="wallet_async_melt") + await wallet.load_mint() + + # Setup: get some funds + mint_quote = await wallet.request_mint(64) + await pay_if_regtest(mint_quote.request) + await wallet.mint(64, quote_id=mint_quote.quote) + assert wallet.balance == 64 + + # Create invoice to melt to + mint_quote = await wallet.request_mint(64) + invoice_payment_request = mint_quote.request + + # Get quote + quote = await wallet.melt_quote(invoice_payment_request) + inputs_payload = [p.to_dict() for p in wallet.proofs] + + # Melt with prefer_async=True + response = httpx.post( + f"{BASE_URL}/v1/melt/bolt11", + json={ + "quote": quote.quote, + "inputs": inputs_payload, + "prefer_async": True, + }, + timeout=None, + ) + assert response.status_code == 200 + result = response.json() + assert result["state"] == MeltQuoteState.pending.value + + # Wait a bit for the background task to complete + await asyncio.sleep(2) + + # Verify it became paid + response = httpx.get(f"{BASE_URL}/v1/melt/quote/bolt11/{quote.quote}") + assert response.status_code == 200 + result = response.json() + assert result["state"] == MeltQuoteState.paid.value + +@pytest.mark.asyncio +@pytest.mark.skipif(not is_fake, reason="only on fakewallet") +async def test_async_melt_nonexistent_quote(ledger): + # Melt with prefer_async=True and a fake quote + response = httpx.post( + f"{BASE_URL}/v1/melt/bolt11", + json={ + "quote": "nonexistent_quote_id", + "inputs": [], + "prefer_async": True, + }, + timeout=None, + ) + # Expect failure (404 or 400 is fine) + assert response.status_code != 200