From b8ad0e0a8f935c4f69a3bd8c5162423fc8e6943a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:14:21 +0200 Subject: [PATCH] Mint: Recover pending melts at startup (#499) * wip works with fakewallet * startup refactor * add tests * regtest tests for pending melts * wip CLN * remove db migration * remove foreign key relation to keyset id * fix: get_promise from db and restore DLEQs * test: check for keyset not found error * fix migrations * lower-case all db column names * add more tests for regtest * simlate failure for lightning * test wallet spent state with hodl invoices * retry * regtest with postgres * retry postgres * add sleeps * longer sleep on github * more sleep for github sigh * increase sleep ffs * add sleep loop * try something * do not pay with wallet but with ledger * fix lnbits pending state * fix pipeline to use fake admin from docker --- .github/workflows/ci.yml | 4 +- .github/workflows/regtest.yml | 4 - cashu/core/base.py | 21 +- cashu/core/errors.py | 4 +- cashu/core/settings.py | 1 + cashu/lightning/corelightningrest.py | 14 +- cashu/lightning/fake.py | 2 +- cashu/lightning/lnbits.py | 12 +- cashu/lightning/lndrest.py | 8 +- cashu/mint/crud.py | 150 +++++++++------ cashu/mint/ledger.py | 112 +++++++++-- cashu/mint/migrations.py | 174 ++++++++++++++--- cashu/mint/router.py | 4 +- cashu/mint/startup.py | 30 +-- cashu/mint/verification.py | 2 +- cashu/wallet/cli/cli.py | 2 +- cashu/wallet/wallet.py | 2 +- tests/conftest.py | 4 +- tests/helpers.py | 26 +-- tests/test_mint_init.py | 277 ++++++++++++++++++++++++++- tests/test_mint_regtest.py | 80 ++++++++ tests/test_wallet_regtest.py | 107 +++++++++++ tests/test_wallet_restore.py | 5 + 23 files changed, 868 insertions(+), 177 deletions(-) create mode 100644 tests/test_mint_regtest.py create mode 100644 tests/test_wallet_regtest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c50f6b..fcae1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,9 @@ jobs: poetry-version: ["1.7.1"] backend-wallet-class: ["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] + # mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"] + mint-database: ["./test_data/test_mint"] with: python-version: ${{ matrix.python-version }} backend-wallet-class: ${{ matrix.backend-wallet-class }} - mint-database: "./test_data/test_mint" + mint-database: ${{ matrix.mint-database }} diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index ed052a4..8beb7d8 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -52,10 +52,6 @@ jobs: chmod -R 777 . bash ./start.sh - - name: Create fake admin - if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }} - run: docker exec cashu-lnbits-1 poetry run python tools/create_fake_admin.py - - name: Run Tests env: WALLET_NAME: test_wallet diff --git a/cashu/core/base.py b/cashu/core/base.py index 072d2a8..f6375f9 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -101,12 +101,12 @@ class Proof(BaseModel): time_created: Union[None, str] = "" time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - mint_id: Union[ - None, str - ] = None # holds the id of the mint operation that created this proof - melt_id: Union[ - None, str - ] = None # holds the id of the melt operation that destroyed this proof + mint_id: Union[None, str] = ( + None # holds the id of the mint operation that created this proof + ) + melt_id: Union[None, str] = ( + None # holds the id of the melt operation that destroyed this proof + ) def __init__(self, **data): super().__init__(**data) @@ -194,6 +194,15 @@ class BlindedSignature(BaseModel): C_: str # Hex-encoded signature dleq: Optional[DLEQ] = None # DLEQ proof + @classmethod + def from_row(cls, row: Row): + return cls( + id=row["id"], + amount=row["amount"], + C_=row["c_"], + dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]), + ) + class BlindedMessages(BaseModel): # NOTE: not used in Pydantic validation diff --git a/cashu/core/errors.py b/cashu/core/errors.py index d36614a..96a9c26 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -63,7 +63,9 @@ class KeysetNotFoundError(KeysetError): detail = "keyset not found" code = 12001 - def __init__(self): + def __init__(self, keyset_id: Optional[str] = None): + if keyset_id: + self.detail = f"{self.detail}: {keyset_id}" super().__init__(self.detail, code=self.code) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index bc01df8..899ab9a 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -125,6 +125,7 @@ class FakeWalletSettings(MintSettings): fakewallet_brr: bool = Field(default=True) fakewallet_delay_payment: bool = Field(default=False) fakewallet_stochastic_invoice: bool = Field(default=False) + fakewallet_payment_state: Optional[bool] = Field(default=None) mint_cache_secrets: bool = Field(default=True) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index 4503f6a..6cbb7d1 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -247,17 +247,21 @@ class CoreLightningRestWallet(LightningBackend): r.raise_for_status() data = r.json() - if r.is_error or "error" in data or not data.get("pays"): - raise Exception("error in corelightning-rest response") + if not data.get("pays"): + # payment not found + logger.error(f"payment not found: {data.get('pays')}") + raise Exception("payment not found") + + if r.is_error or "error" in data: + message = data.get("error") or data + raise Exception(f"error in corelightning-rest response: {message}") pay = data["pays"][0] fee_msat, preimage = None, None if self.statuses[pay["status"]]: # cut off "msat" and convert to int - fee_msat = -int(pay["amount_sent_msat"][:-4]) - int( - pay["amount_msat"][:-4] - ) + fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"]) preimage = pay["preimage"] return PaymentStatus( diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index f4c0f01..564c000 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -139,7 +139,7 @@ class FakeWallet(LightningBackend): return PaymentStatus(paid=paid or None) async def get_payment_status(self, _: str) -> PaymentStatus: - return PaymentStatus(paid=None) + return PaymentStatus(paid=settings.fakewallet_payment_state) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index 96dff6b..174236e 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -151,8 +151,18 @@ class LNbitsWallet(LightningBackend): if "paid" not in data and "details" not in data: return PaymentStatus(paid=None) + paid_value = None + if data["paid"]: + paid_value = True + elif not data["paid"] and data["details"]["pending"]: + paid_value = None + elif not data["paid"] and not data["details"]["pending"]: + paid_value = False + else: + raise ValueError(f"unexpected value for paid: {data['paid']}") + return PaymentStatus( - paid=data["paid"], + paid=paid_value, fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), preimage=data["preimage"], ) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index 2919732..3c2e75a 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -217,14 +217,20 @@ class LndRestWallet(LightningBackend): async for json_line in r.aiter_lines(): try: line = json.loads(json_line) + + # check for errors if line.get("error"): - logger.error( + message = ( line["error"]["message"] if "message" in line["error"] else line["error"] ) + logger.error(f"LND get_payment_status error: {message}") return PaymentStatus(paid=None) + payment = line.get("result") + + # payment exists if payment is not None and payment.get("status"): return PaymentStatus( paid=statuses[payment["status"]], diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 75f4471..30d30b1 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -34,8 +34,7 @@ class LedgerCrud(ABC): derivation_path: str = "", seed: str = "", conn: Optional[Connection] = None, - ) -> List[MintKeyset]: - ... + ) -> List[MintKeyset]: ... @abstractmethod async def get_spent_proofs( @@ -43,8 +42,7 @@ class LedgerCrud(ABC): *, db: Database, conn: Optional[Connection] = None, - ) -> List[Proof]: - ... + ) -> List[Proof]: ... async def get_proof_used( self, @@ -52,8 +50,7 @@ class LedgerCrud(ABC): Y: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[Proof]: - ... + ) -> Optional[Proof]: ... @abstractmethod async def invalidate_proof( @@ -61,9 +58,26 @@ class LedgerCrud(ABC): *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... + + @abstractmethod + async def get_all_melt_quotes_from_pending_proofs( + self, + *, + db: Database, + conn: Optional[Connection] = None, + ) -> List[MeltQuote]: ... + + @abstractmethod + async def get_pending_proofs_for_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> List[Proof]: ... @abstractmethod async def get_proofs_pending( @@ -72,8 +86,7 @@ class LedgerCrud(ABC): Ys: List[str], db: Database, conn: Optional[Connection] = None, - ) -> List[Proof]: - ... + ) -> List[Proof]: ... @abstractmethod async def set_proof_pending( @@ -81,15 +94,18 @@ class LedgerCrud(ABC): *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def unset_proof_pending( - self, *, proof: Proof, db: Database, conn: Optional[Connection] = None - ) -> None: - ... + self, + *, + proof: Proof, + db: Database, + conn: Optional[Connection] = None, + ) -> None: ... @abstractmethod async def store_keyset( @@ -98,16 +114,14 @@ class LedgerCrud(ABC): db: Database, keyset: MintKeyset, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_balance( self, db: Database, conn: Optional[Connection] = None, - ) -> int: - ... + ) -> int: ... @abstractmethod async def store_promise( @@ -115,24 +129,22 @@ class LedgerCrud(ABC): *, db: Database, amount: int, - B_: str, - C_: str, + b_: str, + c_: str, id: str, e: str = "", s: str = "", conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_promise( self, *, db: Database, - B_: str, + b_: str, conn: Optional[Connection] = None, - ) -> Optional[BlindedSignature]: - ... + ) -> Optional[BlindedSignature]: ... @abstractmethod async def store_mint_quote( @@ -141,8 +153,7 @@ class LedgerCrud(ABC): quote: MintQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_mint_quote( @@ -151,8 +162,7 @@ class LedgerCrud(ABC): quote_id: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[MintQuote]: - ... + ) -> Optional[MintQuote]: ... @abstractmethod async def get_mint_quote_by_request( @@ -161,8 +171,7 @@ class LedgerCrud(ABC): request: str, db: Database, conn: Optional[Connection] = None, - ) -> Optional[MintQuote]: - ... + ) -> Optional[MintQuote]: ... @abstractmethod async def update_mint_quote( @@ -171,8 +180,7 @@ class LedgerCrud(ABC): quote: MintQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... # @abstractmethod # async def update_mint_quote_paid( @@ -191,8 +199,7 @@ class LedgerCrud(ABC): quote: MeltQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... @abstractmethod async def get_melt_quote( @@ -202,8 +209,7 @@ class LedgerCrud(ABC): db: Database, checking_id: Optional[str] = None, conn: Optional[Connection] = None, - ) -> Optional[MeltQuote]: - ... + ) -> Optional[MeltQuote]: ... @abstractmethod async def update_melt_quote( @@ -212,8 +218,7 @@ class LedgerCrud(ABC): quote: MeltQuote, db: Database, conn: Optional[Connection] = None, - ) -> None: - ... + ) -> None: ... class LedgerCrudSqlite(LedgerCrud): @@ -228,8 +233,8 @@ class LedgerCrudSqlite(LedgerCrud): *, db: Database, amount: int, - B_: str, - C_: str, + b_: str, + c_: str, id: str, e: str = "", s: str = "", @@ -238,13 +243,13 @@ class LedgerCrudSqlite(LedgerCrud): await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'promises')} - (amount, B_b, C_b, e, s, id, created) + (amount, b_, c_, dleq_e, dleq_s, id, created) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( amount, - B_, - C_, + b_, + c_, e, s, id, @@ -256,17 +261,17 @@ class LedgerCrudSqlite(LedgerCrud): self, *, db: Database, - B_: str, + b_: str, conn: Optional[Connection] = None, ) -> Optional[BlindedSignature]: row = await (conn or db).fetchone( f""" SELECT * from {table_with_schema(db, 'promises')} - WHERE B_b = ? + WHERE b_ = ? """, - (str(B_),), + (str(b_),), ) - return BlindedSignature(amount=row[0], C_=row[2], id=row[3]) if row else None + return BlindedSignature.from_row(row) if row else None async def get_spent_proofs( self, @@ -286,14 +291,15 @@ class LedgerCrudSqlite(LedgerCrud): *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, ) -> None: # we add the proof and secret to the used list await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'proofs_used')} - (amount, C, secret, Y, id, witness, created) - VALUES (?, ?, ?, ?, ?, ?, ?) + (amount, c, secret, y, id, witness, created, melt_quote) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( proof.amount, @@ -303,9 +309,39 @@ class LedgerCrudSqlite(LedgerCrud): proof.id, proof.witness, timestamp_now(db), + quote_id, ), ) + async def get_all_melt_quotes_from_pending_proofs( + self, + *, + db: Database, + conn: Optional[Connection] = None, + ) -> List[MeltQuote]: + rows = await (conn or db).fetchall( + f""" + SELECT * from {table_with_schema(db, 'melt_quotes')} WHERE quote in (SELECT DISTINCT melt_quote FROM {table_with_schema(db, 'proofs_pending')}) + """ + ) + return [MeltQuote.from_row(r) for r in rows] + + async def get_pending_proofs_for_quote( + self, + *, + quote_id: str, + db: Database, + conn: Optional[Connection] = None, + ) -> List[Proof]: + rows = await (conn or db).fetchall( + f""" + SELECT * from {table_with_schema(db, 'proofs_pending')} + WHERE melt_quote = ? + """, + (quote_id,), + ) + return [Proof(**r) for r in rows] + async def get_proofs_pending( self, *, @@ -316,7 +352,7 @@ class LedgerCrudSqlite(LedgerCrud): rows = await (conn or db).fetchall( f""" SELECT * from {table_with_schema(db, 'proofs_pending')} - WHERE Y IN ({','.join(['?']*len(Ys))}) + WHERE y IN ({','.join(['?']*len(Ys))}) """, tuple(Ys), ) @@ -327,21 +363,25 @@ class LedgerCrudSqlite(LedgerCrud): *, db: Database, proof: Proof, + quote_id: Optional[str] = None, conn: Optional[Connection] = None, ) -> None: # we add the proof and secret to the used list await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'proofs_pending')} - (amount, C, secret, Y, created) - VALUES (?, ?, ?, ?, ?) + (amount, c, secret, y, id, witness, created, melt_quote) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( proof.amount, proof.C, proof.secret, proof.Y, + proof.id, + proof.witness, timestamp_now(db), + quote_id, ), ) @@ -628,7 +668,7 @@ class LedgerCrudSqlite(LedgerCrud): row = await (conn or db).fetchone( f""" SELECT * from {table_with_schema(db, 'proofs_used')} - WHERE Y = ? + WHERE y = ? """, (Y,), ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 255a61b..c4f2ea0 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -93,6 +93,83 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): self.pubkey = derive_pubkey(self.seed) self.spent_proofs: Dict[str, Proof] = {} + # ------- STARTUP ------- + + async def startup_ledger(self): + await self._startup_ledger() + await self._check_pending_proofs_and_melt_quotes() + + async def _startup_ledger(self): + if settings.mint_cache_secrets: + await self.load_used_proofs() + await self.init_keysets() + + for derivation_path in settings.mint_derivation_path_list: + await self.activate_keyset(derivation_path=derivation_path) + + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.warning( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'", + RuntimeWarning, + ) + logger.info(f"Backend balance: {status.balance} {unit.name}") + + logger.info(f"Data dir: {settings.cashu_dir}") + + async def _check_pending_proofs_and_melt_quotes(self): + """Startup routine that checks all pending proofs for their melt state and either invalidates + them for a successful melt or deletes them if the melt failed. + """ + # get all pending melt quotes + melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( + db=self.db + ) + if not melt_quotes: + return + for quote in melt_quotes: + # get pending proofs for quote + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote.quote, db=self.db + ) + # check with the backend whether the quote has been paid during downtime + payment = await self.backends[Method[quote.method]][ + Unit[quote.unit] + ].get_payment_status(quote.checking_id) + if payment.paid: + logger.info(f"Melt quote {quote.quote} state: paid") + quote.paid_time = int(time.time()) + quote.paid = True + if payment.fee: + quote.fee_paid = payment.fee.to(Unit[quote.unit]).amount + quote.proof = payment.preimage or "" + await self.crud.update_melt_quote(quote=quote, db=self.db) + # invalidate proofs + await self._invalidate_proofs( + proofs=pending_proofs, quote_id=quote.quote + ) + # unset pending + await self._unset_proofs_pending(pending_proofs) + elif payment.failed: + logger.info(f"Melt quote {quote.quote} state: failed") + + # unset pending + await self._unset_proofs_pending(pending_proofs) + elif payment.pending: + logger.info(f"Melt quote {quote.quote} state: pending") + pass + else: + logger.error("Melt quote state unknown") + pass + # ------- KEYS ------- async def activate_keyset( @@ -229,7 +306,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): # ------- ECASH ------- async def _invalidate_proofs( - self, proofs: List[Proof], conn: Optional[Connection] = None + self, + *, + proofs: List[Proof], + quote_id: Optional[str] = None, + conn: Optional[Connection] = None, ) -> None: """Adds proofs to the set of spent proofs and stores them in the db. @@ -241,7 +322,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): async with get_db_connection(self.db, conn) as conn: # store in db for p in proofs: - await self.crud.invalidate_proof(proof=p, db=self.db, conn=conn) + await self.crud.invalidate_proof( + proof=p, db=self.db, quote_id=quote_id, conn=conn + ) async def _generate_change_promises( self, @@ -708,14 +791,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): ) # verify inputs and their spending conditions + # note, we do not verify outputs here, as they are only used for returning overpaid fees + # we should have used _verify_outputs here already (see above) await self.verify_inputs_and_outputs(proofs=proofs) # set proofs to pending to avoid race conditions - await self._set_proofs_pending(proofs) + await self._set_proofs_pending(proofs, quote_id=melt_quote.quote) try: # 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) - # quote not paid yet (not internal), pay it with the backend if not melt_quote.paid: logger.debug(f"Lightning: pay invoice {melt_quote.request}") @@ -742,7 +826,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): await self.crud.update_melt_quote(quote=melt_quote, db=self.db) # melt successful, invalidate proofs - await self._invalidate_proofs(proofs) + await self._invalidate_proofs(proofs=proofs, quote_id=melt_quote.quote) # prepare change to compensate wallet for overpaid fees return_promises: List[BlindedSignature] = [] @@ -802,7 +886,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): async with get_db_connection(self.db) as conn: # we do this in a single db transaction promises = await self._generate_promises(outputs, keyset, conn) - await self._invalidate_proofs(proofs, conn) + await self._invalidate_proofs(proofs=proofs, conn=conn) except Exception as e: logger.trace(f"split failed: {e}") @@ -823,7 +907,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): for output in outputs: logger.trace(f"looking for promise: {output}") promise = await self.crud.get_promise( - B_=output.B_, db=self.db, conn=conn + b_=output.B_, db=self.db, conn=conn ) if promise is not None: # BEGIN backwards compatibility mints pre `m007_proofs_and_promises_store_id` @@ -890,8 +974,8 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): await self.crud.store_promise( amount=amount, id=keyset_id, - B_=B_.serialize().hex(), - C_=C_.serialize().hex(), + b_=B_.serialize().hex(), + c_=C_.serialize().hex(), e=e.serialize(), s=s.serialize(), db=self.db, @@ -950,12 +1034,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): ) return states - async def _set_proofs_pending(self, proofs: List[Proof]) -> None: + async def _set_proofs_pending( + self, proofs: List[Proof], quote_id: Optional[str] = None + ) -> None: """If none of the proofs is in the pending table (_validate_proofs_pending), adds proofs to the list of pending proofs or removes them. Used as a mutex for proofs. Args: proofs (List[Proof]): Proofs to add to pending table. + quote_id (Optional[str]): Melt quote ID. If it is not set, we assume the pending tokens to be from a swap. Raises: Exception: At least one proof already in pending table. @@ -967,9 +1054,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): try: for p in proofs: await self.crud.set_proof_pending( - proof=p, db=self.db, conn=conn + proof=p, db=self.db, quote_id=quote_id, conn=conn ) - except Exception: + except Exception as e: + logger.error(f"Failed to set proofs pending: {e}") raise TransactionError("Failed to set proofs pending.") async def _unset_proofs_pending(self, proofs: List[Proof]) -> None: diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 2291288..3f9c537 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -20,10 +20,10 @@ async def m001_initial(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} ( amount {db.big_int} NOT NULL, - B_b TEXT NOT NULL, - C_b TEXT NOT NULL, + b_b TEXT NOT NULL, + c_b TEXT NOT NULL, - UNIQUE (B_b) + UNIQUE (b_b) ); """ @@ -33,7 +33,7 @@ async def m001_initial(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( amount {db.big_int} NOT NULL, - C TEXT NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, UNIQUE (secret) @@ -129,7 +129,7 @@ async def m003_mint_keysets(db: Database): f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} ( id TEXT NOT NULL, - amount INTEGER NOT NULL, + amount {db.big_int} NOT NULL, pubkey TEXT NOT NULL, UNIQUE (id, pubkey) @@ -157,8 +157,8 @@ async def m005_pending_proofs_table(db: Database) -> None: await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, UNIQUE (secret) @@ -283,7 +283,7 @@ async def m011_add_quote_tables(db: Database): request TEXT NOT NULL, checking_id TEXT NOT NULL, unit TEXT NOT NULL, - amount INTEGER NOT NULL, + amount {db.big_int} NOT NULL, paid BOOL NOT NULL, issued BOOL NOT NULL, created_time TIMESTAMP, @@ -303,12 +303,12 @@ async def m011_add_quote_tables(db: Database): request TEXT NOT NULL, checking_id TEXT NOT NULL, unit TEXT NOT NULL, - amount INTEGER NOT NULL, - fee_reserve INTEGER, + amount {db.big_int} NOT NULL, + fee_reserve {db.big_int}, paid BOOL NOT NULL, created_time TIMESTAMP, paid_time TIMESTAMP, - fee_paid INTEGER, + fee_paid {db.big_int}, proof TEXT, UNIQUE (quote) @@ -440,11 +440,11 @@ async def m014_proofs_add_Y_column(db: Database): await drop_balance_views(db, conn) await conn.execute( - f"ALTER TABLE {table_with_schema(db, 'proofs_used')} ADD COLUMN Y TEXT" + f"ALTER TABLE {table_with_schema(db, 'proofs_used')} ADD COLUMN y TEXT" ) for proof in proofs_used: await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_used')} SET Y = '{proof.Y}'" + f"UPDATE {table_with_schema(db, 'proofs_used')} SET y = '{proof.Y}'" f" WHERE secret = '{proof.secret}'" ) # Copy proofs_used to proofs_used_old and create a new table proofs_used @@ -461,11 +461,11 @@ async def m014_proofs_add_Y_column(db: Database): await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, id TEXT, - Y TEXT, + y TEXT, created TIMESTAMP, witness TEXT, @@ -475,19 +475,19 @@ async def m014_proofs_add_Y_column(db: Database): """ ) await conn.execute( - f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, C, " - "secret, id, Y, created, witness) SELECT amount, C, secret, id, Y," + f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, c, " + "secret, id, y, created, witness) SELECT amount, c, secret, id, y," f" created, witness FROM {table_with_schema(db, 'proofs_used_old')}" ) await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_used_old')}") - # add column Y to proofs_pending + # add column y to proofs_pending await conn.execute( - f"ALTER TABLE {table_with_schema(db, 'proofs_pending')} ADD COLUMN Y TEXT" + f"ALTER TABLE {table_with_schema(db, 'proofs_pending')} ADD COLUMN y TEXT" ) for proof in proofs_pending: await conn.execute( - f"UPDATE {table_with_schema(db, 'proofs_pending')} SET Y = '{proof.Y}'" + f"UPDATE {table_with_schema(db, 'proofs_pending')} SET y = '{proof.Y}'" f" WHERE secret = '{proof.secret}'" ) @@ -507,10 +507,10 @@ async def m014_proofs_add_Y_column(db: Database): await conn.execute( f""" CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( - amount INTEGER NOT NULL, - C TEXT NOT NULL, + amount {db.big_int} NOT NULL, + c TEXT NOT NULL, secret TEXT NOT NULL, - Y TEXT, + y TEXT, id TEXT, created TIMESTAMP, @@ -520,8 +520,8 @@ async def m014_proofs_add_Y_column(db: Database): """ ) await conn.execute( - f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, C, " - "secret, Y, id, created) SELECT amount, C, secret, Y, id, created" + f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, c, " + "secret, y, id, created) SELECT amount, c, secret, y, id, created" f" FROM {table_with_schema(db, 'proofs_pending_old')}" ) @@ -531,7 +531,7 @@ async def m014_proofs_add_Y_column(db: Database): await create_balance_views(db, conn) -async def m015_add_index_Y_to_proofs_used(db: Database): +async def m015_add_index_Y_to_proofs_used_and_pending(db: Database): # create index on proofs_used table for Y async with db.connect() as conn: await conn.execute( @@ -540,6 +540,12 @@ async def m015_add_index_Y_to_proofs_used(db: Database): f" {table_with_schema(db, 'proofs_used')} (Y)" ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS" + " proofs_pending_Y_idx ON" + f" {table_with_schema(db, 'proofs_pending')} (Y)" + ) + async def m016_recompute_Y_with_new_h2c(db: Database): # get all proofs_used and proofs_pending from the database and compute Y for each of them @@ -570,12 +576,12 @@ async def m016_recompute_Y_with_new_h2c(db: Database): f"('{y}', '{secret}')" for y, secret in proofs_used_data ) await conn.execute( - f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + f"INSERT INTO tmp_proofs_used (y, secret) VALUES {values_placeholder}", ) await conn.execute( f""" UPDATE {table_with_schema(db, 'proofs_used')} - SET Y = tmp_proofs_used.Y + SET y = tmp_proofs_used.y FROM tmp_proofs_used WHERE {table_with_schema(db, 'proofs_used')}.secret = tmp_proofs_used.secret """ @@ -590,12 +596,12 @@ async def m016_recompute_Y_with_new_h2c(db: Database): f"('{y}', '{secret}')" for y, secret in proofs_pending_data ) await conn.execute( - f"INSERT INTO tmp_proofs_used (Y, secret) VALUES {values_placeholder}", + f"INSERT INTO tmp_proofs_used (y, secret) VALUES {values_placeholder}", ) await conn.execute( f""" UPDATE {table_with_schema(db, 'proofs_pending')} - SET Y = tmp_proofs_pending.Y + SET y = tmp_proofs_pending.y FROM tmp_proofs_pending WHERE {table_with_schema(db, 'proofs_pending')}.secret = tmp_proofs_pending.secret """ @@ -606,3 +612,109 @@ async def m016_recompute_Y_with_new_h2c(db: Database): await conn.execute("DROP TABLE tmp_proofs_used") if len(proofs_pending_data): await conn.execute("DROP TABLE tmp_proofs_pending") + + +async def m017_foreign_keys_proof_tables(db: Database): + """ + Create a foreign key relationship between the keyset id in the proof tables and the keyset table. + + Create a foreign key relationship between the keyset id in the promises table and the keyset table. + + Create a foreign key relationship between the quote id in the melt_quotes + and the proofs_used and proofs_pending tables. + + NOTE: We do not use ALTER TABLE directly to add the new column with a foreign key relation because SQLIte does not support it. + """ + + async with db.connect() as conn: + # drop the balance views first + await drop_balance_views(db, conn) + + # add foreign key constraints to proofs_used table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + c TEXT NOT NULL, + secret TEXT NOT NULL, + y TEXT, + witness TEXT, + created TIMESTAMP, + melt_quote TEXT, + + FOREIGN KEY (melt_quote) REFERENCES {table_with_schema(db, 'melt_quotes')}(quote), + + UNIQUE (y) + ); + """ + ) + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'proofs_used_new')} (amount, id, c, secret, y, witness, created) SELECT amount, id, c, secret, y, witness, created FROM {table_with_schema(db, 'proofs_used')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_used')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'proofs_used_new')} RENAME TO {table_with_schema(db, 'proofs_used')}" + ) + + # add foreign key constraints to proofs_pending table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + c TEXT NOT NULL, + secret TEXT NOT NULL, + y TEXT, + witness TEXT, + created TIMESTAMP, + melt_quote TEXT, + + FOREIGN KEY (melt_quote) REFERENCES {table_with_schema(db, 'melt_quotes')}(quote), + + UNIQUE (y) + ); + """ + ) + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'proofs_pending_new')} (amount, id, c, secret, y, created) SELECT amount, id, c, secret, y, created FROM {table_with_schema(db, 'proofs_pending')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'proofs_pending')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'proofs_pending_new')} RENAME TO {table_with_schema(db, 'proofs_pending')}" + ) + + # add foreign key constraints to promises table + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises_new')} ( + amount {db.big_int} NOT NULL, + id TEXT, + b_ TEXT NOT NULL, + c_ TEXT NOT NULL, + dleq_e TEXT, + dleq_s TEXT, + created TIMESTAMP, + mint_quote TEXT, + swap_id TEXT, + + FOREIGN KEY (mint_quote) REFERENCES {table_with_schema(db, 'mint_quotes')}(quote), + + UNIQUE (b_) + ); + """ + ) + + await conn.execute( + f"INSERT INTO {table_with_schema(db, 'promises_new')} (amount, id, b_, c_, dleq_e, dleq_s, created) SELECT amount, id, b_b, c_b, e, s, created FROM {table_with_schema(db, 'promises')}" + ) + await conn.execute(f"DROP TABLE {table_with_schema(db, 'promises')}") + await conn.execute( + f"ALTER TABLE {table_with_schema(db, 'promises_new')} RENAME TO {table_with_schema(db, 'promises')}" + ) + + # recreate the balance views + await create_balance_views(db, conn) + + # recreate indices + await m015_add_index_Y_to_proofs_used_and_pending(db) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index cd7b6cf..c055bf6 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -25,7 +25,7 @@ from ..core.base import ( PostSplitRequest, PostSplitResponse, ) -from ..core.errors import CashuError +from ..core.errors import KeysetNotFoundError from ..core.settings import settings from ..mint.startup import ledger from .limit import limiter @@ -142,7 +142,7 @@ async def keyset_keys(keyset_id: str) -> KeysResponse: keyset = ledger.keysets.get(keyset_id) if keyset is None: - raise CashuError(code=0, detail="keyset not found") + raise KeysetNotFoundError(keyset_id) keyset_for_response = KeysResponseKeyset( id=keyset.id, diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 94d16e6..3563525 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -16,6 +16,11 @@ from ..mint import migrations from ..mint.crud import LedgerCrudSqlite from ..mint.ledger import Ledger +# kill the program if python runs in non-__debug__ mode +# which could lead to asserts not being executed for optimized code +if not __debug__: + raise Exception("Nutshell cannot run in non-debug mode.") + logger.debug("Enviroment Settings:") for key, value in settings.dict().items(): if key in [ @@ -79,29 +84,6 @@ async def rotate_keys(n_seconds=60): async def start_mint_init(): await migrate_databases(ledger.db, migrations) - if settings.mint_cache_secrets: - await ledger.load_used_proofs() - await ledger.init_keysets() - - for derivation_path in settings.mint_derivation_path_list: - await ledger.activate_keyset(derivation_path=derivation_path) - - for method in ledger.backends: - for unit in ledger.backends[method]: - logger.info( - f"Using {ledger.backends[method][unit].__class__.__name__} backend for" - f" method: '{method.name}' and unit: '{unit.name}'" - ) - status = await ledger.backends[method][unit].status() - if status.error_message: - logger.warning( - "The backend for" - f" {ledger.backends[method][unit].__class__.__name__} isn't" - f" working properly: '{status.error_message}'", - RuntimeWarning, - ) - logger.info(f"Backend balance: {status.balance} {unit.name}") - - logger.info(f"Data dir: {settings.cashu_dir}") + await ledger.startup_ledger() logger.info("Mint started.") # asyncio.create_task(rotate_keys()) diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index de38dca..c11fbe6 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -143,7 +143,7 @@ class LedgerVerification( async with self.db.connect() as conn: for output in outputs: promise = await self.crud.get_promise( - B_=output.B_, db=self.db, conn=conn + b_=output.B_, db=self.db, conn=conn ) result.append(False if promise is None else True) return result diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 6332d5d..69aa644 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -247,7 +247,7 @@ async def invoice(ctx: Context, amount: float, id: str, split: int, no_check: bo await wallet.load_mint() await print_balance(ctx) amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) - print(f"Requesting invoice for {wallet.unit.str(amount)} {wallet.unit}.") + print(f"Requesting invoice for {wallet.unit.str(amount)}.") # in case the user wants a specific split, we create a list of amounts optional_split = None if split: diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index d4a5479..986e5e4 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -1014,7 +1014,7 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets): n_change_outputs * [1], change_secrets, change_rs ) - # store the melt_id in proofs + # store the melt_id in proofs db async with self.db.connect() as conn: for p in proofs: p.melt_id = quote_id diff --git a/tests/conftest.py b/tests/conftest.py index af682cc..6d1ac1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,9 +94,7 @@ def mint(): async def ledger(): async def start_mint_init(ledger: Ledger): await migrate_databases(ledger.db, migrations_mint) - if settings.mint_cache_secrets: - await ledger.load_used_proofs() - await ledger.init_keysets() + await ledger.startup_ledger() if not settings.mint_database.startswith("postgres"): # clear sqlite database diff --git a/tests/helpers.py b/tests/helpers.py index 456ab21..674952d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -32,6 +32,7 @@ is_regtest: bool = not is_fake is_deprecated_api_only = settings.debug_mint_only_deprecated is_github_actions = os.getenv("GITHUB_ACTIONS") == "true" is_postgres = settings.mint_database.startswith("postgres") +SLEEP_TIME = 1 if not is_github_actions else 2 docker_lightning_cli = [ "docker", @@ -156,31 +157,6 @@ def pay_onchain(address: str, sats: int) -> str: return run_cmd(cmd) -# def clean_database(settings): -# if DB_TYPE == POSTGRES: -# db_url = make_url(settings.lnbits_database_url) - -# conn = psycopg2.connect(settings.lnbits_database_url) -# conn.autocommit = True -# with conn.cursor() as cur: -# try: -# cur.execute("DROP DATABASE lnbits_test") -# except psycopg2.errors.InvalidCatalogName: -# pass -# cur.execute("CREATE DATABASE lnbits_test") - -# db_url.database = "lnbits_test" -# settings.lnbits_database_url = str(db_url) - -# core.db.__init__("database") - -# conn.close() -# else: -# # FIXME: do this once mock data is removed from test data folder -# # os.remove(settings.lnbits_data_folder + "/database.sqlite3") -# pass - - def pay_if_regtest(bolt11: str): if is_regtest: pay_real_invoice(bolt11) diff --git a/tests/test_mint_init.py b/tests/test_mint_init.py index 77f111b..7bc4567 100644 --- a/tests/test_mint_init.py +++ b/tests/test_mint_init.py @@ -1,13 +1,27 @@ -from typing import List +import asyncio +from typing import List, Tuple +import bolt11 import pytest +import pytest_asyncio -from cashu.core.base import Proof +from cashu.core.base import MeltQuote, Proof, SpentState from cashu.core.crypto.aes import AESCipher from cashu.core.db import Database from cashu.core.settings import settings from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + cancel_invoice, + get_hold_invoice, + is_fake, + is_regtest, + pay_if_regtest, + settle_invoice, +) SEED = "TEST_PRIVATE_KEY" DERIVATION_PATH = "m/0'/0'/0'" @@ -30,6 +44,17 @@ def assert_amt(proofs: List[Proof], expected: int): assert [p.amount for p in proofs] == expected +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet_mint_api_deprecated", + name="wallet_mint_api_deprecated", + ) + await wallet1.load_mint() + yield wallet1 + + @pytest.mark.asyncio async def test_init_keysets_with_duplicates(ledger: Ledger): ledger.keysets = {} @@ -126,3 +151,251 @@ async def test_decrypt_seed(): pubkeys_encrypted[1].serialize().hex() == "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" ) + + +async def create_pending_melts( + ledger: Ledger, check_id: str = "checking_id" +) -> Tuple[Proof, MeltQuote]: + """Helper function for startup tests for fakewallet. Creates fake pending melt + quote and fake proofs that are in the pending table that look like they're being + used to pay the pending melt quote.""" + quote_id = "quote_id" + quote = MeltQuote( + quote=quote_id, + method="bolt11", + request="asdasd", + checking_id=check_id, + unit="sat", + paid=False, + amount=100, + fee_reserve=1, + ) + await ledger.crud.store_melt_quote( + quote=quote, + db=ledger.db, + ) + pending_proof = Proof(amount=123, C="asdasd", secret="asdasd", id=quote_id) + await ledger.crud.set_proof_pending( + db=ledger.db, + proof=pending_proof, + quote_id=quote_id, + ) + # expect a pending melt quote + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + return pending_proof, quote + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only fake wallet") +async def test_startup_fakewallet_pending_quote_success(ledger: Ledger): + """Startup routine test. Expects that a pending proofs are removed form the pending db + after the startup routine determines that the associated melt quote was paid.""" + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = True + # run startup routinge + await ledger.startup_ledger() + + # expect that no pending tokens are in db anymore + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are spent + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.spent + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only fake wallet") +async def test_startup_fakewallet_pending_quote_failure(ledger: Ledger): + """Startup routine test. Expects that a pending proofs are removed form the pending db + after the startup routine determines that the associated melt quote failed to pay. + + The failure is simulated by setting the fakewallet_payment_state to False. + """ + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = False + # run startup routinge + await ledger.startup_ledger() + + # expect that no pending tokens are in db anymore + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are unspent + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.unspent + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only for fake wallet") +async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger): + pending_proof, quote = await create_pending_melts(ledger) + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + settings.fakewallet_payment_state = None + # run startup routinge + await ledger.startup_ledger() + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([pending_proof.Y]) + assert states[0].state == SpentState.pending + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + # settle_invoice(preimage=preimage) + + # run startup routinge + await ledger.startup_ledger() + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + # only now settle the invoice + settle_invoice(preimage=preimage) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + # expect that proofs are pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + settle_invoice(preimage=preimage) + await asyncio.sleep(SLEEP_TIME) + + # run startup routinge + await ledger.startup_ledger() + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are spent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.spent for s in states]) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Ledger): + """Simulate a failure to pay the hodl invoice by canceling it.""" + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + invoice_obj = bolt11.decode(invoice_payment_request) + preimage_hash = invoice_obj.payment_hash + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + # expect that proofs are pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + cancel_invoice(preimage_hash=preimage_hash) + await asyncio.sleep(SLEEP_TIME) + + # run startup routinge + await ledger.startup_ledger() + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes + + # expect that proofs are unspent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.unspent for s in states]) diff --git a/tests/test_mint_regtest.py b/tests/test_mint_regtest.py new file mode 100644 index 0000000..043843c --- /dev/null +++ b/tests/test_mint_regtest.py @@ -0,0 +1,80 @@ +import asyncio + +import pytest +import pytest_asyncio + +from cashu.core.base import SpentState +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + get_hold_invoice, + is_fake, + pay_if_regtest, + settle_invoice, +) + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote)) + # asyncio.create_task( + # wallet.pay_lightning( + # proofs=send_proofs, + # invoice=invoice_payment_request, + # fee_reserve_sat=quote.fee_reserve, + # quote_id=quote.quote, + # ) + # ) + await asyncio.sleep(SLEEP_TIME) + + # expect that melt quote is still pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert melt_quotes + + # expect that proofs are still pending + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.pending for s in states]) + + # only now settle the invoice + settle_invoice(preimage=preimage) + await asyncio.sleep(SLEEP_TIME) + + # expect that proofs are now spent + states = await ledger.check_proofs_state([p.Y for p in send_proofs]) + assert all([s.state == SpentState.spent for s in states]) + + # expect that no melt quote is pending + melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs( + db=ledger.db + ) + assert not melt_quotes diff --git a/tests/test_wallet_regtest.py b/tests/test_wallet_regtest.py new file mode 100644 index 0000000..7a8c61c --- /dev/null +++ b/tests/test_wallet_regtest.py @@ -0,0 +1,107 @@ +import asyncio + +import bolt11 +import pytest +import pytest_asyncio + +from cashu.core.base import SpentState +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.conftest import SERVER_ENDPOINT +from tests.helpers import ( + SLEEP_TIME, + cancel_invoice, + get_hold_invoice, + is_fake, + pay_if_regtest, + settle_invoice, +) + + +@pytest_asyncio.fixture(scope="function") +async def wallet(): + wallet = await Wallet.with_db( + url=SERVER_ENDPOINT, + db="test_data/wallet", + name="wallet", + ) + await wallet.load_mint() + yield wallet + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.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]) + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger): + # fill wallet + invoice = await wallet.request_mint(64) + pay_if_regtest(invoice.bolt11) + await wallet.mint(64, id=invoice.id) + assert wallet.balance == 64 + + # create hodl invoice + preimage, invoice_dict = get_hold_invoice(16) + invoice_payment_request = str(invoice_dict["payment_request"]) + invoice_obj = bolt11.decode(invoice_payment_request) + preimage_hash = invoice_obj.payment_hash + + # wallet pays the invoice + quote = await wallet.get_pay_amount_with_fees(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + asyncio.create_task( + wallet.pay_lightning( + proofs=send_proofs, + invoice=invoice_payment_request, + fee_reserve_sat=quote.fee_reserve, + quote_id=quote.quote, + ) + ) + await asyncio.sleep(SLEEP_TIME) + + states = await wallet.check_proof_state(send_proofs) + assert all([s.state == SpentState.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]) diff --git a/tests/test_wallet_restore.py b/tests/test_wallet_restore.py index 7670b5c..9064235 100644 --- a/tests/test_wallet_restore.py +++ b/tests/test_wallet_restore.py @@ -172,6 +172,11 @@ async def test_restore_wallet_after_mint(wallet3: Wallet): await wallet3.restore_promises_from_to(0, 20) assert wallet3.balance == 64 + # expect that DLEQ proofs are restored + assert all([p.dleq for p in wallet3.proofs]) + assert all([p.dleq.e for p in wallet3.proofs]) # type: ignore + assert all([p.dleq.s for p in wallet3.proofs]) # type: ignore + @pytest.mark.asyncio async def test_restore_wallet_with_invalid_mnemonic(wallet3: Wallet):