diff --git a/cashu/core/base.py b/cashu/core/base.py index 699cace..e5b921e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -48,7 +48,7 @@ class DLEQWallet(BaseModel): # ------- PROOFS ------- -class SpentState(Enum): +class ProofSpentState(Enum): unspent = "UNSPENT" spent = "SPENT" pending = "PENDING" @@ -59,13 +59,13 @@ class SpentState(Enum): class ProofState(LedgerEvent): Y: str - state: SpentState + state: ProofSpentState witness: Optional[str] = None @root_validator() def check_witness(cls, values): state, witness = values.get("state"), values.get("witness") - if witness is not None and state != SpentState.spent: + if witness is not None and state != ProofSpentState.spent: raise ValueError('Witness can only be set if the spent state is "SPENT"') return values @@ -268,6 +268,15 @@ class Invoice(BaseModel): time_paid: Union[None, str, int, float] = "" +class MeltQuoteState(Enum): + unpaid = "UNPAID" + pending = "PENDING" + paid = "PAID" + + def __str__(self): + return self.name + + class MeltQuote(LedgerEvent): quote: str method: str @@ -277,6 +286,7 @@ class MeltQuote(LedgerEvent): amount: int fee_reserve: int paid: bool + state: MeltQuoteState created_time: Union[int, None] = None paid_time: Union[int, None] = None fee_paid: int = 0 @@ -303,6 +313,7 @@ class MeltQuote(LedgerEvent): amount=row["amount"], fee_reserve=row["fee_reserve"], paid=row["paid"], + state=MeltQuoteState[row["state"]], created_time=created_time, paid_time=paid_time, fee_paid=row["fee_paid"], @@ -318,6 +329,27 @@ class MeltQuote(LedgerEvent): def kind(self) -> JSONRPCSubscriptionKinds: return JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE + # method that is invoked when the `state` attribute is changed. to protect the state from being set to anything else if the current state is paid + def __setattr__(self, name, value): + # an unpaid quote can only be set to pending or paid + if name == "state" and self.state == MeltQuoteState.unpaid: + if value != MeltQuoteState.pending and value != MeltQuoteState.paid: + raise Exception("Cannot change state of an unpaid quote.") + # a paid quote can not be changed + if name == "state" and self.state == MeltQuoteState.paid: + raise Exception("Cannot change state of a paid quote.") + super().__setattr__(name, value) + + +class MintQuoteState(Enum): + unpaid = "UNPAID" + paid = "PAID" + pending = "PENDING" + issued = "ISSUED" + + def __str__(self): + return self.name + class MintQuote(LedgerEvent): quote: str @@ -328,6 +360,7 @@ class MintQuote(LedgerEvent): amount: int paid: bool issued: bool + state: MintQuoteState created_time: Union[int, None] = None paid_time: Union[int, None] = None expiry: Optional[int] = None @@ -353,6 +386,7 @@ class MintQuote(LedgerEvent): amount=row["amount"], paid=row["paid"], issued=row["issued"], + state=MintQuoteState[row["state"]], created_time=created_time, paid_time=paid_time, ) @@ -366,6 +400,24 @@ class MintQuote(LedgerEvent): def kind(self) -> JSONRPCSubscriptionKinds: return JSONRPCSubscriptionKinds.BOLT11_MINT_QUOTE + def __setattr__(self, name, value): + # un unpaid quote can only be set to paid + if name == "state" and self.state == MintQuoteState.unpaid: + if value != MintQuoteState.paid: + raise Exception("Cannot change state of an unpaid quote.") + # a paid quote can only be set to pending or issued + if name == "state" and self.state == MintQuoteState.paid: + if value != MintQuoteState.pending and value != MintQuoteState.issued: + raise Exception(f"Cannot change state of a paid quote to {value}.") + # a pending quote can only be set to paid or issued + if name == "state" and self.state == MintQuoteState.pending: + if value not in [MintQuoteState.paid, MintQuoteState.issued]: + raise Exception("Cannot change state of a pending quote.") + # an issued quote cannot be changed + if name == "state" and self.state == MintQuoteState.issued: + raise Exception("Cannot change state of an issued quote.") + super().__setattr__(name, value) + # ------- KEYSETS ------- diff --git a/cashu/core/models.py b/cashu/core/models.py index 133c151..9d1b823 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -6,6 +6,8 @@ from .base import ( BlindedMessage, BlindedMessage_Deprecated, BlindedSignature, + MeltQuote, + MintQuote, Proof, ProofState, ) @@ -98,9 +100,19 @@ class PostMintQuoteRequest(BaseModel): class PostMintQuoteResponse(BaseModel): quote: str # quote id request: str # input payment request - paid: bool # whether the request has been paid + paid: Optional[ + bool + ] # whether the request has been paid # DEPRECATED as per NUT PR #141 + state: str # state of the quote expiry: Optional[int] # expiry of the quote + @classmethod + def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse": + to_dict = mint_quote.dict() + # turn state into string + to_dict["state"] = mint_quote.state.value + return PostMintQuoteResponse.parse_obj(to_dict) + # ------- API: MINT ------- @@ -168,9 +180,17 @@ class PostMeltQuoteResponse(BaseModel): quote: str # quote id amount: int # input amount fee_reserve: int # input fee reserve - paid: bool # whether the request has been paid + paid: bool # whether the request has been paid # DEPRECATED as per NUT PR #136 + state: str # state of the quote expiry: Optional[int] # expiry of the quote + @classmethod + def from_melt_quote(self, melt_quote: MeltQuote) -> "PostMeltQuoteResponse": + to_dict = melt_quote.dict() + # turn state into string + to_dict["state"] = melt_quote.state.value + return PostMeltQuoteResponse.parse_obj(to_dict) + # ------- API: MELT ------- diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 33bfbed..f6fe554 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -433,8 +433,8 @@ class LedgerCrudSqlite(LedgerCrud): await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'mint_quotes')} - (quote, method, request, checking_id, unit, amount, issued, paid, created_time, paid_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (quote, method, request, checking_id, unit, amount, issued, paid, state, created_time, paid_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( quote.quote, @@ -445,6 +445,7 @@ class LedgerCrudSqlite(LedgerCrud): quote.amount, quote.issued, quote.paid, + quote.state.name, timestamp_from_seconds(db, quote.created_time), timestamp_from_seconds(db, quote.paid_time), ), @@ -510,10 +511,11 @@ class LedgerCrudSqlite(LedgerCrud): ) -> None: await (conn or db).execute( f"UPDATE {table_with_schema(db, 'mint_quotes')} SET issued = ?, paid = ?," - " paid_time = ? WHERE quote = ?", + " state = ?, paid_time = ? WHERE quote = ?", ( quote.issued, quote.paid, + quote.state.name, timestamp_from_seconds(db, quote.paid_time), quote.quote, ), @@ -546,8 +548,8 @@ class LedgerCrudSqlite(LedgerCrud): await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'melt_quotes')} - (quote, method, request, checking_id, unit, amount, fee_reserve, paid, created_time, paid_time, fee_paid, proof) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (quote, method, request, checking_id, unit, amount, fee_reserve, paid, state, created_time, paid_time, fee_paid, proof) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( quote.quote, @@ -558,6 +560,7 @@ class LedgerCrudSqlite(LedgerCrud): quote.amount, quote.fee_reserve or 0, quote.paid, + quote.state.name, timestamp_from_seconds(db, quote.created_time), timestamp_from_seconds(db, quote.paid_time), quote.fee_paid, @@ -608,10 +611,11 @@ class LedgerCrudSqlite(LedgerCrud): conn: Optional[Connection] = None, ) -> None: await (conn or db).execute( - f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, fee_paid = ?," - " paid_time = ?, proof = ? WHERE quote = ?", + f"UPDATE {table_with_schema(db, 'melt_quotes')} SET paid = ?, state = ?," + " fee_paid = ?, paid_time = ?, proof = ? WHERE quote = ?", ( quote.paid, + quote.state.name, quote.fee_paid, timestamp_from_seconds(db, quote.paid_time), quote.proof, diff --git a/cashu/mint/db/read.py b/cashu/mint/db/read.py index b245b08..c3a8409 100644 --- a/cashu/mint/db/read.py +++ b/cashu/mint/db/read.py @@ -1,6 +1,6 @@ from typing import Dict, List -from ...core.base import Proof, ProofState, SpentState +from ...core.base import Proof, ProofSpentState, ProofState from ...core.db import Database from ..crud import LedgerCrud @@ -54,14 +54,14 @@ class DbReadHelper: proofs_pending = await self._get_proofs_pending(Ys) for Y in Ys: if Y not in proofs_spent and Y not in proofs_pending: - states.append(ProofState(Y=Y, state=SpentState.unspent)) + states.append(ProofState(Y=Y, state=ProofSpentState.unspent)) elif Y not in proofs_spent and Y in proofs_pending: - states.append(ProofState(Y=Y, state=SpentState.pending)) + states.append(ProofState(Y=Y, state=ProofSpentState.pending)) else: states.append( ProofState( Y=Y, - state=SpentState.spent, + state=ProofSpentState.spent, witness=proofs_spent[Y].witness, ) ) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 2f307bf..21495c5 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -3,7 +3,7 @@ from typing import List, Optional from loguru import logger -from ...core.base import Proof, ProofState, SpentState +from ...core.base import Proof, ProofSpentState, ProofState from ...core.db import Connection, Database, get_db_connection from ...core.errors import ( TransactionError, @@ -50,7 +50,7 @@ class DbWriteHelper: proof=p, db=self.db, quote_id=quote_id, conn=conn ) await self.events.submit( - ProofState(Y=p.Y, state=SpentState.pending) + ProofState(Y=p.Y, state=ProofSpentState.pending) ) except Exception as e: logger.error(f"Failed to set proofs pending: {e}") @@ -72,7 +72,7 @@ class DbWriteHelper: await self.crud.unset_proof_pending(proof=p, db=self.db, conn=conn) if not spent: await self.events.submit( - ProofState(Y=p.Y, state=SpentState.unspent) + ProofState(Y=p.Y, state=ProofSpentState.unspent) ) async def _validate_proofs_pending( diff --git a/cashu/mint/events/events.py b/cashu/mint/events/events.py index 10b40a9..f62c287 100644 --- a/cashu/mint/events/events.py +++ b/cashu/mint/events/events.py @@ -38,9 +38,9 @@ class LedgerEventManager: def serialize_event(self, event: LedgerEvent) -> dict: if isinstance(event, MintQuote): - return_dict = PostMintQuoteResponse.parse_obj(event.dict()).dict() + return_dict = PostMintQuoteResponse.from_mint_quote(event).dict() elif isinstance(event, MeltQuote): - return_dict = PostMeltQuoteResponse.parse_obj(event.dict()).dict() + return_dict = PostMeltQuoteResponse.from_melt_quote(event).dict() elif isinstance(event, ProofState): return_dict = event.dict(exclude_unset=True, exclude_none=True) return return_dict diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 37a23ce..21c9550 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -11,12 +11,14 @@ from ..core.base import ( BlindedMessage, BlindedSignature, MeltQuote, + MeltQuoteState, Method, MintKeyset, MintQuote, + MintQuoteState, Proof, + ProofSpentState, ProofState, - SpentState, Unit, ) from ..core.crypto import b_dhke @@ -153,6 +155,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe logger.info(f"Melt quote {quote.quote} state: paid") quote.paid_time = int(time.time()) quote.paid = True + quote.state = MeltQuoteState.paid if payment.fee: quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount quote.proof = payment.preimage or "" @@ -302,7 +305,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe proof=p, db=self.db, quote_id=quote_id, conn=conn ) await self.events.submit( - ProofState(Y=p.Y, state=SpentState.spent, witness=p.witness or None) + ProofState( + Y=p.Y, state=ProofSpentState.spent, witness=p.witness or None + ) ) async def _generate_change_promises( @@ -429,6 +434,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe amount=quote_request.amount, issued=False, paid=False, + state=MintQuoteState.unpaid, created_time=int(time.time()), expiry=expiry, ) @@ -455,7 +461,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) - if not quote.paid: + if quote.state == MintQuoteState.unpaid: if not quote.checking_id: raise CashuError("quote has no checking id") logger.trace(f"Lightning: checking invoice {quote.checking_id}") @@ -465,6 +471,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe if status.paid: logger.trace(f"Setting quote {quote_id} as paid") quote.paid = True + quote.state = MintQuoteState.paid quote.paid_time = int(time.time()) await self.crud.update_mint_quote(quote=quote, db=self.db) await self.events.submit(quote) @@ -509,6 +516,12 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe raise QuoteNotPaidError() if quote.issued: raise TransactionError("quote already issued") + + if not quote.state == MintQuoteState.paid: + raise QuoteNotPaidError() + if quote.state == MintQuoteState.issued: + raise TransactionError("quote already issued") + if not quote.unit == output_unit.name: raise TransactionError("quote unit does not match output unit") if not quote.amount == sum_amount_outputs: @@ -518,6 +531,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe logger.trace(f"crud: setting quote {quote_id} as issued") quote.issued = True + quote.state = MintQuoteState.issued await self.crud.update_mint_quote(quote=quote, db=self.db) promises = await self._generate_promises(outputs) @@ -549,6 +563,12 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe raise TransactionError("mint quote already paid") if mint_quote.issued: raise TransactionError("mint quote already issued") + + if mint_quote.state == MintQuoteState.issued: + raise TransactionError("mint quote already issued") + if mint_quote.state != MintQuoteState.unpaid: + raise TransactionError("mint quote already paid") + if not mint_quote.checking_id: raise TransactionError("mint quote has no checking id") if melt_quote.is_mpp: @@ -658,6 +678,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe unit=unit.name, amount=payment_quote.amount.to(unit).amount, paid=False, + state=MeltQuoteState.unpaid, fee_reserve=payment_quote.fee.to(unit).amount, created_time=int(time.time()), expiry=expiry, @@ -670,6 +691,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe amount=quote.amount, fee_reserve=quote.fee_reserve, paid=quote.paid, + state=quote.state.value, expiry=quote.expiry, ) @@ -714,6 +736,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe if status.paid: logger.trace(f"Setting quote {quote_id} as paid") melt_quote.paid = True + melt_quote.state = MeltQuoteState.paid if status.fee: melt_quote.fee_paid = status.fee.to(unit).amount if status.preimage: @@ -753,6 +776,8 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe # we settle the transaction internally if melt_quote.paid: raise TransactionError("melt quote already paid") + if melt_quote.state != MeltQuoteState.unpaid: + raise TransactionError("melt quote already paid") # verify amounts from bolt11 invoice bolt11_request = melt_quote.request @@ -768,11 +793,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe raise TransactionError("units do not match") if not mint_quote.method == melt_quote.method: raise TransactionError("methods do not match") + if mint_quote.paid: raise TransactionError("mint quote already paid") if mint_quote.issued: raise TransactionError("mint quote already issued") + if mint_quote.state != MintQuoteState.unpaid: + raise TransactionError("mint quote already paid") + logger.info( f"Settling bolt11 payment internally: {melt_quote.quote} ->" f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" @@ -780,9 +809,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe melt_quote.fee_paid = 0 # no internal fees melt_quote.paid = True + melt_quote.state = MeltQuoteState.paid melt_quote.paid_time = int(time.time()) mint_quote.paid = True + mint_quote.state = MintQuoteState.paid mint_quote.paid_time = melt_quote.paid_time async with get_db_connection(self.db) as conn: @@ -821,7 +852,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe melt_quote.unit, melt_quote.method ) - if melt_quote.paid: + if melt_quote.state != MeltQuoteState.unpaid: raise TransactionError("melt quote already paid") # make sure that the outputs (for fee return) are in the same unit as the quote @@ -866,7 +897,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe # settle the transaction internally if there is a mint quote with the same payment request melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) # quote not paid yet (not internal), pay it with the backend - if not melt_quote.paid: + if not melt_quote.paid and melt_quote.state == MeltQuoteState.unpaid: logger.debug(f"Lightning: pay invoice {melt_quote.request}") payment = await self.backends[method][unit].pay_invoice( melt_quote, melt_quote.fee_reserve * 1000 @@ -887,6 +918,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions, LedgerTasks, LedgerFe melt_quote.proof = payment.preimage # set quote as paid melt_quote.paid = True + melt_quote.state = MeltQuoteState.paid melt_quote.paid_time = int(time.time()) await self.crud.update_melt_quote(quote=melt_quote, db=self.db) await self.events.submit(melt_quote) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 73ad7e8..cbf2645 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -773,3 +773,45 @@ async def m019_add_fee_to_keysets(db: Database): await conn.execute( f"UPDATE {table_with_schema(db, 'keysets')} SET input_fee_ppk = 0" ) + + +async def m020_add_state_to_mint_and_melt_quotes(db: Database): + async with db.connect() as conn: + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'mint_quotes')} ADD COLUMN state TEXT" + ) + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'melt_quotes')} ADD COLUMN state TEXT" + ) + + # get all melt and mint quotes and figure out the state to set using the `paid` column + # and the `paid` and `issued` column respectively + # mint quotes: + async with db.connect() as conn: + rows = await conn.fetchall( + f"SELECT * FROM {table_with_schema(db, 'mint_quotes')}" + ) + for row in rows: + if row["issued"]: + state = "issued" + elif row["paid"]: + state = "paid" + else: + state = "unpaid" + await conn.execute( + f"UPDATE {table_with_schema(db, 'mint_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'" + ) + + # melt quotes: + async with db.connect() as conn: + rows = await conn.fetchall( + f"SELECT * FROM {table_with_schema(db, 'melt_quotes')}" + ) + for row in rows: + if row["paid"]: + state = "paid" + else: + state = "unpaid" + await conn.execute( + f"UPDATE {table_with_schema(db, 'melt_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'" + ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 1bc4216..8236279 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -161,6 +161,7 @@ async def mint_quote( request=quote.request, quote=quote.quote, paid=quote.paid, + state=quote.state.value, expiry=quote.expiry, ) logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}") @@ -184,6 +185,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: quote=mint_quote.quote, request=mint_quote.request, paid=mint_quote.paid, + state=mint_quote.state.value, expiry=mint_quote.expiry, ) logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}") @@ -274,6 +276,7 @@ async def get_melt_quote(request: Request, quote: str) -> PostMeltQuoteResponse: amount=melt_quote.amount, fee_reserve=melt_quote.fee_reserve, paid=melt_quote.paid, + state=melt_quote.state.value, expiry=melt_quote.expiry, ) logger.trace(f"< GET /v1/melt/quote/bolt11/{quote}") diff --git a/cashu/mint/router_deprecated.py b/cashu/mint/router_deprecated.py index 520ec8d..0fccc60 100644 --- a/cashu/mint/router_deprecated.py +++ b/cashu/mint/router_deprecated.py @@ -3,7 +3,7 @@ from typing import Dict, List, Optional from fastapi import APIRouter, Request from loguru import logger -from ..core.base import BlindedMessage, BlindedSignature, SpentState +from ..core.base import BlindedMessage, BlindedSignature, ProofSpentState from ..core.errors import CashuError from ..core.models import ( CheckFeesRequest_deprecated, @@ -345,13 +345,13 @@ async def check_spendable_deprecated( spendableList: List[bool] = [] pendingList: List[bool] = [] for proof_state in proofs_state: - if proof_state.state == SpentState.unspent: + if proof_state.state == ProofSpentState.unspent: spendableList.append(True) pendingList.append(False) - elif proof_state.state == SpentState.spent: + elif proof_state.state == ProofSpentState.spent: spendableList.append(False) pendingList.append(False) - elif proof_state.state == SpentState.pending: + elif proof_state.state == ProofSpentState.pending: spendableList.append(True) pendingList.append(True) return CheckSpendableResponse_deprecated( diff --git a/cashu/mint/tasks.py b/cashu/mint/tasks.py index 413421c..fa231c2 100644 --- a/cashu/mint/tasks.py +++ b/cashu/mint/tasks.py @@ -3,7 +3,7 @@ from typing import Mapping from loguru import logger -from ..core.base import Method, Unit +from ..core.base import Method, MintQuoteState, Unit from ..core.db import Database from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud @@ -40,6 +40,7 @@ class LedgerTasks(SupportsDb, SupportsBackends, SupportsEvents): # set the quote as paid if not quote.paid: quote.paid = True + quote.state = MintQuoteState.paid await self.crud.update_mint_quote(quote=quote, db=self.db) logger.trace(f"Quote {quote} set as paid and ") await self.events.submit(quote) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 3c60043..d64a4b1 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -15,7 +15,7 @@ import click from click import Context from loguru import logger -from ...core.base import Invoice, Method, TokenV3, Unit +from ...core.base import Invoice, Method, MintQuoteState, TokenV3, Unit from ...core.helpers import sum_proofs from ...core.json_rpc.base import JSONRPCNotficationParams from ...core.logging import configure_logger @@ -296,8 +296,10 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo except Exception: return logger.debug(f"Received callback for quote: {quote}") + # we need to sleep to give the callback map some time to be populated + time.sleep(0.1) if ( - quote.paid + (quote.paid or quote.state == MintQuoteState.paid.value) and quote.request == invoice.bolt11 and msg.subId in subscription.callback_map.keys() ): @@ -310,6 +312,9 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo except Exception as e: print(f"Error during mint: {str(e)}") return + else: + logger.debug("Quote not paid yet.") + return # user requests an invoice if amount and not id: diff --git a/cashu/wallet/lightning/lightning.py b/cashu/wallet/lightning/lightning.py index 4f24d68..be6ef10 100644 --- a/cashu/wallet/lightning/lightning.py +++ b/cashu/wallet/lightning/lightning.py @@ -1,6 +1,6 @@ import bolt11 -from ...core.base import Amount, SpentState, Unit +from ...core.base import Amount, ProofSpentState, Unit from ...core.helpers import sum_promises from ...core.settings import settings from ...lightning.base import ( @@ -131,12 +131,12 @@ class LightningWallet(Wallet): if not proofs_states: return PaymentStatus(paid=False) # "states not fount" - if all([p.state == SpentState.pending for p in proofs_states.states]): + if all([p.state == ProofSpentState.pending for p in proofs_states.states]): return PaymentStatus(paid=None) # "pending (with check)" - if any([p.state == SpentState.spent for p in proofs_states.states]): + if any([p.state == ProofSpentState.spent for p in proofs_states.states]): # NOTE: consider adding this check in wallet.py and mark the invoice as paid if all proofs are spent return PaymentStatus(paid=True) # "paid (with check)" - if all([p.state == SpentState.unspent for p in proofs_states.states]): + if all([p.state == ProofSpentState.unspent for p in proofs_states.states]): return PaymentStatus(paid=False) # "failed (with check)" return PaymentStatus(paid=None) # "undefined state" diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 83bac04..05decf4 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -243,3 +243,81 @@ async def m012_add_fee_to_keysets(db: Database): # add column for storing the fee of a keyset await conn.execute("ALTER TABLE keysets ADD COLUMN input_fee_ppk INTEGER") await conn.execute("UPDATE keysets SET input_fee_ppk = 0") + + +# # async def m020_add_state_to_mint_and_melt_quotes(db: Database): +# # async with db.connect() as conn: +# # await conn.execute( +# # f"ALTER TABLE {table_with_schema(db, 'mint_quotes')} ADD COLUMN state TEXT" +# # ) +# # await conn.execute( +# # f"ALTER TABLE {table_with_schema(db, 'melt_quotes')} ADD COLUMN state TEXT" +# # ) + +# # # get all melt and mint quotes and figure out the state to set using the `paid` column +# # # and the `paid` and `issued` column respectively +# # # mint quotes: +# # async with db.connect() as conn: +# # rows = await conn.fetchall( +# # f"SELECT * FROM {table_with_schema(db, 'mint_quotes')}" +# # ) +# # for row in rows: +# # if row["issued"]: +# # state = "issued" +# # elif row["paid"]: +# # state = "paid" +# # else: +# # state = "unpaid" +# # await conn.execute( +# # f"UPDATE {table_with_schema(db, 'mint_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'" +# # ) + +# # # melt quotes: +# # async with db.connect() as conn: +# # rows = await conn.fetchall( +# # f"SELECT * FROM {table_with_schema(db, 'melt_quotes')}" +# # ) +# # for row in rows: +# # if row["paid"]: +# # state = "paid" +# # else: +# # state = "unpaid" +# # await conn.execute( +# # f"UPDATE {table_with_schema(db, 'melt_quotes')} SET state = '{state}' WHERE quote = '{row['quote']}'" +# # ) +# # add the equivalent of the above migration for the wallet here. do not use table_with_schema. use the tables and columns +# # as they are defined in the wallet db + + +# async def m020_add_state_to_mint_and_melt_quotes(db: Database): +# async with db.connect() as conn: +# await conn.execute("ALTER TABLE mint_quotes ADD COLUMN state TEXT") +# await conn.execute("ALTER TABLE melt_quotes ADD COLUMN state TEXT") + +# # get all melt and mint quotes and figure out the state to set using the `paid` column +# # and the `paid` and `issued` column respectively +# # mint quotes: +# async with db.connect() as conn: +# rows = await conn.fetchall("SELECT * FROM mint_quotes") +# for row in rows: +# if row["issued"]: +# state = "issued" +# elif row["paid"]: +# state = "paid" +# else: +# state = "unpaid" +# await conn.execute( +# f"UPDATE mint_quotes SET state = '{state}' WHERE quote = '{row['quote']}'" +# ) + +# # melt quotes: +# async with db.connect() as conn: +# rows = await conn.fetchall("SELECT * FROM melt_quotes") +# for row in rows: +# if row["paid"]: +# state = "paid" +# else: +# state = "unpaid" +# await conn.execute( +# f"UPDATE melt_quotes SET state = '{state}' WHERE quote = '{row['quote']}'" +# ) diff --git a/cashu/wallet/subscriptions.py b/cashu/wallet/subscriptions.py index 35c0b30..a00aff1 100644 --- a/cashu/wallet/subscriptions.py +++ b/cashu/wallet/subscriptions.py @@ -47,14 +47,19 @@ class SubscriptionManager: try: msg = JSONRPCNotification.parse_raw(message) - params = JSONRPCNotficationParams.parse_obj(msg.params) logger.debug(f"Received notification: {msg}") - self.callback_map[params.subId](params) + except Exception as e: + logger.error(f"Error parsing notification: {e}") + return + try: + params = JSONRPCNotficationParams.parse_obj(msg.params) + logger.trace(f"Notification params: {params}") + except Exception as e: + logger.error(f"Error parsing notification params: {e}") return - except Exception: - pass - logger.error(f"Error parsing message: {message}") + self.callback_map[params.subId](params) + return def connect(self): self.websocket.run_forever(ping_interval=10, ping_timeout=5) diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index fb32c48..7c1194c 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -11,9 +11,10 @@ from loguru import logger from ..core.base import ( BlindedMessage, BlindedSignature, + MeltQuoteState, Proof, + ProofSpentState, ProofState, - SpentState, Unit, WalletKeyset, ) @@ -390,6 +391,7 @@ class LedgerAPI(LedgerAPIDeprecated, object): amount=amount or invoice_obj.amount_msat // 1000, fee_reserve=ret.fee or 0, paid=False, + state=MeltQuoteState.unpaid.value, expiry=invoice_obj.expiry, ) # END backwards compatibility < 0.15.0 @@ -509,11 +511,11 @@ class LedgerAPI(LedgerAPIDeprecated, object): states: List[ProofState] = [] for spendable, pending, p in zip(ret.spendable, ret.pending, proofs): if spendable and not pending: - states.append(ProofState(Y=p.Y, state=SpentState.unspent)) + states.append(ProofState(Y=p.Y, state=ProofSpentState.unspent)) elif spendable and pending: - states.append(ProofState(Y=p.Y, state=SpentState.pending)) + states.append(ProofState(Y=p.Y, state=ProofSpentState.pending)) else: - states.append(ProofState(Y=p.Y, state=SpentState.spent)) + states.append(ProofState(Y=p.Y, state=ProofSpentState.spent)) ret = PostCheckStateResponse(states=states) return ret # END backwards compatibility < 0.15.0 diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 2426f02..96c440e 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -15,7 +15,7 @@ from ..core.base import ( DLEQWallet, Invoice, Proof, - SpentState, + ProofSpentState, Unit, WalletKeyset, ) @@ -924,7 +924,7 @@ class Wallet( if check_spendable: proof_states = await self.check_proof_state(proofs) for i, state in enumerate(proof_states.states): - if state.state == SpentState.spent: + if state.state == ProofSpentState.spent: invalidated_proofs.append(proofs[i]) else: invalidated_proofs = proofs diff --git a/cashu/wallet/wallet_deprecated.py b/cashu/wallet/wallet_deprecated.py index 614b919..b418a54 100644 --- a/cashu/wallet/wallet_deprecated.py +++ b/cashu/wallet/wallet_deprecated.py @@ -10,6 +10,7 @@ from ..core.base import ( BlindedMessage, BlindedMessage_Deprecated, BlindedSignature, + MintQuoteState, Proof, WalletKeyset, ) @@ -252,6 +253,7 @@ class LedgerAPIDeprecated(SupportsHttpxClient, SupportsMintURL): quote=mint_response.hash, request=mint_response.pr, paid=False, + state=MintQuoteState.unpaid.value, expiry=decoded_invoice.date + (decoded_invoice.expiry or 0), ) diff --git a/tests/conftest.py b/tests/conftest.py index ed3dc2f..a76d3ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ settings.tor = False settings.wallet_unit = "sat" settings.mint_backend_bolt11_sat = settings.mint_backend_bolt11_sat or "FakeWallet" settings.fakewallet_brr = True -settings.fakewallet_delay_outgoing_payment = None +settings.fakewallet_delay_outgoing_payment = 0 settings.fakewallet_delay_incoming_payment = 1 settings.fakewallet_stochastic_invoice = False assert ( diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index b374b22..cf5034e 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -3,7 +3,7 @@ import httpx import pytest import pytest_asyncio -from cashu.core.base import SpentState +from cashu.core.base import ProofSpentState from cashu.core.models import ( GetInfoResponse, MintMeltMethodSetting, @@ -403,7 +403,7 @@ async def test_api_check_state(ledger: Ledger): response = PostCheckStateResponse.parse_obj(response.json()) assert response assert len(response.states) == 2 - assert response.states[0].state == SpentState.unspent + assert response.states[0].state == ProofSpentState.unspent @pytest.mark.asyncio diff --git a/tests/test_mint_init.py b/tests/test_mint_init.py index de8cedd..20c8000 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -5,7 +5,7 @@ import bolt11 import pytest import pytest_asyncio -from cashu.core.base import MeltQuote, Proof, SpentState +from cashu.core.base import MeltQuote, MeltQuoteState, Proof, ProofSpentState from cashu.core.crypto.aes import AESCipher from cashu.core.db import Database from cashu.core.settings import settings @@ -144,6 +144,7 @@ async def create_pending_melts( checking_id=check_id, unit="sat", paid=False, + state=MeltQuoteState.unpaid, amount=100, fee_reserve=1, ) @@ -172,7 +173,7 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): after the startup routine determines that the associated melt quote was paid.""" pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.pending + assert states[0].state == ProofSpentState.pending settings.fakewallet_payment_state = True # run startup routinge await ledger.startup_ledger() @@ -185,7 +186,7 @@ async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): # expect that proofs are spent states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.spent + assert states[0].state == ProofSpentState.spent @pytest.mark.asyncio @@ -198,7 +199,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): """ pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.pending + assert states[0].state == ProofSpentState.pending settings.fakewallet_payment_state = False # run startup routinge await ledger.startup_ledger() @@ -211,7 +212,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): # expect that proofs are unspent states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.unspent + assert states[0].state == ProofSpentState.unspent @pytest.mark.asyncio @@ -219,7 +220,7 @@ async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): pending_proof, quote = await create_pending_melts(ledger) states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.pending + assert states[0].state == ProofSpentState.pending settings.fakewallet_payment_state = None # run startup routinge await ledger.startup_ledger() @@ -232,7 +233,7 @@ async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([pending_proof.Y]) - assert states[0].state == SpentState.pending + assert states[0].state == ProofSpentState.pending @pytest.mark.asyncio @@ -274,7 +275,7 @@ async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Led # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.pending for s in states]) + assert all([s.state == ProofSpentState.pending for s in states]) # only now settle the invoice settle_invoice(preimage=preimage) @@ -308,7 +309,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led await asyncio.sleep(SLEEP_TIME) # expect that proofs are pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.pending for s in states]) + assert all([s.state == ProofSpentState.pending for s in states]) settle_invoice(preimage=preimage) await asyncio.sleep(SLEEP_TIME) @@ -324,7 +325,7 @@ async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Led # expect that proofs are spent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.spent for s in states]) + assert all([s.state == ProofSpentState.spent for s in states]) @pytest.mark.asyncio @@ -359,7 +360,7 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led # expect that proofs are pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.pending for s in states]) + assert all([s.state == ProofSpentState.pending for s in states]) cancel_invoice(preimage_hash=preimage_hash) await asyncio.sleep(SLEEP_TIME) @@ -375,4 +376,4 @@ async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Led # expect that proofs are unspent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.unspent for s in states]) + assert all([s.state == ProofSpentState.unspent for s in states]) diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py index f870d87..b699c7a 100644 --- a/tests/test_mint_lightning_blink.py +++ b/tests/test_mint_lightning_blink.py @@ -2,7 +2,7 @@ import pytest import respx from httpx import Response -from cashu.core.base import Amount, MeltQuote, Unit +from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit from cashu.core.models import PostMeltQuoteRequest from cashu.core.settings import settings from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore @@ -99,6 +99,7 @@ async def test_blink_pay_invoice(): amount=100, fee_reserve=12, paid=False, + state=MeltQuoteState.unpaid, ) payment = await blink.pay_invoice(quote, 1000) assert payment.ok @@ -131,6 +132,7 @@ async def test_blink_pay_invoice_failure(): amount=100, fee_reserve=12, paid=False, + state=MeltQuoteState.unpaid, ) payment = await blink.pay_invoice(quote, 1000) assert not payment.ok diff --git a/tests/test_mint_regtest.py b/tests/test_mint_regtest.py index bb5ecce..60c2ced 100644 --- a/tests/test_mint_regtest.py +++ b/tests/test_mint_regtest.py @@ -3,7 +3,7 @@ import asyncio import pytest import pytest_asyncio -from cashu.core.base import SpentState +from cashu.core.base import ProofSpentState from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -63,7 +63,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): # expect that proofs are still pending states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.pending for s in states]) + assert all([s.state == ProofSpentState.pending for s in states]) # only now settle the invoice settle_invoice(preimage=preimage) @@ -71,7 +71,7 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): # expect that proofs are now spent states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs]) - assert all([s.state == SpentState.spent for s in states]) + assert all([s.state == ProofSpentState.spent for s in states]) # expect that no melt quote is pending melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index e893967..e396f2a 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -7,7 +7,7 @@ from typing import List import pytest import pytest_asyncio -from cashu.core.base import Proof, SpentState +from cashu.core.base import Proof, ProofSpentState from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.migrations import migrate_databases from cashu.core.p2pk import SigFlags @@ -80,7 +80,7 @@ async def test_p2pk(wallet1: Wallet, wallet2: Wallet): await wallet2.redeem(send_proofs) proof_states = await wallet2.check_proof_state(send_proofs) - assert all([p.state == SpentState.spent for p in proof_states.states]) + assert all([p.state == ProofSpentState.spent for p in proof_states.states]) if not is_deprecated_api_only: for state in proof_states.states: diff --git a/tests/test_wallet_regtest.py b/tests/test_wallet_regtest.py index d445118..4916fcf 100644 --- a/tests/test_wallet_regtest.py +++ b/tests/test_wallet_regtest.py @@ -4,7 +4,7 @@ import bolt11 import pytest import pytest_asyncio -from cashu.core.base import SpentState +from cashu.core.base import ProofSpentState from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -57,14 +57,14 @@ async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == SpentState.pending for s in states.states]) + assert all([s.state == ProofSpentState.pending for s in states.states]) settle_invoice(preimage=preimage) await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == SpentState.spent for s in states.states]) + assert all([s.state == ProofSpentState.spent for s in states.states]) @pytest.mark.asyncio @@ -97,11 +97,11 @@ async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger): await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == SpentState.pending for s in states.states]) + assert all([s.state == ProofSpentState.pending for s in states.states]) cancel_invoice(preimage_hash=preimage_hash) await asyncio.sleep(SLEEP_TIME) states = await wallet.check_proof_state(send_proofs) - assert all([s.state == SpentState.unspent for s in states.states]) + assert all([s.state == ProofSpentState.unspent for s in states.states]) diff --git a/tests/test_wallet_subscription.py b/tests/test_wallet_subscription.py index d81896d..d470592 100644 --- a/tests/test_wallet_subscription.py +++ b/tests/test_wallet_subscription.py @@ -3,7 +3,7 @@ import asyncio import pytest import pytest_asyncio -from cashu.core.base import Method, ProofState +from cashu.core.base import Method, MintQuoteState, ProofSpentState, ProofState from cashu.core.json_rpc.base import JSONRPCNotficationParams from cashu.core.nuts import WEBSOCKETS_NUT from cashu.core.settings import settings @@ -51,20 +51,17 @@ async def test_wallet_subscription_mint(wallet: Wallet): wait = settings.fakewallet_delay_incoming_payment or 2 await asyncio.sleep(wait + 2) - # TODO: check for pending and paid states according to: https://github.com/cashubtc/nuts/pull/136 - # TODO: we have three messages here, but the value "paid" only changes once - # the mint sends an update when the quote is pending but the API does not express that yet - - # first we expect the issued=False state to arrive - assert triggered assert len(msg_stack) == 3 assert msg_stack[0].payload["paid"] is False + assert msg_stack[0].payload["state"] == MintQuoteState.unpaid.value assert msg_stack[1].payload["paid"] is True + assert msg_stack[1].payload["state"] == MintQuoteState.paid.value assert msg_stack[2].payload["paid"] is True + assert msg_stack[2].payload["state"] == MintQuoteState.issued.value @pytest.mark.asyncio @@ -103,16 +100,16 @@ async def test_wallet_subscription_swap(wallet: Wallet): pending_stack = msg_stack[:n_subscriptions] for msg in pending_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state.value == "UNSPENT" + assert proof_state.state == ProofSpentState.unspent # the second one is the PENDING state spent_stack = msg_stack[n_subscriptions : n_subscriptions * 2] for msg in spent_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state.value == "PENDING" + assert proof_state.state == ProofSpentState.pending # the third one is the SPENT state spent_stack = msg_stack[n_subscriptions * 2 :] for msg in spent_stack: proof_state = ProofState.parse_obj(msg.payload) - assert proof_state.state.value == "SPENT" + assert proof_state.state == ProofSpentState.spent