Files
nutshell/tests/mint/test_mint_init.py
callebtc fc0e3fe663 Mint: watchdog balance log and killswitch (#705)
* wip store balance

* store balances in watchdog worker

* move mint_auth_database setting

* auth db

* balances returned as Amount (instead of int)

* add test for balance change on invoice receive

* fix 1 test

* cancel tasks on shutdown

* watchdog can now abort

* remove wallet api server

* fix lndgrpc

* fix lnbits balance

* disable watchdog

* balance lnbits msat

* test db watcher with its own database connection

* init superclass only once

* wip: log balance in keysets table

* check max balance using new keyset balance

* fix test

* fix another test

* store fees in keysets

* format

* cleanup

* shorter

* add keyset migration to auth server

* fix fakewallet

* fix db tests

* fix postgres problems during migration 26 (mint)

* fix cln

* ledger

* working with pending

* super fast watchdog, errors

* test new pipeline

* delete walletapi

* delete unneeded files

* revert workflows
2025-05-11 20:29:13 +02:00

529 lines
17 KiB
Python

import asyncio
from typing import List, Tuple
import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Method, Proof, Unit
from cashu.core.crypto.aes import AESCipher
from cashu.core.db import Database
from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult, PaymentStatus
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,
get_real_invoice,
is_fake,
is_regtest,
pay_if_regtest,
settle_invoice,
)
SEED = "TEST_PRIVATE_KEY"
DERIVATION_PATH = "m/0'/0'/0'"
DECRYPTON_KEY = "testdecryptionkey"
ENCRYPTED_SEED = "U2FsdGVkX1_7UU_-nVBMBWDy_9yDu4KeYb7MH8cJTYQGD4RWl82PALH8j-HKzTrI"
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
assert exc.args[0] == msg, Exception(
f"Expected error: {msg}, got: {exc.args[0]}"
)
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
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(ledger: Ledger):
ledger.keysets = {}
await ledger.init_keysets()
assert len(ledger.keysets) == 2
@pytest.mark.asyncio
async def test_ledger_encrypt():
aes = AESCipher(DECRYPTON_KEY)
encrypted = aes.encrypt(SEED.encode())
assert aes.decrypt(encrypted) == SEED
@pytest.mark.asyncio
async def test_ledger_decrypt():
aes = AESCipher(DECRYPTON_KEY)
assert aes.decrypt(ENCRYPTED_SEED) == SEED
@pytest.mark.asyncio
async def test_decrypt_seed():
ledger = Ledger(
db=Database("mint", settings.mint_database),
seed=SEED,
seed_decryption_key=None,
derivation_path=DERIVATION_PATH,
backends={},
crud=LedgerCrudSqlite(),
)
await ledger.init_keysets()
assert ledger.keyset.seed == SEED
private_key_1 = (
ledger.keysets[list(ledger.keysets.keys())[0]].private_keys[1].serialize()
)
assert (
private_key_1
== "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d"
)
pubkeys = ledger.keysets[list(ledger.keysets.keys())[0]].public_keys
assert pubkeys
assert (
pubkeys[1].serialize().hex()
== "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
)
ledger_encrypted = Ledger(
db=Database("mint", settings.mint_database),
seed=ENCRYPTED_SEED,
seed_decryption_key=DECRYPTON_KEY,
derivation_path=DERIVATION_PATH,
backends={},
crud=LedgerCrudSqlite(),
)
await ledger_encrypted.init_keysets()
assert ledger_encrypted.keyset.seed == SEED
private_key_1 = (
ledger_encrypted.keysets[list(ledger_encrypted.keysets.keys())[0]]
.private_keys[1]
.serialize()
)
assert (
private_key_1
== "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d"
)
pubkeys_encrypted = ledger_encrypted.keysets[
list(ledger_encrypted.keysets.keys())[0]
].public_keys
assert pubkeys_encrypted
assert (
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",
state=MeltQuoteState.pending,
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=ledger.keyset.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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.FAILED.name
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.PENDING.name
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only for fake wallet")
async def test_startup_fakewallet_pending_quote_unknown(ledger: Ledger):
# unknown state simulates a failure th check the lightning backend
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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
assert melt_quotes[0].state == MeltQuoteState.pending
# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].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
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
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.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
asyncio.create_task(
wallet.melt(
proofs=send_proofs,
invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
)
await asyncio.sleep(SLEEP_TIME)
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.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
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
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.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
asyncio.create_task(
wallet.melt(
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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.pending for s in states])
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.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
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
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.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
asyncio.create_task(
wallet.melt(
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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.pending for s in states])
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.unspent for s in states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_startup_regtest_pending_quote_unknown(wallet: Wallet, ledger: Ledger):
"""Simulate an unknown payment by executing a pending payment, then
manipulating the melt_quote in the mint's db so that its checking_id
points to an unknown payment."""
# fill wallet
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
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.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
asyncio.create_task(
wallet.melt(
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.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.pending for s in states])
# before we cancel the payment, we manipulate the melt_quote's checking_id so
# that the mint fails to look up the payment and treats the payment as failed during startup
melt_quote = await ledger.crud.get_melt_quote_by_request(
db=ledger.db, request=invoice_payment_request
)
assert melt_quote
assert melt_quote.pending
# manipulate the checking_id 32 bytes hexadecmial
melt_quote.checking_id = "a" * 64
await ledger.crud.update_melt_quote(quote=melt_quote, db=ledger.db)
await asyncio.sleep(SLEEP_TIME)
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
# 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
assert melt_quotes[0].state == MeltQuoteState.pending
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.pending for s in states])
# clean up
cancel_invoice(preimage_hash=preimage_hash)
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_check_nonexisting_melt_quote(wallet: Wallet, ledger: Ledger):
invoice_obj = get_real_invoice(16)
invoice_payment_request = str(invoice_obj["payment_request"])
checking_id = invoice_obj["r_hash"]
quote = MeltQuote(
quote="nonexistingquote",
method="bolt11",
request=invoice_payment_request,
checking_id=checking_id,
unit="sat",
amount=64,
state=MeltQuoteState.pending,
fee_reserve=0,
created_time=0,
)
await ledger.crud.store_melt_quote(quote=quote, db=ledger.db)
# assert that there is one pending melt quote
melt_quotes = await ledger.crud.get_melt_quote(
db=ledger.db, checking_id=quote.checking_id
)
assert melt_quotes
assert melt_quotes.state == MeltQuoteState.pending
# run startup routine
await ledger._check_pending_proofs_and_melt_quotes()
status: PaymentStatus = await ledger.backends[Method.bolt11][
Unit.sat
].get_payment_status(quote.checking_id)
assert status.unknown
# this should NOT remove the pending melt quote
await ledger.get_melt_quote(quote.quote, rollback_unknown=False)
# assert melt quote unpaid
melt_quotes = await ledger.crud.get_melt_quote(
db=ledger.db, checking_id=quote.checking_id
)
assert melt_quotes
assert melt_quotes.state == MeltQuoteState.pending
# this should remove the pending melt quote
await ledger.get_melt_quote(quote.quote, rollback_unknown=True)
# assert melt quote unpaid
melt_quotes = await ledger.crud.get_melt_quote(
db=ledger.db, checking_id=quote.checking_id
)
assert melt_quotes
assert melt_quotes.state == MeltQuoteState.unpaid