Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 46 additions & 16 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 19 additions & 2 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down
5 changes: 4 additions & 1 deletion cashu/wallet/v1_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 10 additions & 3 deletions cashu/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,15 +828,20 @@ 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.

Args:
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
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/mint/test_async_melt.py
Original file line number Diff line number Diff line change
@@ -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
Loading