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"] poetry-version: ["1.7.1"]
backend-wallet-class: backend-wallet-class:
["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] ["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"]
# mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"]
mint-database: ["./test_data/test_mint"]
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }} 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 . chmod -R 777 .
bash ./start.sh 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 - name: Run Tests
env: env:
WALLET_NAME: test_wallet WALLET_NAME: test_wallet

View File

@@ -101,12 +101,12 @@ class Proof(BaseModel):
time_created: Union[None, str] = "" time_created: Union[None, str] = ""
time_reserved: Union[None, str] = "" time_reserved: Union[None, str] = ""
derivation_path: Union[None, str] = "" # derivation path of the proof derivation_path: Union[None, str] = "" # derivation path of the proof
mint_id: Union[ mint_id: Union[None, str] = (
None, str None # holds the id of the mint operation that created this proof
] = None # holds the id of the mint operation that created this proof )
melt_id: Union[ melt_id: Union[None, str] = (
None, str None # holds the id of the melt operation that destroyed this proof
] = None # holds the id of the melt operation that destroyed this proof )
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -194,6 +194,15 @@ class BlindedSignature(BaseModel):
C_: str # Hex-encoded signature C_: str # Hex-encoded signature
dleq: Optional[DLEQ] = None # DLEQ proof 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): class BlindedMessages(BaseModel):
# NOTE: not used in Pydantic validation # NOTE: not used in Pydantic validation

View File

@@ -63,7 +63,9 @@ class KeysetNotFoundError(KeysetError):
detail = "keyset not found" detail = "keyset not found"
code = 12001 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) super().__init__(self.detail, code=self.code)

View File

@@ -125,6 +125,7 @@ class FakeWalletSettings(MintSettings):
fakewallet_brr: bool = Field(default=True) fakewallet_brr: bool = Field(default=True)
fakewallet_delay_payment: bool = Field(default=False) fakewallet_delay_payment: bool = Field(default=False)
fakewallet_stochastic_invoice: 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) mint_cache_secrets: bool = Field(default=True)

View File

@@ -247,17 +247,21 @@ class CoreLightningRestWallet(LightningBackend):
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if r.is_error or "error" in data or not data.get("pays"): if not data.get("pays"):
raise Exception("error in corelightning-rest response") # 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] pay = data["pays"][0]
fee_msat, preimage = None, None fee_msat, preimage = None, None
if self.statuses[pay["status"]]: if self.statuses[pay["status"]]:
# cut off "msat" and convert to int # cut off "msat" and convert to int
fee_msat = -int(pay["amount_sent_msat"][:-4]) - int( fee_msat = -int(pay["amount_sent_msat"]) - int(pay["amount_msat"])
pay["amount_msat"][:-4]
)
preimage = pay["preimage"] preimage = pay["preimage"]
return PaymentStatus( return PaymentStatus(

View File

@@ -139,7 +139,7 @@ class FakeWallet(LightningBackend):
return PaymentStatus(paid=paid or None) return PaymentStatus(paid=paid or None)
async def get_payment_status(self, _: str) -> PaymentStatus: 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]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:

View File

@@ -151,8 +151,18 @@ class LNbitsWallet(LightningBackend):
if "paid" not in data and "details" not in data: if "paid" not in data and "details" not in data:
return PaymentStatus(paid=None) 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( return PaymentStatus(
paid=data["paid"], paid=paid_value,
fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])),
preimage=data["preimage"], preimage=data["preimage"],
) )

View File

