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
This commit is contained in:
callebtc
2024-04-03 17:14:21 +02:00
committed by GitHub
parent 1f1daca232
commit b8ad0e0a8f
23 changed files with 868 additions and 177 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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"],
)

View File

@@ -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"]],

View File

@@ -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,),
)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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,

View File

@@ -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())

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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])

View File

@@ -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

View File

@@ -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])

View File

@@ -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):