diff --git a/cashu/core/base.py b/cashu/core/base.py index 51aaf2a98..bea6ef720 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -469,23 +469,134 @@ def from_row(cls, row: Row): ) @classmethod - def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str): + def from_resp_wallet( + cls, + mint_quote_resp, + mint: str, + amount: int, + unit: str, + paid_time: Optional[int] = None, + issued_time: Optional[int] = None, + ): + # Prefer amount_paid/amount_issued if present + amount_paid = mint_quote_resp.amount_paid + amount_issued = mint_quote_resp.amount_issued + + if amount_paid is not None and amount_issued is not None: + if amount_paid == 0 and amount_issued == 0: + if mint_quote_resp.state == "PENDING": + state = MintQuoteState.pending + else: + state = MintQuoteState.unpaid + elif amount_paid > amount_issued: + state = MintQuoteState.paid + elif amount_paid == amount_issued and amount_issued > 0: + state = MintQuoteState.issued + else: + state = MintQuoteState.unpaid + elif mint_quote_resp.state: + state = MintQuoteState(mint_quote_resp.state) + else: + state = MintQuoteState.unpaid + + if paid_time is None and mint_quote_resp.updated_at is not None: + if state in [MintQuoteState.paid, MintQuoteState.issued]: + paid_time = mint_quote_resp.updated_at + + if issued_time is None and mint_quote_resp.updated_at is not None: + if state == MintQuoteState.issued: + issued_time = mint_quote_resp.updated_at + return cls( quote=mint_quote_resp.quote, method="bolt11", request=mint_quote_resp.request, checking_id="", - unit=mint_quote_resp.unit - or unit, # BACKWARDS COMPATIBILITY mint response < 0.17.0 - amount=mint_quote_resp.amount - or amount, # BACKWARDS COMPATIBILITY mint response < 0.17.0 - state=MintQuoteState(mint_quote_resp.state), + unit=mint_quote_resp.unit or unit, # BACKWARDS COMPATIBILITY mint response < 0.17.0 + amount=mint_quote_resp.amount or amount, # BACKWARDS COMPATIBILITY mint response < 0.17.0 + state=state, mint=mint, expiry=mint_quote_resp.expiry, created_time=int(time.time()), + paid_time=paid_time, + issued_time=issued_time, pubkey=mint_quote_resp.pubkey, ) + @classmethod + def check_stale_and_from_resp_wallet( + cls, + mint_quote_resp, + mint: str, + mint_quote_local: Optional["MintQuote"] = None, + default_amount: int = 0, + default_unit: str = "sat", + ) -> "MintQuote": + # Check if the response is stale according to the spec + is_stale = False + if mint_quote_local: + resp_updated_at = mint_quote_resp.updated_at + resp_amount_paid = mint_quote_resp.amount_paid + resp_amount_issued = mint_quote_resp.amount_issued + local_updated_at = ( + mint_quote_local.issued_time or mint_quote_local.paid_time + ) + + if ( + (resp_updated_at is not None and local_updated_at is not None and resp_updated_at < local_updated_at) + or (resp_amount_paid is not None and resp_amount_paid < mint_quote_local.amount_paid) + or (resp_amount_issued is not None and resp_amount_issued < mint_quote_local.amount_issued) + ): + is_stale = True + + if is_stale and mint_quote_local: + return mint_quote_local + + amount = ( + (mint_quote_resp.amount or mint_quote_local.amount) + if mint_quote_local + else default_amount + ) + unit = ( + (mint_quote_resp.unit or mint_quote_local.unit) + if mint_quote_local + else default_unit + ) + + paid_time = mint_quote_local.paid_time if mint_quote_local else None + issued_time = mint_quote_local.issued_time if mint_quote_local else None + + return cls.from_resp_wallet( + mint_quote_resp, + mint=mint, + amount=amount, + unit=unit, + paid_time=paid_time, + issued_time=issued_time, + ) + + @property + def amount_paid(self) -> int: + if self.state in [MintQuoteState.paid, MintQuoteState.issued]: + return self.amount + return 0 + + @property + def amount_issued(self) -> int: + if self.state == MintQuoteState.issued: + return self.amount + return 0 + + @property + def updated_at(self) -> int: + if self.issued_time is not None: + return self.issued_time + if self.paid_time is not None: + return self.paid_time + if self.created_time is not None: + return self.created_time + return 0 + @property def identifier(self) -> str: """Implementation of the abstract method from LedgerEventManager""" diff --git a/cashu/core/models/mint_quote.py b/cashu/core/models/mint_quote.py index 36cd4f77a..1ae2d0318 100644 --- a/cashu/core/models/mint_quote.py +++ b/cashu/core/models/mint_quote.py @@ -38,8 +38,11 @@ class PostMintQuoteResponse(BaseModel): unit: Optional[ str ] # output unit (optional for BACKWARDS COMPAT mint response < 0.17.0) - state: Optional[str] # state of the quote (optional for backwards compat) - expiry: Optional[int] # expiry of the quote + amount_paid: Optional[int] = None + amount_issued: Optional[int] = None + updated_at: Optional[int] = None + state: Optional[str] = None # state of the quote (optional for backwards compat) + expiry: Optional[int] = None # expiry of the quote pubkey: Optional[str] = None # NUT-20 quote lock pubkey @classmethod @@ -47,4 +50,7 @@ def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse": to_dict = mint_quote.model_dump() # turn state into string to_dict["state"] = mint_quote.state.value + to_dict["amount_paid"] = mint_quote.amount_paid + to_dict["amount_issued"] = mint_quote.amount_issued + to_dict["updated_at"] = mint_quote.updated_at return cls.model_validate(to_dict) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 8e5b68caf..f2ffcc041 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -178,9 +178,12 @@ async def mint_quote( request=quote.request, amount=quote.amount, unit=quote.unit, - state=quote.state.value, + state=str(quote.state.value), expiry=quote.expiry, pubkey=quote.pubkey, + amount_paid=quote.amount_paid, + amount_issued=quote.amount_issued, + updated_at=quote.updated_at, ) logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}") return resp @@ -204,9 +207,12 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: request=mint_quote.request, amount=mint_quote.amount, unit=mint_quote.unit, - state=mint_quote.state.value, + state=str(mint_quote.state.value), expiry=mint_quote.expiry, pubkey=mint_quote.pubkey, + amount_paid=mint_quote.amount_paid, + amount_issued=mint_quote.amount_issued, + updated_at=mint_quote.updated_at, ) logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}") return resp @@ -231,9 +237,12 @@ async def mint_quote_check( request=quote.request, amount=quote.amount, unit=quote.unit, - state=quote.state.value, + state=str(quote.state.value), expiry=quote.expiry, pubkey=quote.pubkey, + amount_paid=quote.amount_paid, + amount_issued=quote.amount_issued, + updated_at=quote.updated_at, ) for quote in quotes ] diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 5be1e7b4d..5a07c1fff 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -569,25 +569,27 @@ async def get_mint_quote( """ mint_quote_response = await super().get_mint_quote(quote_id) mint_quote_local = await get_bolt11_mint_quote(db=self.db, quote=quote_id) - mint_quote = MintQuote.from_resp_wallet( - mint_quote_response, + + mint_quote = MintQuote.check_stale_and_from_resp_wallet( + mint_quote_resp=mint_quote_response, mint=self.url, - amount=( - mint_quote_response.amount or mint_quote_local.amount - if mint_quote_local - else 0 # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), - unit=( - mint_quote_response.unit or mint_quote_local.unit - if mint_quote_local - else self.unit.name # BACKWARD COMPATIBILITY mint response < 0.17.0 - ), + mint_quote_local=mint_quote_local, + default_amount=0, + default_unit=self.unit.name, ) + if mint_quote_local and mint_quote_local.privkey: mint_quote.privkey = mint_quote_local.privkey if not mint_quote_local: await store_bolt11_mint_quote(db=self.db, quote=mint_quote) + elif mint_quote_local.state != mint_quote.state: + await update_bolt11_mint_quote( + db=self.db, + quote=mint_quote.quote, + state=mint_quote.state, + paid_time=mint_quote.paid_time or int(time.time()), + ) return mint_quote diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index 5d111809a..3676210a3 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -218,6 +218,10 @@ async def test_mint_quote(ledger: Ledger): assert resp_quote.amount == 100 assert resp_quote.unit == "sat" assert resp_quote.request == result["request"] + assert resp_quote.amount_paid == 0 + assert resp_quote.amount_issued == 0 + assert resp_quote.updated_at is not None + assert resp_quote.updated_at > 0 invoice = bolt11.decode(result["request"]) assert invoice.amount_msat == 100 * 1000 @@ -245,6 +249,10 @@ async def test_mint_quote(ledger: Ledger): assert resp_quote.amount == 100 assert resp_quote.unit == "sat" assert resp_quote.request == result["request"] + assert resp_quote.amount_paid == 100 + assert resp_quote.amount_issued == 0 + assert resp_quote.updated_at is not None + assert resp_quote.updated_at >= result["updated_at"] assert resp_quote.pubkey == "02" + "00" * 32 diff --git a/tests/mint/test_mint_app_router.py b/tests/mint/test_mint_app_router.py index 80c6074d0..d13a9989a 100644 --- a/tests/mint/test_mint_app_router.py +++ b/tests/mint/test_mint_app_router.py @@ -70,6 +70,9 @@ async def mint_quote(payload): state=SimpleNamespace(value="UNPAID"), expiry=123, pubkey=payload.pubkey, + amount_paid=0, + amount_issued=0, + updated_at=123, ) async def get_mint_quote(quote): @@ -82,6 +85,9 @@ async def get_mint_quote(quote): state=SimpleNamespace(value="UNPAID"), expiry=123, pubkey=None, + amount_paid=0, + amount_issued=0, + updated_at=123, ) async def melt_quote(payload):