@@ -217,14 +217,20 @@ class LndRestWallet(LightningBackend):
async for json_line in r.aiter_lines(): async for json_line in r.aiter_lines():
try: try:
line = json.loads(json_line) line = json.loads(json_line)
# check for errors
if line.get("error"): if line.get("error"):
logger.error( message = (
line["error"]["message"] line["error"]["message"]
if "message" in line["error"] if "message" in line["error"]
else line["error"] else line["error"]
) )
logger.error(f"LND get_payment_status error: {message}")
return PaymentStatus(paid=None) return PaymentStatus(paid=None)
payment = line.get("result") payment = line.get("result")
# payment exists
if payment is not None and payment.get("status"): if payment is not None and payment.get("status"):
return PaymentStatus( return PaymentStatus(
paid=statuses[payment["status"]], paid=statuses[payment["status"]],

View File

@@ -34,8 +34,7 @@ class LedgerCrud(ABC):
derivation_path: str = "", derivation_path: str = "",
seed: str = "", seed: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List[MintKeyset]: ) -> List[MintKeyset]: ...
...
@abstractmethod @abstractmethod
async def get_spent_proofs( async def get_spent_proofs(
@@ -43,8 +42,7 @@ class LedgerCrud(ABC):
*, *,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List[Proof]: ) -> List[Proof]: ...
...
async def get_proof_used( async def get_proof_used(
self, self,
@@ -52,8 +50,7 @@ class LedgerCrud(ABC):
Y: str, Y: str,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[Proof]: ) -> Optional[Proof]: ...
...
@abstractmethod @abstractmethod
async def invalidate_proof( async def invalidate_proof(
@@ -61,9 +58,26 @@ class LedgerCrud(ABC):
*, *,
db: Database, db: Database,
proof: Proof, proof: Proof,
quote_id: Optional[str] = None,
conn: Optional[Connection] = 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 @abstractmethod
async def get_proofs_pending( async def get_proofs_pending(
@@ -72,8 +86,7 @@ class LedgerCrud(ABC):
Ys: List[str], Ys: List[str],
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List[Proof]: ) -> List[Proof]: ...
...
@abstractmethod @abstractmethod
async def set_proof_pending( async def set_proof_pending(
@@ -81,15 +94,18 @@ class LedgerCrud(ABC):
*, *,
db: Database, db: Database,
proof: Proof, proof: Proof,
quote_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
@abstractmethod @abstractmethod
async def unset_proof_pending( async def unset_proof_pending(
self, *, proof: Proof, db: Database, conn: Optional[Connection] = None self,
) -> None: *,
... proof: Proof,
db: Database,
conn: Optional[Connection] = None,
) -> None: ...
@abstractmethod @abstractmethod
async def store_keyset( async def store_keyset(
@@ -98,16 +114,14 @@ class LedgerCrud(ABC):
db: Database, db: Database,
keyset: MintKeyset, keyset: MintKeyset,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
@abstractmethod @abstractmethod
async def get_balance( async def get_balance(
self, self,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> int: ) -> int: ...
...
@abstractmethod @abstractmethod
async def store_promise( async def store_promise(
@@ -115,24 +129,22 @@ class LedgerCrud(ABC):
*, *,
db: Database, db: Database,
amount: int, amount: int,
B_: str, b_: str,
C_: str, c_: str,
id: str, id: str,
e: str = "", e: str = "",
s: str = "", s: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
@abstractmethod @abstractmethod
async def get_promise( async def get_promise(
self, self,
*, *,
db: Database, db: Database,
B_: str, b_: str,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[BlindedSignature]: ) -> Optional[BlindedSignature]: ...
...
@abstractmethod @abstractmethod
async def store_mint_quote( async def store_mint_quote(
@@ -141,8 +153,7 @@ class LedgerCrud(ABC):
quote: MintQuote, quote: MintQuote,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
@abstractmethod @abstractmethod
async def get_mint_quote( async def get_mint_quote(
@@ -151,8 +162,7 @@ class LedgerCrud(ABC):
quote_id: str, quote_id: str,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[MintQuote]: ) -> Optional[MintQuote]: ...
...
@abstractmethod @abstractmethod
async def get_mint_quote_by_request( async def get_mint_quote_by_request(
@@ -161,8 +171,7 @@ class LedgerCrud(ABC):
request: str, request: str,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[MintQuote]: ) -> Optional[MintQuote]: ...
...
@abstractmethod @abstractmethod
async def update_mint_quote( async def update_mint_quote(
@@ -171,8 +180,7 @@ class LedgerCrud(ABC):
quote: MintQuote, quote: MintQuote,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
# @abstractmethod # @abstractmethod
# async def update_mint_quote_paid( # async def update_mint_quote_paid(
@@ -191,8 +199,7 @@ class LedgerCrud(ABC):
quote: MeltQuote, quote: MeltQuote,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
@abstractmethod @abstractmethod
async def get_melt_quote( async def get_melt_quote(
@@ -202,8 +209,7 @@ class LedgerCrud(ABC):
db: Database, db: Database,
checking_id: Optional[str] = None, checking_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[MeltQuote]: ) -> Optional[MeltQuote]: ...
...
@abstractmethod @abstractmethod
async def update_melt_quote( async def update_melt_quote(
@@ -212,8 +218,7 @@ class LedgerCrud(ABC):
quote: MeltQuote, quote: MeltQuote,
db: Database, db: Database,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None: ...
...
class LedgerCrudSqlite(LedgerCrud): class LedgerCrudSqlite(LedgerCrud):
@@ -228,8 +233,8 @@ class LedgerCrudSqlite(LedgerCrud):
*, *,
db: Database, db: Database,
amount: int, amount: int,
B_: str, b_: str,
C_: str, c_: str,
id: str, id: str,
e: str = "", e: str = "",
s: str = "", s: str = "",
@@ -238,13 +243,13 @@ class LedgerCrudSqlite(LedgerCrud):
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'promises')} 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 (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
( (
amount, amount,
B_, b_,
C_, c_,
e, e,
s, s,
id, id,
@@ -256,17 +261,17 @@ class LedgerCrudSqlite(LedgerCrud):
self, self,
*, *,
db: Database, db: Database,
B_: str, b_: str,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Optional[BlindedSignature]: ) -> Optional[BlindedSignature]:
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
f""" f"""
SELECT * from {table_with_schema(db, 'promises')} 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( async def get_spent_proofs(
self, self,
@@ -286,14 +291,15 @@ class LedgerCrudSqlite(LedgerCrud):
*, *,
db: Database, db: Database,
proof: Proof, proof: Proof,
quote_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
# we add the proof and secret to the used list # we add the proof and secret to the used list
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'proofs_used')} INSERT INTO {table_with_schema(db, 'proofs_used')}
(amount, C, secret, Y, id, witness, created) (amount, c, secret, y, id, witness, created, melt_quote)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
proof.amount, proof.amount,
@@ -303,9 +309,39 @@ class LedgerCrudSqlite(LedgerCrud):
proof.id, proof.id,
proof.witness, proof.witness,
timestamp_now(db), 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( async def get_proofs_pending(
self, self,
*, *,
@@ -316,7 +352,7 @@ class LedgerCrudSqlite(LedgerCrud):
rows = await (conn or db).fetchall( rows = await (conn or db).fetchall(
f""" f"""
SELECT * from {table_with_schema(db, 'proofs_pending')} SELECT * from {table_with_schema(db, 'proofs_pending')}
WHERE Y IN ({','.join(['?']*len(Ys))}) WHERE y IN ({','.join(['?']*len(Ys))})
""", """,
tuple(Ys), tuple(Ys),
) )
@@ -327,21 +363,25 @@ class LedgerCrudSqlite(LedgerCrud):
*, *,
db: Database, db: Database,
proof: Proof, proof: Proof,
quote_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
# we add the proof and secret to the used list # we add the proof and secret to the used list
await (conn or db).execute( await (conn or db).execute(
f""" f"""
INSERT INTO {table_with_schema(db, 'proofs_pending')} INSERT INTO {table_with_schema(db, 'proofs_pending')}
(amount, C, secret, Y, created) (amount, c, secret, y, id, witness, created, melt_quote)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
proof.amount, proof.amount,
proof.C, proof.C,
proof.secret, proof.secret,
proof.Y, proof.Y,
proof.id,
proof.witness,
timestamp_now(db), timestamp_now(db),
quote_id,
), ),
) )
@@ -628,7 +668,7 @@ class LedgerCrudSqlite(LedgerCrud):
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
f""" f"""
SELECT * from {table_with_schema(db, 'proofs_used')} SELECT * from {table_with_schema(db, 'proofs_used')}
WHERE Y = ? WHERE y = ?
""", """,
(Y,), (Y,),
) )

View File

@@ -93,6 +93,83 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
self.pubkey = derive_pubkey(self.seed) self.pubkey = derive_pubkey(self.seed)
self.spent_proofs: Dict[str, Proof] = {} 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 ------- # ------- KEYS -------
async def activate_keyset( async def activate_keyset(
@@ -229,7 +306,11 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
# ------- ECASH ------- # ------- ECASH -------
async def _invalidate_proofs( 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: ) -> None:
"""Adds proofs to the set of spent proofs and stores them in the db. """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: async with get_db_connection(self.db, conn) as conn:
# store in db # store in db
for p in proofs: 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( async def _generate_change_promises(
self, self,
@@ -708,14 +791,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
) )
# verify inputs and their spending conditions # 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) await self.verify_inputs_and_outputs(proofs=proofs)
# set proofs to pending to avoid race conditions # 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: try:
# settle the transaction internally if there is a mint quote with the same payment request # 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) melt_quote = await self.melt_mint_settle_internally(melt_quote)
# quote not paid yet (not internal), pay it with the backend # quote not paid yet (not internal), pay it with the backend
if not melt_quote.paid: if not melt_quote.paid:
logger.debug(f"Lightning: pay invoice {melt_quote.request}") 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) await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
# melt successful, invalidate proofs # 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 # prepare change to compensate wallet for overpaid fees
return_promises: List[BlindedSignature] = [] return_promises: List[BlindedSignature] = []
@@ -802,7 +886,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
async with get_db_connection(self.db) as conn: async with get_db_connection(self.db) as conn:
# we do this in a single db transaction # we do this in a single db transaction
promises = await self._generate_promises(outputs, keyset, conn) 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: except Exception as e:
logger.trace(f"split failed: {e}") logger.trace(f"split failed: {e}")
@@ -823,7 +907,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
for output in outputs: for output in outputs:
logger.trace(f"looking for promise: {output}") logger.trace(f"looking for promise: {output}")
promise = await self.crud.get_promise( 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: if promise is not None:
# BEGIN backwards compatibility mints pre `m007_proofs_and_promises_store_id` # BEGIN backwards compatibility mints pre `m007_proofs_and_promises_store_id`
@@ -890,8 +974,8 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
await self.crud.store_promise( await self.crud.store_promise(
amount=amount, amount=amount,
id=keyset_id, id=keyset_id,
B_=B_.serialize().hex(), b_=B_.serialize().hex(),
C_=C_.serialize().hex(), c_=C_.serialize().hex(),
e=e.serialize(), e=e.serialize(),
s=s.serialize(), s=s.serialize(),
db=self.db, db=self.db,
@@ -950,12 +1034,15 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
) )
return states 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 """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. the list of pending proofs or removes them. Used as a mutex for proofs.
Args: Args:
proofs (List[Proof]): Proofs to add to pending table. 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: Raises:
Exception: At least one proof already in pending table. Exception: At least one proof already in pending table.
@@ -967,9 +1054,10 @@ class Ledger(LedgerVerification, LedgerSpendingConditions):
try: try:
for p in proofs: for p in proofs:
await self.crud.set_proof_pending( 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.") raise TransactionError("Failed to set proofs pending.")
async def _unset_proofs_pending(self, proofs: List[Proof]) -> None: async def _unset_proofs_pending(self, proofs: List[Proof]) -> None:

View File

@@ -20,10 +20,10 @@ async def m001_initial(db: Database):
f""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'promises')} (
amount {db.big_int} NOT NULL, amount {db.big_int} NOT NULL,
B_b TEXT NOT NULL, b_b TEXT NOT NULL,
C_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""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} (
amount {db.big_int} NOT NULL, amount {db.big_int} NOT NULL,
C TEXT NOT NULL, c TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
UNIQUE (secret) UNIQUE (secret)
@@ -129,7 +129,7 @@ async def m003_mint_keysets(db: Database):
f""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'mint_pubkeys')} (
id TEXT NOT NULL, id TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
UNIQUE (id, pubkey) UNIQUE (id, pubkey)
@@ -157,8 +157,8 @@ async def m005_pending_proofs_table(db: Database) -> None:
await conn.execute( await conn.execute(
f""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} (
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
C TEXT NOT NULL, c TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
UNIQUE (secret) UNIQUE (secret)
@@ -283,7 +283,7 @@ async def m011_add_quote_tables(db: Database):
request TEXT NOT NULL, request TEXT NOT NULL,
checking_id TEXT NOT NULL, checking_id TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
paid BOOL NOT NULL, paid BOOL NOT NULL,
issued BOOL NOT NULL, issued BOOL NOT NULL,
created_time TIMESTAMP, created_time TIMESTAMP,
@@ -303,12 +303,12 @@ async def m011_add_quote_tables(db: Database):
request TEXT NOT NULL, request TEXT NOT NULL,
checking_id TEXT NOT NULL, checking_id TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
fee_reserve INTEGER, fee_reserve {db.big_int},
paid BOOL NOT NULL, paid BOOL NOT NULL,
created_time TIMESTAMP, created_time TIMESTAMP,
paid_time TIMESTAMP, paid_time TIMESTAMP,
fee_paid INTEGER, fee_paid {db.big_int},
proof TEXT, proof TEXT,
UNIQUE (quote) UNIQUE (quote)
@@ -440,11 +440,11 @@ async def m014_proofs_add_Y_column(db: Database):
await drop_balance_views(db, conn) await drop_balance_views(db, conn)
await conn.execute( 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: for proof in proofs_used:
await conn.execute( 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}'" f" WHERE secret = '{proof.secret}'"
) )
# Copy proofs_used to proofs_used_old and create a new table proofs_used # 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( await conn.execute(
f""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_used')} (
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
C TEXT NOT NULL, c TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
id TEXT, id TEXT,
Y TEXT, y TEXT,
created TIMESTAMP, created TIMESTAMP,
witness TEXT, witness TEXT,
@@ -475,19 +475,19 @@ async def m014_proofs_add_Y_column(db: Database):
""" """
) )
await conn.execute( await conn.execute(
f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, C, " f"INSERT INTO {table_with_schema(db, 'proofs_used')} (amount, c, "
"secret, id, Y, created, witness) SELECT amount, C, secret, id, Y," "secret, id, y, created, witness) SELECT amount, c, secret, id, y,"
f" created, witness FROM {table_with_schema(db, 'proofs_used_old')}" f" created, witness FROM {table_with_schema(db, 'proofs_used_old')}"
) )
await conn.execute(f"DROP TABLE {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( 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: for proof in proofs_pending:
await conn.execute( 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}'" f" WHERE secret = '{proof.secret}'"
) )
@@ -507,10 +507,10 @@ async def m014_proofs_add_Y_column(db: Database):
await conn.execute( await conn.execute(
f""" f"""
CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} ( CREATE TABLE IF NOT EXISTS {table_with_schema(db, 'proofs_pending')} (
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
C TEXT NOT NULL, c TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
Y TEXT, y TEXT,
id TEXT, id TEXT,
created TIMESTAMP, created TIMESTAMP,
@@ -520,8 +520,8 @@ async def m014_proofs_add_Y_column(db: Database):
""" """
) )
await conn.execute( await conn.execute(
f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, C, " f"INSERT INTO {table_with_schema(db, 'proofs_pending')} (amount, c, "
"secret, Y, id, created) SELECT amount, C, secret, Y, id, created" "secret, y, id, created) SELECT amount, c, secret, y, id, created"
f" FROM {table_with_schema(db, 'proofs_pending_old')}" 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) 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 # create index on proofs_used table for Y
async with db.connect() as conn: async with db.connect() as conn:
await conn.execute( 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)" 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): 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 # 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 f"('{y}', '{secret}')" for y, secret in proofs_used_data
) )
await conn.execute( 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( await conn.execute(
f""" f"""
UPDATE {table_with_schema(db, 'proofs_used')} UPDATE {table_with_schema(db, 'proofs_used')}
SET Y = tmp_proofs_used.Y SET y = tmp_proofs_used.y
FROM tmp_proofs_used FROM tmp_proofs_used
WHERE {table_with_schema(db, 'proofs_used')}.secret = tmp_proofs_used.secret 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 f"('{y}', '{secret}')" for y, secret in proofs_pending_data
) )
await conn.execute( 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( await conn.execute(
f""" f"""
UPDATE {table_with_schema(db, 'proofs_pending')} UPDATE {table_with_schema(db, 'proofs_pending')}
SET Y = tmp_proofs_pending.Y SET y = tmp_proofs_pending.y
FROM tmp_proofs_pending FROM tmp_proofs_pending
WHERE {table_with_schema(db, 'proofs_pending')}.secret = tmp_proofs_pending.secret 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") await conn.execute("DROP TABLE tmp_proofs_used")
if len(proofs_pending_data): if len(proofs_pending_data):
await conn.execute("DROP TABLE tmp_proofs_pending") 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, PostSplitRequest,
PostSplitResponse, PostSplitResponse,
) )
from ..core.errors import CashuError from ..core.errors import KeysetNotFoundError
from ..core.settings import settings from ..core.settings import settings
from ..mint.startup import ledger from ..mint.startup import ledger
from .limit import limiter from .limit import limiter
@@ -142,7 +142,7 @@ async def keyset_keys(keyset_id: str) -> KeysResponse:
keyset = ledger.keysets.get(keyset_id) keyset = ledger.keysets.get(keyset_id)
if keyset is None: if keyset is None:
raise CashuError(code=0, detail="keyset not found") raise KeysetNotFoundError(keyset_id)
keyset_for_response = KeysResponseKeyset( keyset_for_response = KeysResponseKeyset(
id=keyset.id, id=keyset.id,

View File

@@ -16,6 +16,11 @@ from ..mint import migrations
from ..mint.crud import LedgerCrudSqlite from ..mint.crud import LedgerCrudSqlite
from ..mint.ledger import Ledger 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:") logger.debug("Enviroment Settings:")
for key, value in settings.dict().items(): for key, value in settings.dict().items():
if key in [ if key in [
@@ -79,29 +84,6 @@ async def rotate_keys(n_seconds=60):
async def start_mint_init(): async def start_mint_init():
await migrate_databases(ledger.db, migrations) await migrate_databases(ledger.db, migrations)
if settings.mint_cache_secrets: await ledger.startup_ledger()
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}")
logger.info("Mint started.") logger.info("Mint started.")
# asyncio.create_task(rotate_keys()) # asyncio.create_task(rotate_keys())

View File

@@ -143,7 +143,7 @@ class LedgerVerification(
async with self.db.connect() as conn: async with self.db.connect() as conn:
for output in outputs: for output in outputs:
promise = await self.crud.get_promise( 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) result.append(False if promise is None else True)
return result 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 wallet.load_mint()
await print_balance(ctx) await print_balance(ctx)
amount = int(amount * 100) if wallet.unit == Unit.usd else int(amount) 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 # in case the user wants a specific split, we create a list of amounts
optional_split = None optional_split = None
if split: if split:

View File

@@ -1014,7 +1014,7 @@ class Wallet(LedgerAPI, WalletP2PK, WalletHTLC, WalletSecrets):
n_change_outputs * [1], change_secrets, change_rs 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: async with self.db.connect() as conn:
for p in proofs: for p in proofs:
p.melt_id = quote_id p.melt_id = quote_id

View File

@@ -94,9 +94,7 @@ def mint():
async def ledger(): async def ledger():
async def start_mint_init(ledger: Ledger): async def start_mint_init(ledger: Ledger):
await migrate_databases(ledger.db, migrations_mint) await migrate_databases(ledger.db, migrations_mint)
if settings.mint_cache_secrets: await ledger.startup_ledger()
await ledger.load_used_proofs()
await ledger.init_keysets()
if not settings.mint_database.startswith("postgres"): if not settings.mint_database.startswith("postgres"):
# clear sqlite database # 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_deprecated_api_only = settings.debug_mint_only_deprecated
is_github_actions = os.getenv("GITHUB_ACTIONS") == "true" is_github_actions = os.getenv("GITHUB_ACTIONS") == "true"
is_postgres = settings.mint_database.startswith("postgres") is_postgres = settings.mint_database.startswith("postgres")
SLEEP_TIME = 1 if not is_github_actions else 2
docker_lightning_cli = [ docker_lightning_cli = [
"docker", "docker",
@@ -156,31 +157,6 @@ def pay_onchain(address: str, sats: int) -> str:
return run_cmd(cmd) 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): def pay_if_regtest(bolt11: str):
if is_regtest: if is_regtest:
pay_real_invoice(bolt11) 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
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.crypto.aes import AESCipher
from cashu.core.db import Database from cashu.core.db import Database
from cashu.core.settings import settings from cashu.core.settings import settings
from cashu.mint.crud import LedgerCrudSqlite from cashu.mint.crud import LedgerCrudSqlite
from cashu.mint.ledger import Ledger 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" SEED = "TEST_PRIVATE_KEY"
DERIVATION_PATH = "m/0'/0'/0'" 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 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 @pytest.mark.asyncio
async def test_init_keysets_with_duplicates(ledger: Ledger): async def test_init_keysets_with_duplicates(ledger: Ledger):
ledger.keysets = {} ledger.keysets = {}
@@ -126,3 +151,251 @@ async def test_decrypt_seed():
pubkeys_encrypted[1].serialize().hex() pubkeys_encrypted[1].serialize().hex()
== "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" == "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) await wallet3.restore_promises_from_to(0, 20)
assert wallet3.balance == 64 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 @pytest.mark.asyncio
async def test_restore_wallet_with_invalid_mnemonic(wallet3: Wallet): async def test_restore_wallet_with_invalid_mnemonic(wallet3: Wallet):