Tests: split wallet test from mint test pipeline (#748)

* split wallet test from mint test pipeline

* regtest mint and wallet

* fix

* fix

* move mint tests

* real invoice in regtest mpp
This commit is contained in:
callebtc
2025-05-11 14:14:49 +02:00
committed by GitHub
parent 619d06f0ab
commit 38bdb9ce76
35 changed files with 137 additions and 15 deletions

237
tests/mint/test_mint.py Normal file
View File

@@ -0,0 +1,237 @@
from typing import List
import pytest
from cashu.core.base import BlindedMessage, MintKeyset, Proof, Unit
from cashu.core.crypto.b_dhke import step1_alice
from cashu.core.helpers import calculate_number_of_blank_outputs
from cashu.core.models import PostMintQuoteRequest
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from tests.helpers import pay_if_regtest
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.mark.asyncio
async def test_pubkeys(ledger: Ledger):
assert ledger.keyset.public_keys
assert (
ledger.keyset.public_keys[1].serialize().hex()
== "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
)
assert (
ledger.keyset.public_keys[2 ** (settings.max_order - 1)].serialize().hex()
== "023c84c0895cc0e827b348ea0a62951ca489a5e436f3ea7545f3c1d5f1bea1c866"
)
@pytest.mark.asyncio
async def test_privatekeys(ledger: Ledger):
assert ledger.keyset.private_keys
assert (
ledger.keyset.private_keys[1].serialize()
== "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d"
)
assert (
ledger.keyset.private_keys[2 ** (settings.max_order - 1)].serialize()
== "b0477644cb3d82ffcc170bc0a76e0409727232e87c5ae51d64a259936228c7be"
)
@pytest.mark.asyncio
async def test_keysets(ledger: Ledger):
assert len(ledger.keysets)
assert len(list(ledger.keysets.keys()))
assert ledger.keyset.id == "009a1f293253e41e"
@pytest.mark.asyncio
async def test_get_keyset(ledger: Ledger):
keyset = ledger.get_keyset()
assert isinstance(keyset, dict)
assert len(keyset) == settings.max_order
@pytest.mark.asyncio
async def test_mint(ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat"))
await pay_if_regtest(quote.request)
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
id="009a1f293253e41e",
)
]
promises = await ledger.mint(outputs=blinded_messages_mock, quote_id=quote.quote)
assert len(promises)
assert promises[0].amount == 8
assert (
promises[0].C_
== "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e"
)
@pytest.mark.asyncio
async def test_mint_invalid_quote(ledger: Ledger):
await assert_err(
ledger.get_mint_quote(quote_id="invalid_quote_id"),
"quote not found",
)
@pytest.mark.asyncio
async def test_melt_invalid_quote(ledger: Ledger):
await assert_err(
ledger.get_melt_quote(quote_id="invalid_quote_id"),
"quote not found",
)
@pytest.mark.asyncio
async def test_mint_invalid_blinded_message(ledger: Ledger):
quote = await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat"))
await pay_if_regtest(quote.request)
blinded_messages_mock_invalid_key = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bff02d7fa2365379b0840afe249a7a9d71237",
id="009a1f293253e41e",
)
]
await assert_err(
ledger.mint(outputs=blinded_messages_mock_invalid_key, quote_id=quote.quote),
"invalid public key",
)
@pytest.mark.asyncio
async def test_generate_promises(ledger: Ledger):
blinded_messages_mock = [
BlindedMessage(
amount=8,
B_="02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239",
id="009a1f293253e41e",
)
]
promises = await ledger._generate_promises(blinded_messages_mock)
assert (
promises[0].C_
== "031422eeffb25319e519c68de000effb294cb362ef713a7cf4832cea7b0452ba6e"
)
assert promises[0].amount == 8
assert promises[0].id == "009a1f293253e41e"
# DLEQ proof present
assert promises[0].dleq
assert promises[0].dleq.s
assert promises[0].dleq.e
@pytest.mark.asyncio
async def test_generate_change_promises(ledger: Ledger):
# Example slightly adapted from NUT-08 because we want to ensure the dynamic change
# token amount works: `n_blank_outputs != n_returned_promises != 4`.
# invoice_amount = 100_000
fee_reserve = 2_000
# total_provided = invoice_amount + fee_reserve
actual_fee = 100
expected_returned_promises = 7 # Amounts = [4, 8, 32, 64, 256, 512, 1024]
expected_returned_fees = 1900
n_blank_outputs = calculate_number_of_blank_outputs(fee_reserve)
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
outputs = [
BlindedMessage(
amount=1,
B_=b.serialize().hex(),
id="009a1f293253e41e",
)
for b, _ in blinded_msgs
]
promises = await ledger._generate_change_promises(
fee_provided=fee_reserve, fee_paid=actual_fee, outputs=outputs
)
assert len(promises) == expected_returned_promises
assert sum([promise.amount for promise in promises]) == expected_returned_fees
@pytest.mark.asyncio
async def test_generate_change_promises_legacy_wallet(ledger: Ledger):
# Check if mint handles a legacy wallet implementation (always sends 4 blank
# outputs) as well.
# invoice_amount = 100_000
fee_reserve = 2_000
# total_provided = invoice_amount + fee_reserve
actual_fee = 100
expected_returned_promises = 4 # Amounts = [64, 256, 512, 1024]
expected_returned_fees = 1856
n_blank_outputs = 4
blinded_msgs = [step1_alice(str(n)) for n in range(n_blank_outputs)]
outputs = [
BlindedMessage(
amount=1,
B_=b.serialize().hex(),
id="009a1f293253e41e",
)
for b, _ in blinded_msgs
]
promises = await ledger._generate_change_promises(fee_reserve, actual_fee, outputs)
assert len(promises) == expected_returned_promises
assert sum([promise.amount for promise in promises]) == expected_returned_fees
@pytest.mark.asyncio
async def test_generate_change_promises_returns_empty_if_no_outputs(ledger: Ledger):
# invoice_amount = 100_000
fee_reserve = 1_000
# total_provided = invoice_amount + fee_reserve
actual_fee_msat = 100_000
outputs = None
promises = await ledger._generate_change_promises(
fee_reserve, actual_fee_msat, outputs
)
assert len(promises) == 0
@pytest.mark.asyncio
async def test_get_balance(ledger: Ledger):
unit = Unit["sat"]
active_keyset: MintKeyset = next(
filter(lambda k: k.active and k.unit == unit, ledger.keysets.values())
)
balance = await ledger.get_balance(active_keyset)
assert balance == 0
@pytest.mark.asyncio
async def test_maximum_balance(ledger: Ledger):
settings.mint_max_balance = 1000
await ledger.mint_quote(PostMintQuoteRequest(amount=8, unit="sat"))
await assert_err(
ledger.mint_quote(PostMintQuoteRequest(amount=8000, unit="sat")),
"Mint has reached maximum balance.",
)
settings.mint_max_balance = 0

583
tests/mint/test_mint_api.py Normal file
View File

@@ -0,0 +1,583 @@
import bolt11
import httpx
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.models import (
GetInfoResponse,
MintMethodSetting,
PostCheckStateRequest,
PostCheckStateResponse,
PostMeltQuoteResponse,
PostMintQuoteResponse,
PostRestoreRequest,
PostRestoreResponse,
)
from cashu.core.nuts import nut20
from cashu.core.nuts.nuts import MINT_NUT
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.crud import bump_secret_derivation
from cashu.wallet.wallet import Wallet
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
BASE_URL = "http://localhost:3337"
@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=BASE_URL,
db="test_data/wallet_mint_api",
name="wallet_mint_api",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_info(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/v1/info")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.pubkey
assert response.json()["pubkey"] == ledger.pubkey.serialize().hex()
info = GetInfoResponse(**response.json())
assert info.nuts
assert info.nuts[MINT_NUT]["disabled"] is False
setting = MintMethodSetting.parse_obj(info.nuts[MINT_NUT]["methods"][0])
assert setting.method == "bolt11"
assert setting.unit == "sat"
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_keys(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/v1/keys")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
expected = {
"keysets": [
{
"id": keyset.id,
"unit": keyset.unit.name,
"keys": {
str(k): v.serialize().hex()
for k, v in keyset.public_keys.items() # type: ignore
},
}
for keyset in ledger.keysets.values()
]
}
assert response.json() == expected
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_keysets(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/v1/keysets")
assert response.status_code == 200, f"{response.url} {response.status_code}"
expected = {
"keysets": [
{
"id": "009a1f293253e41e",
"unit": "sat",
"active": True,
"input_fee_ppk": 0,
},
{
"id": "00c074b96c7e2b0e",
"unit": "usd",
"active": True,
"input_fee_ppk": 0,
},
]
}
assert response.json() == expected
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_keyset_keys(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/v1/keys/009a1f293253e41e")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
expected = {
"keysets": [
{
"id": "009a1f293253e41e",
"unit": "sat",
"keys": {
str(k): v.serialize().hex()
for k, v in ledger.keysets["009a1f293253e41e"].public_keys.items() # type: ignore
},
}
]
}
assert response.json() == expected
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_keyset_keys_old_keyset_id(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/v1/keys/009a1f293253e41e")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
expected = {
"keysets": [
{
"id": "009a1f293253e41e",
"unit": "sat",
"keys": {
str(k): v.serialize().hex()
for k, v in ledger.keysets["009a1f293253e41e"].public_keys.items() # type: ignore
},
}
]
}
assert response.json() == expected
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_swap(ledger: Ledger, wallet: 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
secrets, rs, derivation_paths = await wallet.generate_n_secrets(2)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
# outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"])
inputs_payload = [p.to_dict() for p in wallet.proofs]
outputs_payload = [o.dict() for o in outputs]
payload = {"inputs": inputs_payload, "outputs": outputs_payload}
response = httpx.post(f"{BASE_URL}/v1/swap", json=payload, timeout=None)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert len(result["signatures"]) == 2
assert result["signatures"][0]["amount"] == 32
assert result["signatures"][1]["amount"] == 32
assert result["signatures"][0]["id"] == "009a1f293253e41e"
assert result["signatures"][0]["dleq"]
assert "e" in result["signatures"][0]["dleq"]
assert "s" in result["signatures"][0]["dleq"]
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_mint_quote(ledger: Ledger):
response = httpx.post(
f"{BASE_URL}/v1/mint/quote/bolt11",
json={"unit": "sat", "amount": 100, "pubkey": "02" + "00" * 32},
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result["quote"]
assert result["request"]
assert result["pubkey"] == "02" + "00" * 32
# deserialize the response
resp_quote = PostMintQuoteResponse(**result)
assert resp_quote.quote == result["quote"]
assert resp_quote.state == MintQuoteState.unpaid.value
assert resp_quote.amount == 100
assert resp_quote.unit == "sat"
assert resp_quote.request == result["request"]
# check if DEPRECATED paid flag is also returned
assert result["paid"] is False
assert resp_quote.paid is False
invoice = bolt11.decode(result["request"])
assert invoice.amount_msat == 100 * 1000
expiry = None
if invoice.expiry is not None:
expiry = invoice.date + invoice.expiry
assert result["expiry"] == expiry
# pay the invoice
await pay_if_regtest(result["request"])
# get mint quote again from api
response = httpx.get(
f"{BASE_URL}/v1/mint/quote/bolt11/{result['quote']}",
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result2 = response.json()
assert result2["quote"] == result["quote"]
# deserialize the response
resp_quote = PostMintQuoteResponse(**result2)
assert resp_quote.quote == result["quote"]
assert resp_quote.state == MintQuoteState.paid.value
assert resp_quote.amount == 100
assert resp_quote.unit == "sat"
assert resp_quote.request == result["request"]
# check if DEPRECATED paid flag is also returned
assert result2["paid"] is True
assert resp_quote.paid is True
assert resp_quote.pubkey == "02" + "00" * 32
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_mint(ledger: Ledger, wallet: Wallet):
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
assert mint_quote.privkey
signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={
"quote": mint_quote.quote,
"outputs": outputs_payload,
"signature": signature,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert len(result["signatures"]) == 2
assert result["signatures"][0]["amount"] == 32
assert result["signatures"][1]["amount"] == 32
assert result["signatures"][0]["id"] == "009a1f293253e41e"
assert result["signatures"][0]["dleq"]
assert "e" in result["signatures"][0]["dleq"]
assert "s" in result["signatures"][0]["dleq"]
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_mint_bolt11_no_signature(ledger: Ledger, wallet: Wallet):
"""
For backwards compatibility, we do not require a NUT-20 signature
for minting with bolt11.
"""
response = httpx.post(
f"{BASE_URL}/v1/mint/quote/bolt11",
json={
"unit": "sat",
"amount": 64,
# no pubkey
},
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result["pubkey"] is None
await pay_if_regtest(result["request"])
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={
"quote": result["quote"],
"outputs": outputs_payload,
# no signature
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
@pytest.mark.skipif(
is_regtest,
reason="regtest",
)
async def test_melt_quote_internal(ledger: Ledger, wallet: Wallet):
# internal invoice
mint_quote = await wallet.request_mint(64)
request = mint_quote.request
response = httpx.post(
f"{BASE_URL}/v1/melt/quote/bolt11",
json={"unit": "sat", "request": request},
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result["quote"]
assert result["amount"] == 64
# TODO: internal invoice, fee should be 0
assert result["fee_reserve"] == 0
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == result["quote"]
assert resp_quote.payment_preimage is None
assert resp_quote.change is None
assert resp_quote.state == MeltQuoteState.unpaid.value
assert resp_quote.amount == 64
assert resp_quote.unit == "sat"
assert resp_quote.request == request
# check if DEPRECATED paid flag is also returned
assert result["paid"] is False
assert resp_quote.paid is False
invoice_obj = bolt11.decode(request)
expiry = None
if invoice_obj.expiry is not None:
expiry = invoice_obj.date + invoice_obj.expiry
assert result["expiry"] == expiry
# # get melt quote again from api
# response = httpx.get(
# f"{BASE_URL}/v1/melt/quote/bolt11/{result['quote']}",
# )
# assert response.status_code == 200, f"{response.url} {response.status_code}"
# result2 = response.json()
# assert result2["quote"] == result["quote"]
# # deserialize the response
# resp_quote = PostMeltQuoteResponse(**result2)
# assert resp_quote.quote == result["quote"]
# assert resp_quote.payment_preimage is not None
# assert len(resp_quote.payment_preimage) == 64
# assert resp_quote.change is not None
# assert resp_quote.state == MeltQuoteState.paid.value
# # check if DEPRECATED paid flag is also returned
# assert result2["paid"] is True
# assert resp_quote.paid is True
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
@pytest.mark.skipif(
is_fake,
reason="only works on regtest",
)
async def test_melt_quote_external(ledger: Ledger, wallet: Wallet):
# internal invoice
invoice_dict = get_real_invoice(64)
request = invoice_dict["payment_request"]
response = httpx.post(
f"{BASE_URL}/v1/melt/quote/bolt11",
json={"unit": "sat", "request": request},
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result["quote"]
assert result["amount"] == 64
# external invoice, fee should be 2
assert result["fee_reserve"] == 2
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_melt_internal(ledger: Ledger, wallet: Wallet):
# internal invoice
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 invoice to melt to
mint_quote = await wallet.request_mint(64)
invoice_payment_request = mint_quote.request
quote = await wallet.melt_quote(invoice_payment_request)
assert quote.amount == 64
assert quote.fee_reserve == 0
inputs_payload = [p.to_dict() for p in wallet.proofs]
# outputs for change
secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
outputs, rs = wallet._construct_outputs([2], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={
"quote": quote.quote,
"inputs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result.get("payment_preimage") is None
assert result["paid"] is True
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == quote.quote
# internal invoice, no preimage, no change
assert resp_quote.payment_preimage is None
assert resp_quote.change == []
assert resp_quote.state == MeltQuoteState.paid.value
assert resp_quote.amount == 64
assert resp_quote.unit == "sat"
assert resp_quote.request == invoice_payment_request
# check if DEPRECATED paid flag is also returned
assert result["paid"] is True
assert resp_quote.paid is True
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
@pytest.mark.skipif(
is_fake,
reason="only works on regtest",
)
async def test_melt_external(ledger: Ledger, wallet: Wallet):
# internal invoice
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
invoice_dict = get_real_invoice(62)
invoice_payment_request = invoice_dict["payment_request"]
quote = await wallet.melt_quote(invoice_payment_request)
assert quote.amount == 62
assert quote.fee_reserve == 2
keep, send = await wallet.swap_to_send(wallet.proofs, 64)
inputs_payload = [p.to_dict() for p in send]
# outputs for change
secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
outputs, rs = wallet._construct_outputs([2], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={
"quote": quote.quote,
"inputs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
response.raise_for_status()
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result.get("payment_preimage") is not None
assert result["paid"] is True
assert result["change"]
# we get back 2 sats because Lightning was free to pay on regtest
assert result["change"][0]["amount"] == 2
# deserialize the response
resp_quote = PostMeltQuoteResponse(**result)
assert resp_quote.quote == quote.quote
assert resp_quote.amount == 62
assert resp_quote.unit == "sat"
assert resp_quote.request == invoice_payment_request
assert resp_quote.payment_preimage is not None
assert len(resp_quote.payment_preimage) == 64
assert resp_quote.change is not None
assert resp_quote.change[0].amount == 2
assert resp_quote.state == MeltQuoteState.paid.value
# check if DEPRECATED paid flag is also returned
assert result["paid"] is True
assert resp_quote.paid is True
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_check_state(ledger: Ledger):
payload = PostCheckStateRequest(Ys=["asdasdasd", "asdasdasd1"])
response = httpx.post(
f"{BASE_URL}/v1/checkstate",
json=payload.dict(),
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
response = PostCheckStateResponse.parse_obj(response.json())
assert response
assert len(response.states) == 2
assert response.states[0].state.unspent
@pytest.mark.asyncio
@pytest.mark.skipif(
settings.debug_mint_only_deprecated,
reason="settings.debug_mint_only_deprecated is set",
)
async def test_api_restore(ledger: Ledger, wallet: 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
secret_counter = await bump_secret_derivation(
db=wallet.db, keyset_id=wallet.keyset_id, by=0, skip=True
)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(
secret_counter - 1, secret_counter - 1
)
outputs, rs = wallet._construct_outputs([64], secrets, rs)
payload = PostRestoreRequest(outputs=outputs)
response = httpx.post(
f"{BASE_URL}/v1/restore",
json=payload.dict(),
)
data = response.json()
assert "signatures" in data
assert "outputs" in data
assert response.status_code == 200, f"{response.url} {response.status_code}"
response = PostRestoreResponse.parse_obj(response.json())
assert response
assert response
assert len(response.signatures) == 1
assert len(response.outputs) == 1
assert response.outputs == outputs

View File

@@ -0,0 +1,128 @@
import httpx
import pytest
import pytest_asyncio
from cashu.core.nuts import nut20
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.helpers import pay_if_regtest
BASE_URL = "http://localhost:3337"
invoice_32sat = "lnbc320n1pnsuamsdqqxqrrsssp5w3tlpw2zss396qh28l3a07u35zdx8nmknzryk89ackn23eywdu2spp5ckt298t835ejzh2xepyxlg57f54q27ffc2zjsjh3t5pmx4wghpcqne0vycw5dfalx5y45d2jtwqfwz437hduyccn9nxk2feay0ytxldjpf3fcjrcf5k2s56q3erj86ymlqdp703y89vt4lr4lun5z5duulcqwuwutn"
@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=BASE_URL,
db="test_data/wallet_mint_api",
name="wallet_mint_api",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_mint_cached_responses(wallet: Wallet):
# Testing mint
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
quote_id = mint_quote.quote
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
assert mint_quote.privkey
signature = nut20.sign_mint_quote(quote_id, outputs, mint_quote.privkey)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload, "signature": signature},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload, "signature": signature},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text
@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_swap_cached_responses(wallet: Wallet):
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
minted = await wallet.mint(64, mint_quote.quote)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
assert mint_quote.privkey
signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey)
inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/swap",
json={
"inputs": inputs_payload,
"outputs": outputs_payload,
"signature": signature,
},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/swap",
json={"inputs": inputs_payload, "outputs": outputs_payload},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text
@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_melt_cached_responses(wallet: Wallet):
mint_quote = await wallet.request_mint(64)
melt_quote = await wallet.melt_quote(invoice_32sat)
await pay_if_regtest(mint_quote.request)
minted = await wallet.mint(64, mint_quote.quote)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10010)
outputs, rs = wallet._construct_outputs([32], secrets, rs)
inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={
"quote": melt_quote.quote,
"inputs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={
"quote": melt_quote.quote,
"inputs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text

View File

@@ -0,0 +1,357 @@
import httpx
import pytest
import pytest_asyncio
from cashu.core.base import Proof, Unit
from cashu.core.models import (
CheckSpendableRequest_deprecated,
CheckSpendableResponse_deprecated,
GetMintResponse_deprecated,
PostRestoreRequest,
PostRestoreResponse,
)
from cashu.mint.ledger import Ledger
from cashu.wallet.crud import bump_secret_derivation
from cashu.wallet.wallet import Wallet
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
BASE_URL = "http://localhost:3337"
@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=BASE_URL,
db="test_data/wallet_mint_api_deprecated",
name="wallet_mint_api_deprecated",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
async def test_info(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/info")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.pubkey
assert response.json()["pubkey"] == ledger.pubkey.serialize().hex()
@pytest.mark.asyncio
async def test_api_keys(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/keys")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
assert response.json() == {
str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items()
}
@pytest.mark.asyncio
async def test_api_keysets(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/keysets")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
sat_keysets = {k: v for k, v in ledger.keysets.items() if v.unit == Unit.sat}
assert response.json()["keysets"] == list(sat_keysets.keys())
@pytest.mark.asyncio
async def test_api_keyset_keys(ledger: Ledger):
response = httpx.get(f"{BASE_URL}/keys/009a1f293253e41e")
assert response.status_code == 200, f"{response.url} {response.status_code}"
assert ledger.keyset.public_keys
assert response.json() == {
str(k): v.serialize().hex() for k, v in ledger.keyset.public_keys.items()
}
@pytest.mark.asyncio
async def test_split(ledger: Ledger, wallet: 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
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(20000, 20001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
# outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"])
inputs_payload = [p.to_dict() for p in wallet.proofs]
outputs_payload = [o.dict() for o in outputs]
# strip "id" from outputs_payload, which is not used in the deprecated split endpoint
for o in outputs_payload:
o.pop("id")
payload = {"proofs": inputs_payload, "outputs": outputs_payload}
response = httpx.post(f"{BASE_URL}/split", json=payload, timeout=None)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result["promises"]
@pytest.mark.asyncio
async def test_split_deprecated_with_amount(ledger: Ledger, wallet: 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
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(80000, 80001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
# outputs = wallet._construct_outputs([32, 32], ["a", "b"], ["c", "d"])
inputs_payload = [p.to_dict() for p in wallet.proofs]
outputs_payload = [o.dict() for o in outputs]
# strip "id" from outputs_payload, which is not used in the deprecated split endpoint
for o in outputs_payload:
o.pop("id")
# we supply an amount here, which should cause the very old deprecated split endpoint to be used
payload = {"proofs": inputs_payload, "outputs": outputs_payload, "amount": 32}
response = httpx.post(f"{BASE_URL}/split", json=payload, timeout=None)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
# old deprecated output format
assert result["fst"]
assert result["snd"]
@pytest.mark.asyncio
async def test_api_mint_validation(ledger):
response = httpx.get(f"{BASE_URL}/mint?amount=-21")
assert "detail" in response.json()
response = httpx.get(f"{BASE_URL}/mint?amount=0")
assert "detail" in response.json()
response = httpx.get(f"{BASE_URL}/mint?amount=2100000000000001")
assert "detail" in response.json()
response = httpx.get(f"{BASE_URL}/mint?amount=1")
assert "detail" not in response.json()
@pytest.mark.asyncio
async def test_mint(ledger: Ledger, wallet: Wallet):
quote_response = httpx.get(
f"{BASE_URL}/mint",
params={"amount": 64},
timeout=None,
)
mint_quote = GetMintResponse_deprecated.parse_obj(quote_response.json())
await pay_if_regtest(mint_quote.pr)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/mint",
json={"outputs": outputs_payload},
params={"hash": mint_quote.hash},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert len(result["promises"]) == 2
assert result["promises"][0]["amount"] == 32
assert result["promises"][1]["amount"] == 32
assert result["promises"][0]["id"] == "009a1f293253e41e"
assert result["promises"][0]["dleq"]
assert "e" in result["promises"][0]["dleq"]
assert "s" in result["promises"][0]["dleq"]
@pytest.mark.asyncio
async def test_melt_internal(ledger: Ledger, wallet: Wallet):
# 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 invoice to melt to
mint_quote = await wallet.request_mint(64)
invoice_payment_request = mint_quote.request
quote = await wallet.melt_quote(invoice_payment_request)
assert quote.amount == 64
assert quote.fee_reserve == 0
inputs_payload = [p.to_dict() for p in wallet.proofs]
# outputs for change
secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
outputs, rs = wallet._construct_outputs([2], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/melt",
json={
"pr": invoice_payment_request,
"proofs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result.get("preimage") is None
assert result["paid"] is True
@pytest.mark.asyncio
async def test_melt_internal_no_change_outputs(ledger: Ledger, wallet: Wallet):
# Clients without NUT-08 will not send change outputs
# internal invoice
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 invoice to melt to
mint_quote = await wallet.request_mint(64)
invoice_payment_request = mint_quote.request
quote = await wallet.melt_quote(invoice_payment_request)
assert quote.amount == 64
assert quote.fee_reserve == 0
inputs_payload = [p.to_dict() for p in wallet.proofs]
# outputs for change
secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
outputs, rs = wallet._construct_outputs([2], secrets, rs)
response = httpx.post(
f"{BASE_URL}/melt",
json={
"pr": invoice_payment_request,
"proofs": inputs_payload,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result.get("preimage") is None
assert result["paid"] is True
@pytest.mark.asyncio
@pytest.mark.skipif(
is_fake,
reason="only works on regtest",
)
async def test_melt_external(ledger: Ledger, wallet: Wallet):
# internal invoice
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 invoice to melt to
# use 2 sat less because we need to pay the fee
invoice_dict = get_real_invoice(62)
invoice_payment_request = invoice_dict["payment_request"]
quote = await wallet.melt_quote(invoice_payment_request)
assert quote.amount == 62
assert quote.fee_reserve == 2
inputs_payload = [p.to_dict() for p in wallet.proofs]
# outputs for change
secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
outputs, rs = wallet._construct_outputs([2], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/melt",
json={
"pr": invoice_payment_request,
"proofs": inputs_payload,
"outputs": outputs_payload,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
assert result.get("preimage") is not None
assert result["paid"] is True
assert result["change"]
# we get back 2 sats because Lightning was free to pay on regtest
assert result["change"][0]["amount"] == 2
@pytest.mark.asyncio
async def test_checkfees(ledger: Ledger, wallet: Wallet):
# internal invoice
mint_quote = await wallet.request_mint(64)
response = httpx.post(
f"{BASE_URL}/checkfees",
json={
"pr": mint_quote.request,
},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
# internal invoice, so no fee
assert result["fee"] == 0
@pytest.mark.asyncio
@pytest.mark.skipif(not is_regtest, reason="only works on regtest")
async def test_checkfees_external(ledger: Ledger, wallet: Wallet):
# external invoice
invoice_dict = get_real_invoice(62)
invoice_payment_request = invoice_dict["payment_request"]
response = httpx.post(
f"{BASE_URL}/checkfees",
json={"pr": invoice_payment_request},
timeout=None,
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
result = response.json()
# external invoice, so fee
assert result["fee"] == 2
@pytest.mark.asyncio
async def test_api_check_state(ledger: Ledger):
proofs = [
Proof(id="1234", amount=0, secret="asdasdasd", C="asdasdasd"),
Proof(id="1234", amount=0, secret="asdasdasd1", C="asdasdasd1"),
]
payload = CheckSpendableRequest_deprecated(proofs=proofs)
response = httpx.post(
f"{BASE_URL}/check",
json=payload.dict(),
)
assert response.status_code == 200, f"{response.url} {response.status_code}"
states = CheckSpendableResponse_deprecated.parse_obj(response.json())
assert states.spendable
assert len(states.spendable) == 2
assert states.pending
assert len(states.pending) == 2
@pytest.mark.asyncio
async def test_api_restore(ledger: Ledger, wallet: 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
secret_counter = await bump_secret_derivation(
db=wallet.db, keyset_id=wallet.keyset_id, by=0, skip=True
)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(
secret_counter - 1, secret_counter - 1
)
outputs, rs = wallet._construct_outputs([64], secrets, rs)
payload = PostRestoreRequest(outputs=outputs)
response = httpx.post(
f"{BASE_URL}/restore",
json=payload.dict(),
)
data = response.json()
assert "promises" in data
assert "outputs" in data
assert response.status_code == 200, f"{response.url} {response.status_code}"
response = PostRestoreResponse.parse_obj(response.json())
assert response
assert response.promises
assert len(response.promises) == 1
assert len(response.outputs) == 1
assert response.outputs == outputs

291
tests/mint/test_mint_db.py Normal file
View File

@@ -0,0 +1,291 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from fastapi import WebSocket
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.json_rpc.base import (
JSONRPCMethods,
JSONRPCNotficationParams,
JSONRPCNotification,
JSONRPCSubscriptionKinds,
)
from cashu.core.models import PostMeltQuoteRequest
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
assert_err,
is_github_actions,
pay_if_regtest,
)
@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
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_github_actions, reason="GITHUB_ACTIONS")
async def test_mint_proofs_pending(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
proofs = wallet.proofs.copy()
proofs_states_before_split = await wallet.check_proof_state(proofs)
assert all([s.unspent for s in proofs_states_before_split.states])
await ledger.db_write._verify_spent_proofs_and_set_pending(proofs)
proof_states = await wallet.check_proof_state(proofs)
assert all([s.pending for s in proof_states.states])
await assert_err(wallet.split(wallet.proofs, 20), "proofs are pending.")
await ledger.db_write._unset_proofs_pending(proofs)
await wallet.split(proofs, 20)
proofs_states_after_split = await wallet.check_proof_state(proofs)
assert all([s.spent for s in proofs_states_after_split.states])
@pytest.mark.asyncio
async def test_mint_quote(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.amount == 128
assert quote.unit == "sat"
assert not quote.paid
# assert quote.paid_time is None
assert quote.created_time
@pytest.mark.asyncio
async def test_mint_quote_state_transitions(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.unpaid
# set pending again
async def set_state(quote, state):
quote.state = state
# set pending
await assert_err(
set_state(quote, MintQuoteState.pending),
"Cannot change state of an unpaid mint quote",
)
# set unpaid
await assert_err(
set_state(quote, MintQuoteState.unpaid),
"Cannot change state of an unpaid mint quote",
)
# set paid
quote.state = MintQuoteState.paid
# set unpaid
await assert_err(
set_state(quote, MintQuoteState.unpaid),
"Cannot change state of a paid mint quote to unpaid.",
)
# set pending
quote.state = MintQuoteState.pending
# set paid again
quote.state = MintQuoteState.paid
# set pending again
quote.state = MintQuoteState.pending
# set issued
quote.state = MintQuoteState.issued
# set pending again
await assert_err(
set_state(quote, MintQuoteState.pending),
"Cannot change state of an issued mint quote.",
)
@pytest.mark.asyncio
async def test_get_mint_quote_by_request(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
quote = await ledger.crud.get_mint_quote(request=mint_quote.request, db=ledger.db)
assert quote is not None
assert quote.amount == 128
assert quote.unit == "sat"
assert not quote.paid
# assert quote.paid_time is None
assert quote.created_time
@pytest.mark.asyncio
async def test_melt_quote(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.amount == 128
assert quote.unit == "sat"
assert not quote.paid
# assert quote.paid_time is None
assert quote.created_time
@pytest.mark.asyncio
async def test_melt_quote_set_pending(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
)
assert melt_quote is not None
assert melt_quote.state == MeltQuoteState.unpaid.value
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.unpaid
previous_state = quote.state
await ledger.db_write._set_melt_quote_pending(quote)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.pending
# set unpending
await ledger.db_write._unset_melt_quote_pending(quote, previous_state)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.state == previous_state
@pytest.mark.asyncio
async def test_melt_quote_state_transitions(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
)
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.unpaid
# set pending
quote.state = MeltQuoteState.pending
# set unpaid
quote.state = MeltQuoteState.unpaid
# set paid
quote.state = MeltQuoteState.paid
# set pending again
async def set_state(quote, state):
quote.state = state
await assert_err(
set_state(quote, MeltQuoteState.pending),
"Cannot change state of a paid melt quote.",
)
@pytest.mark.asyncio
async def test_mint_quote_set_pending(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
mint_quote = await ledger.crud.get_mint_quote(
quote_id=mint_quote.quote, db=ledger.db
)
assert mint_quote is not None
assert mint_quote.unpaid
# pay_if_regtest pays on regtest, get_mint_quote pays on FakeWallet
await pay_if_regtest(mint_quote.request)
_ = await ledger.get_mint_quote(mint_quote.quote)
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.paid
previous_state = MintQuoteState.paid
await ledger.db_write._set_mint_quote_pending(quote.quote)
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.pending
# try to mint while pending
await assert_err(
wallet.mint(128, quote_id=mint_quote.quote), "Mint quote already pending."
)
# set unpending
await ledger.db_write._unset_mint_quote_pending(quote.quote, previous_state)
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.state == previous_state
assert quote.paid
# # set paid and mint again
# quote.state = MintQuoteState.paid
# await ledger.crud.update_mint_quote(quote=quote, db=ledger.db)
await wallet.mint(quote.amount, quote_id=quote.quote)
# check if quote is issued
quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db)
assert quote is not None
assert quote.issued
@pytest.mark.asyncio
async def test_db_events_add_client(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(128)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
)
assert melt_quote is not None
assert melt_quote.state == MeltQuoteState.unpaid.value
quote = await ledger.crud.get_melt_quote(quote_id=melt_quote.quote, db=ledger.db)
assert quote is not None
assert quote.quote == melt_quote.quote
assert quote.unpaid
# add event client
websocket_mock = AsyncMock(spec=WebSocket)
client = ledger.events.add_client(websocket_mock, ledger.db, ledger.crud)
asyncio.create_task(client.start())
await asyncio.sleep(0.1)
websocket_mock.accept.assert_called_once()
# add subscription
client.add_subscription(
JSONRPCSubscriptionKinds.BOLT11_MELT_QUOTE, [quote.quote], "subId"
)
quote_pending = await ledger.db_write._set_melt_quote_pending(quote)
await asyncio.sleep(0.1)
notification = JSONRPCNotification(
method=JSONRPCMethods.SUBSCRIBE.value,
params=JSONRPCNotficationParams(
subId="subId", payload=quote_pending.dict()
).dict(),
)
websocket_mock.send_text.assert_called_with(notification.json())
# remove subscription
client.remove_subscription("subId")

View File

@@ -0,0 +1,330 @@
import asyncio
import datetime
import os
import time
from typing import List, Tuple
import pytest
import pytest_asyncio
from cashu.core import db
from cashu.core.db import Connection
from cashu.core.migrations import backup_database
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import is_github_actions, is_postgres, is_regtest, pay_if_regtest
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
async def assert_err_multiple(f, msgs: List[str]):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
for msg in msgs:
if msg in str(exc.args[0]):
return
raise Exception(f"Expected error: {msgs}, got: {exc.args[0]}")
raise Exception(f"Expected error: {msgs}, got no error")
@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
await wallet.db.engine.dispose()
@pytest.mark.asyncio
async def test_db_tables(ledger: Ledger):
async with ledger.db.connect() as conn:
if ledger.db.type == db.SQLITE:
tables_res = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table';"
)
elif ledger.db.type in {db.POSTGRES, db.COCKROACH}:
tables_res = await conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema ="
" 'public';"
)
tables_all: List[Tuple[str]] = tables_res.all() # type: ignore
tables = [t[0] for t in tables_all]
tables_expected = [
"dbversions",
"keysets",
"proofs_used",
"proofs_pending",
"melt_quotes",
"mint_quotes",
"mint_pubkeys",
"promises",
]
for table in tables_expected:
assert table in tables
@pytest.mark.asyncio
@pytest.mark.skipif(
is_github_actions and is_postgres,
reason=(
"Fails on GitHub Actions because pg_dump is not the same version as postgres"
),
)
async def test_backup_db_migration(ledger: Ledger):
settings.db_backup_path = "./test_data/backups/"
filepath = await backup_database(ledger.db, 999)
assert os.path.exists(filepath)
@pytest.mark.asyncio
async def test_timestamp_now(ledger: Ledger):
ts = ledger.db.timestamp_now_str()
if ledger.db.type == db.SQLITE:
assert isinstance(ts, str)
assert int(ts) <= time.time()
elif ledger.db.type in {db.POSTGRES, db.COCKROACH}:
assert isinstance(ts, str)
datetime.datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
@pytest.mark.asyncio
async def test_db_connect(ledger: Ledger):
async with ledger.db.connect() as conn:
assert isinstance(conn, Connection)
@pytest.mark.asyncio
async def test_db_get_connection(ledger: Ledger):
async with ledger.db.get_connection() as conn:
assert isinstance(conn, Connection)
@pytest.mark.asyncio
async def test_db_get_connection_locked(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(64)
async def get_connection():
"""This code makes sure that only the error of the second connection is raised (which we check in the assert_err)"""
try:
async with ledger.db.get_connection(lock_table="mint_quotes"):
try:
async with ledger.db.get_connection(
lock_table="mint_quotes", lock_timeout=0.1
) as conn2:
# write something with conn1, we never reach this point if the lock works
await conn2.execute(
f"INSERT INTO mint_quotes (quote, amount) VALUES ('{mint_quote.quote}', 100);"
)
except Exception as exc:
# this is expected to raise
raise Exception(f"conn2: {exc}")
except Exception as exc:
if str(exc).startswith("conn2"):
raise exc
else:
raise Exception("not expected to happen")
await assert_err(get_connection(), "failed to acquire database lock")
@pytest.mark.asyncio
@pytest.mark.skipif(
not not is_postgres,
reason="SQLite does not support row locking",
)
async def test_db_get_connection_lock_row(wallet: Wallet, ledger: Ledger):
mint_quote = await wallet.request_mint(64)
async def get_connection():
"""This code makes sure that only the error of the second connection is raised (which we check in the assert_err)"""
try:
async with ledger.db.get_connection(
lock_table="mint_quotes",
lock_select_statement=f"quote='{mint_quote.quote}'",
lock_timeout=0.1,
) as conn1:
await conn1.execute(
f"UPDATE mint_quotes SET amount=100 WHERE quote='{mint_quote.quote}';"
)
try:
async with ledger.db.get_connection(
lock_table="mint_quotes",
lock_select_statement=f"quote='{mint_quote.quote}'",
lock_timeout=0.1,
) as conn2:
# write something with conn1, we never reach this point if the lock works
await conn2.execute(
f"UPDATE mint_quotes SET amount=101 WHERE quote='{mint_quote.quote}';"
)
except Exception as exc:
# this is expected to raise
raise Exception(f"conn2: {exc}")
except Exception as exc:
if "conn2" in str(exc):
raise exc
else:
raise Exception(f"not expected to happen: {exc}")
await assert_err(get_connection(), "failed to acquire database lock")
@pytest.mark.asyncio
@pytest.mark.skipif(
is_github_actions and is_regtest and not is_postgres,
reason=("Fails on GitHub Actions for regtest + SQLite"),
)
async def test_db_verify_spent_proofs_and_set_pending_race_condition(
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
await assert_err_multiple(
asyncio.gather(
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
),
[
"failed to acquire database lock",
"proofs are pending",
], # depending on how fast the database is, it can be either
)
@pytest.mark.asyncio
@pytest.mark.skipif(
is_github_actions and is_regtest and not is_postgres,
reason=("Fails on GitHub Actions for regtest + SQLite"),
)
async def test_db_verify_spent_proofs_and_set_pending_delayed_no_race_condition(
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
async def delayed_verify_spent_proofs_and_set_pending():
await asyncio.sleep(0.1)
await ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs)
await assert_err(
asyncio.gather(
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
delayed_verify_spent_proofs_and_set_pending(),
),
"proofs are pending",
)
@pytest.mark.asyncio
@pytest.mark.skipif(
is_github_actions and is_regtest and not is_postgres,
reason=("Fails on GitHub Actions for regtest + SQLite"),
)
async def test_db_verify_spent_proofs_and_set_pending_no_race_condition_different_proofs(
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, split=[32, 32])
assert wallet.balance == 64
assert len(wallet.proofs) == 2
asyncio.gather(
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs[:1]),
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs[1:]),
)
@pytest.mark.asyncio
@pytest.mark.skipif(
not is_postgres,
reason="SQLite does not support row locking",
)
async def test_db_get_connection_lock_different_row(wallet: Wallet, ledger: Ledger):
if ledger.db.type == db.SQLITE:
pytest.skip("SQLite does not support row locking")
# this should work since we lock two different rows
mint_quote = await wallet.request_mint(64)
mint_quote_2 = await wallet.request_mint(64)
async def get_connection2():
"""This code makes sure that only the error of the second connection is raised (which we check in the assert_err)"""
try:
async with ledger.db.get_connection(
lock_table="mint_quotes",
lock_select_statement=f"quote='{mint_quote.quote}'",
lock_timeout=0.1,
):
try:
async with ledger.db.get_connection(
lock_table="mint_quotes",
lock_select_statement=f"quote='{mint_quote_2.quote}'",
lock_timeout=0.1,
) as conn2:
# write something with conn1, this time we should reach this block with postgres
quote = await ledger.crud.get_mint_quote(
quote_id=mint_quote_2.quote, db=ledger.db, conn=conn2
)
assert quote is not None
quote.amount = 100
await ledger.crud.update_mint_quote(
quote=quote, db=ledger.db, conn=conn2
)
except Exception as exc:
# this is expected to raise
raise Exception(f"conn2: {exc}")
except Exception as exc:
if "conn2" in str(exc):
raise exc
else:
raise Exception(f"not expected to happen: {exc}")
await get_connection2()
@pytest.mark.asyncio
@pytest.mark.skipif(
is_github_actions and is_regtest and not is_postgres,
reason=("Fails on GitHub Actions for regtest + SQLite"),
)
async def test_db_lock_table(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
async with ledger.db.connect(lock_table="proofs_pending", lock_timeout=0.1) as conn:
assert isinstance(conn, Connection)
await assert_err(
ledger.db_write._verify_spent_proofs_and_set_pending(wallet.proofs),
"failed to acquire database lock",
)

View File

@@ -0,0 +1,276 @@
from typing import Optional
import pytest
import pytest_asyncio
from cashu.core.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest
from cashu.core.split import amount_split
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
@pytest_asyncio.fixture(scope="function")
async def wallet1(ledger: Ledger):
wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet1.load_mint()
yield wallet1
def set_ledger_keyset_fees(
fee_ppk: int, ledger: Ledger, wallet: Optional[Wallet] = None
):
for keyset in ledger.keysets.values():
keyset.input_fee_ppk = fee_ppk
if wallet:
for wallet_keyset in wallet.keysets.values():
wallet_keyset.input_fee_ppk = fee_ppk
@pytest.mark.asyncio
async def test_get_fees_for_proofs(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, split=[1] * 64, quote_id=mint_quote.quote)
# two proofs
set_ledger_keyset_fees(100, ledger)
proofs = [wallet1.proofs[0], wallet1.proofs[1]]
fees = ledger.get_fees_for_proofs(proofs)
assert fees == 1
set_ledger_keyset_fees(1234, ledger)
fees = ledger.get_fees_for_proofs(proofs)
assert fees == 3
set_ledger_keyset_fees(0, ledger)
fees = ledger.get_fees_for_proofs(proofs)
assert fees == 0
set_ledger_keyset_fees(1, ledger)
fees = ledger.get_fees_for_proofs(proofs)
assert fees == 1
# ten proofs
ten_proofs = wallet1.proofs[:10]
set_ledger_keyset_fees(100, ledger)
fees = ledger.get_fees_for_proofs(ten_proofs)
assert fees == 1
set_ledger_keyset_fees(101, ledger)
fees = ledger.get_fees_for_proofs(ten_proofs)
assert fees == 2
# three proofs
three_proofs = wallet1.proofs[:3]
set_ledger_keyset_fees(333, ledger)
fees = ledger.get_fees_for_proofs(three_proofs)
assert fees == 1
set_ledger_keyset_fees(334, ledger)
fees = ledger.get_fees_for_proofs(three_proofs)
assert fees == 2
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_wallet_selection_with_fee(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1)
# THIS TEST IS A FAKE, WE SET THE WALLET FEES MANUALLY IN set_ledger_keyset_fees
# check if all wallet keysets have the correct fees
for keyset in wallet1.keysets.values():
assert keyset.input_fee_ppk == 100
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
send_proofs, _ = await wallet1.select_to_send(wallet1.proofs, 10)
assert sum_proofs(send_proofs) == 10
send_proofs_with_fees, _ = await wallet1.select_to_send(
wallet1.proofs, 10, include_fees=True
)
assert sum_proofs(send_proofs_with_fees) == 11
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_wallet_swap_to_send_with_fee(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1)
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(
64, quote_id=mint_quote.quote, split=[32, 32]
) # make sure we need to swap
# quirk: this should call a `/v1/swap` with the mint but the mint will
# throw an error since the fees are only changed in the `ledger` instance, not in the uvicorn API server
# this *should* succeed if the fees were set in the API server
# at least, we can verify that the wallet is correctly computing the fees
# by asserting for this super specific error message from the (API server) mint
await assert_err(
wallet1.select_to_send(wallet1.proofs, 10),
"Mint Error: inputs (32) - fees (0) vs outputs (31) are not balanced.",
)
@pytest.mark.asyncio
async def test_split_with_fees(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger)
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
send_proofs, _ = await wallet1.select_to_send(wallet1.proofs, 10)
fees = ledger.get_fees_for_proofs(send_proofs)
assert fees == 1
outputs = await wallet1.construct_outputs(amount_split(9))
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(outputs)
assert [p.amount for p in promises] == [p.amount for p in outputs]
@pytest.mark.asyncio
async def test_split_with_high_fees(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(1234, ledger)
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
send_proofs, _ = await wallet1.select_to_send(wallet1.proofs, 10)
fees = ledger.get_fees_for_proofs(send_proofs)
assert fees == 3
outputs = await wallet1.construct_outputs(amount_split(7))
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(outputs)
assert [p.amount for p in promises] == [p.amount for p in outputs]
@pytest.mark.asyncio
async def test_split_not_enough_fees(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger)
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
send_proofs, _ = await wallet1.select_to_send(wallet1.proofs, 10)
fees = ledger.get_fees_for_proofs(send_proofs)
assert fees == 1
# with 10 sat input, we request 10 sat outputs but fees are 1 sat so the swap will fail
outputs = await wallet1.construct_outputs(amount_split(10))
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs), "are not balanced"
)
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_melt_internal(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1)
# mint twice so we have enough to pay the second invoice back
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
assert wallet1.balance == 128
# create a mint quote so that we can melt to it internally
invoice_to_pay = await wallet1.request_mint(64)
invoice_payment_request = invoice_to_pay.request
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
)
assert not melt_quote.paid
assert melt_quote.amount == 64
assert melt_quote.fee_reserve == 0
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
# let's first try to melt without enough funds
send_proofs, fees = await wallet1.select_to_send(wallet1.proofs, 64)
# this should fail because we need 64 + 1 sat fees
assert sum_proofs(send_proofs) == 64
await assert_err(
ledger.melt(proofs=send_proofs, quote=melt_quote.quote),
"not enough inputs provided for melt",
)
# the wallet respects the fees for coin selection
send_proofs, fees = await wallet1.select_to_send(
wallet1.proofs, 64, include_fees=True
)
# includes 1 sat fees
assert sum_proofs(send_proofs) == 65
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_melt_external_with_fees(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1)
# mint twice so we have enough to pay the second invoice back
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
assert wallet1.balance == 128
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
mint_quote = await wallet1.melt_quote(invoice_payment_request)
total_amount = mint_quote.amount + mint_quote.fee_reserve
send_proofs, fee = await wallet1.select_to_send(
wallet1.proofs, total_amount, include_fees=True
)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
)
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
assert not melt_quote.paid, "melt quote should not be paid"
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"

View File

@@ -0,0 +1,528 @@
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=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.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

View File

@@ -0,0 +1,107 @@
import pytest
from cashu.core.base import MintKeyset, Unit
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from tests.mint.test_mint_init import (
DECRYPTON_KEY,
DERIVATION_PATH,
ENCRYPTED_SEED,
SEED,
)
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
@pytest.mark.asyncio
async def test_keyset_0_15_0():
keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.15.0")
assert len(keyset.public_keys_hex) == settings.max_order
assert keyset.seed == "TEST_PRIVATE_KEY"
assert keyset.derivation_path == "m/0'/0'/0'"
assert (
keyset.public_keys_hex[1]
== "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
)
assert keyset.id == "009a1f293253e41e"
@pytest.mark.asyncio
async def test_keyset_0_14_0():
keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.14.0")
assert len(keyset.public_keys_hex) == settings.max_order
assert keyset.seed == "TEST_PRIVATE_KEY"
assert keyset.derivation_path == "m/0'/0'/0'"
assert (
keyset.public_keys_hex[1]
== "036d6f3adf897e88e16ece3bffb2ce57a0b635fa76f2e46dbe7c636a937cd3c2f2"
)
assert keyset.id == "xnI+Y0j7cT1/"
@pytest.mark.asyncio
async def test_keyset_0_11_0():
keyset = MintKeyset(seed=SEED, derivation_path=DERIVATION_PATH, version="0.11.0")
assert len(keyset.public_keys_hex) == settings.max_order
assert keyset.seed == "TEST_PRIVATE_KEY"
assert keyset.derivation_path == "m/0'/0'/0'"
assert (
keyset.public_keys_hex[1]
== "026b714529f157d4c3de5a93e3a67618475711889b6434a497ae6ad8ace6682120"
)
assert keyset.id == "Zkdws9zWxNc4"
@pytest.mark.asyncio
async def test_keyset_0_15_0_encrypted():
settings.mint_seed_decryption_key = DECRYPTON_KEY
keyset = MintKeyset(
encrypted_seed=ENCRYPTED_SEED,
derivation_path=DERIVATION_PATH,
version="0.15.0",
)
assert len(keyset.public_keys_hex) == settings.max_order
assert keyset.seed == "TEST_PRIVATE_KEY"
assert keyset.derivation_path == "m/0'/0'/0'"
assert (
keyset.public_keys_hex[1]
== "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"
)
assert keyset.id == "009a1f293253e41e"
@pytest.mark.asyncio
async def test_keyset_rotation(ledger: Ledger):
keyset_sat = next(
filter(lambda k: k.unit == Unit["sat"] and k.active, ledger.keysets.values())
)
new_keyset_sat = await ledger.rotate_next_keyset(
unit=Unit["sat"], max_order=20, input_fee_ppk=1
)
keyset_sat_derivation = keyset_sat.derivation_path.split("/")
new_keyset_sat_derivation = keyset_sat.derivation_path.split("/")
assert (
keyset_sat_derivation[:-1] == new_keyset_sat_derivation[:-1]
), "keyset derivation does not match up to the counter branch"
assert (
int(new_keyset_sat_derivation[-1].replace("'", ""))
- int(keyset_sat_derivation[-1].replace("'", ""))
== 0
), "counters should differ by exactly 1"
assert new_keyset_sat.input_fee_ppk == 1
assert len(new_keyset_sat.private_keys.values()) == 20
old_keyset = (await ledger.crud.get_keyset(db=ledger.db, id=keyset_sat.id))[0]
assert not old_keyset.active, "old keyset is still active"

View File

@@ -0,0 +1,250 @@
import pytest
import respx
from httpx import Response
from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit
from cashu.core.models import PostMeltQuoteRequest
from cashu.core.settings import settings
from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore
settings.mint_blink_key = "123"
blink = BlinkWallet(unit=Unit.sat)
payment_request = (
"lnbc10u1pjap7phpp50s9lzr3477j0tvacpfy2ucrs4q0q6cvn232ex7nt2zqxxxj8gxrsdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss"
"p575z0n39w2j7zgnpqtdlrgz9rycner4eptjm3lz363dzylnrm3h4s9qyyssqfz8jglcshnlcf0zkw4qu8fyr564lg59x5al724kms3h6gpuhx9xrfv27tgx3l3u3cyf6"
"3r52u0xmac6max8mdupghfzh84t4hfsvrfsqwnuszf"
) # 1000 sat
payment_request_10k = (
"lnbc100u1pjaxuyzpp5wn37d3mx38haqs7nd5he4j7pq4r806e6s83jdksxrd77pnanm3zqdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrss"
"sp5ayy0uuhwgy8hwphvy7ptzpg2dfn8vt3vlgsk53rsvj76jvafhujs9qyyssqc8aj03s5au3tgu6pj0rm0ws4a838s8ffe3y3qkj77esh7qmgsz7qlvdlzgj6dvx7tx7"
"zn6k352z85rvdqvlszrevvzakp96a4pvyn2cpgaaks6"
)
payment_request_4973 = (
"lnbc49730n1pjaxuxnpp5zw0ry2w2heyuv7wk4r6z38vvgnaudfst0hl2p5xnv0mjkxtavg2qdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrr"
"sssp5x8tv2ka0m95hgek25kauw540m0dx727stqqr07l8h37v5283sn5q9qyyssqeevcs6vxcdnerk5w5mwfmntsf8nze7nxrf97dywmga7v0742vhmxtjrulgu3kah4f"
"2r6025j974jpjg4mkqhv2gdls5k7e5cvwdf4wcp3ytsvx"
)
payment_request_1 = (
"lnbc10n1pjaxujrpp5sqehn6h5p8xpa0c0lvj5vy3a537gxfk5e7h2ate2alfw3y5cm6xqdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss"
"p5fkxsvyl0r32mvnhv9cws4rp986v0wjl2lp93zzl8jejnuwzvpynq9qyyssqqmsnatsz87qrgls98c97dfa6l2z3rzg2x6kxmrvpz886rwjylmd56y3qxzfulrq03kkh"
"hwk6r32wes6pjt2zykhnsjn30c6uhuk0wugp3x74al"
)
@respx.mock
@pytest.mark.asyncio
async def test_blink_status():
mock_response = {
"data": {
"me": {
"defaultAccount": {
"wallets": [
{"walletCurrency": "USD", "id": "123", "balance": 32142},
{
"walletCurrency": "BTC",
"id": "456",
"balance": 100000,
},
]
}
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.status()
assert status.balance == 100000
@respx.mock
@pytest.mark.asyncio
async def test_blink_create_invoice():
mock_response = {
"data": {
"lnInvoiceCreateOnBehalfOfRecipient": {
"invoice": {"paymentRequest": payment_request}
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
invoice = await blink.create_invoice(Amount(Unit.sat, 1000))
assert invoice.checking_id == invoice.payment_request
assert invoice.ok
@respx.mock
@pytest.mark.asyncio
async def test_blink_pay_invoice():
mock_response = {
"data": {
"lnInvoicePaymentSend": {
"status": "SUCCESS",
"transaction": {
"settlementFee": 10,
"settlementVia": {
"preImage": "123",
},
},
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = MeltQuote(
request=payment_request,
quote="asd",
method="bolt11",
checking_id=payment_request,
unit="sat",
amount=100,
fee_reserve=12,
state=MeltQuoteState.unpaid,
)
payment = await blink.pay_invoice(quote, 1000)
assert payment.settled
assert payment.fee
assert payment.fee.amount == 10
assert payment.error_message is None
assert payment.checking_id == payment_request
@respx.mock
@pytest.mark.asyncio
async def test_blink_pay_invoice_failure():
mock_response = {
"data": {
"lnInvoicePaymentSend": {
"status": "FAILURE",
"errors": [
{"message": "This is the error", "codee": "ROUTE_FINDING_ERROR"},
],
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
quote = MeltQuote(
request=payment_request,
quote="asd",
method="bolt11",
checking_id=payment_request,
unit="sat",
amount=100,
fee_reserve=12,
state=MeltQuoteState.unpaid,
)
payment = await blink.pay_invoice(quote, 1000)
assert not payment.settled
assert payment.fee is None
assert payment.error_message
assert "This is the error" in payment.error_message
assert payment.checking_id == payment_request
@respx.mock
@pytest.mark.asyncio
async def test_blink_get_invoice_status():
mock_response = {
"data": {
"lnInvoicePaymentStatus": {
"status": "PAID",
"errors": [],
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_invoice_status("123")
assert status.settled
@respx.mock
@pytest.mark.asyncio
async def test_blink_get_payment_status():
mock_response = {
"data": {
"me": {
"defaultAccount": {
"walletById": {
"transactionsByPaymentHash": [
{
"status": "SUCCESS",
"settlementFee": 10,
"direction": "SEND",
"settlementVia": {
"preImage": "123",
},
}
]
}
}
}
}
}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
status = await blink.get_payment_status(payment_request)
assert status.settled
assert status.fee
assert status.fee.amount == 10
assert status.preimage == "123"
@respx.mock
@pytest.mark.asyncio
async def test_blink_get_payment_quote():
# response says 1 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect MINIMUM_FEE_MSAT/1000 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat
# response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, 10) # sat
# response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
melt_quote_request_4973 = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request_4973
)
quote = await blink.get_payment_quote(melt_quote_request_4973)
assert quote.checking_id == payment_request_4973
assert quote.amount == Amount(Unit.sat, 4973) # sat
assert quote.fee == Amount(Unit.sat, 25) # sat
# response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat
mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
melt_quote_request_1 = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request_1
)
quote = await blink.get_payment_quote(melt_quote_request_1)
assert quote.checking_id == payment_request_1
assert quote.amount == Amount(Unit.sat, 1) # sat
assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat
@respx.mock
@pytest.mark.asyncio
async def test_blink_get_payment_quote_backend_error():
# response says error but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat (MINIMUM_FEE_MSAT)
mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}}
respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response))
melt_quote_request = PostMeltQuoteRequest(
unit=Unit.sat.name, request=payment_request
)
quote = await blink.get_payment_quote(melt_quote_request)
assert quote.checking_id == payment_request
assert quote.amount == Amount(Unit.sat, 1000) # sat
assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat

View File

@@ -0,0 +1,371 @@
from typing import List, Tuple
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Proof
from cashu.core.errors import LightningPaymentFailedError
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
is_regtest,
)
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
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=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_fakewallet_pending_quote_get_melt_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
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.paid
# 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_fakewallet_pending_quote_get_melt_quote_pending(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.PENDING.name
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.pending
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert melt_quotes
# expect that proofs are 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 fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_failed(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.FAILED.name
# get_melt_quote should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote)
assert quote2.state == MeltQuoteState.unpaid
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are pending
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 fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_unknown(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.UNKNOWN.name
# get_melt_quote(..., rollback_unknown=True) should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote, rollback_unknown=True)
assert quote2.state == MeltQuoteState.unpaid
# expect that pending tokens are still in db
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
# expect that proofs are pending
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 fake wallet")
async def test_melt_lightning_pay_invoice_settled(ledger: Ledger, wallet: Wallet):
mint_quote = await wallet.request_mint(64)
await ledger.get_mint_quote(mint_quote.quote) # fakewallet: set the quote to paid
await wallet.mint(64, quote_id=mint_quote.quote)
# invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
# quote = await ledger.get_melt_quote(quote_id)
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.paid.value
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_failed(ledger: Ledger, wallet: Wallet):
mint_quote = await wallet.request_mint(64)
await ledger.get_mint_quote(mint_quote.quote) # fakewallet: set the quote to paid
await wallet.mint(64, quote_id=mint_quote.quote)
# invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
# quote = await ledger.get_melt_quote(quote_id)
settings.fakewallet_payment_state = PaymentResult.FAILED.name
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningPaymentFailedError")
except LightningPaymentFailedError:
pass
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningPaymentFailedError")
except LightningPaymentFailedError:
pass
settings.fakewallet_payment_state = PaymentResult.FAILED.name
settings.fakewallet_pay_invoice_state = PaymentResult.UNKNOWN.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningPaymentFailedError")
except LightningPaymentFailedError:
pass
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name
settings.fakewallet_pay_invoice_state = PaymentResult.UNKNOWN.name
try:
await ledger.melt(proofs=wallet.proofs, quote=quote_id)
raise AssertionError("Expected LightningPaymentFailedError")
except LightningPaymentFailedError:
pass
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_settled(
ledger: Ledger, wallet: Wallet
):
mint_quote = await wallet.request_mint(64)
await ledger.get_mint_quote(mint_quote.quote) # fakewallet: set the quote to paid
await wallet.mint(64, quote_id=mint_quote.quote)
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.pending.value
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in wallet.proofs])
assert all([s.pending for s in states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_failed_pending(
ledger: Ledger, wallet: Wallet
):
mint_quote = await wallet.request_mint(64)
await ledger.get_mint_quote(mint_quote.quote) # fakewallet: set the quote to paid
await wallet.mint(64, quote_id=mint_quote.quote)
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
settings.fakewallet_payment_state = PaymentResult.PENDING.name
melt_response = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert melt_response.state == MeltQuoteState.pending.value
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in wallet.proofs])
assert all([s.pending for s in states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_melt_lightning_pay_invoice_exception_exception(
ledger: Ledger, wallet: Wallet
):
"""Simulates the case where pay_invoice and get_payment_status raise an exception (due to network issues for example)."""
settings.mint_disable_melt_on_error = True
mint_quote = await wallet.request_mint(64)
await ledger.get_mint_quote(mint_quote.quote) # fakewallet: set the quote to paid
await wallet.mint(64, quote_id=mint_quote.quote)
# invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
# quote = await ledger.get_melt_quote(quote_id)
settings.fakewallet_payment_state_exception = True
settings.fakewallet_pay_invoice_state_exception = True
# we expect a pending melt quote because something has gone wrong (for example has lost connection to backend)
resp = await ledger.melt(proofs=wallet.proofs, quote=quote_id)
assert resp.state == MeltQuoteState.pending.value
# the mint should be locked now and not allow any other melts until it is restarted
quote_id = (
await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=invoice_62_sat)
)
).quote
await assert_err(
ledger.melt(proofs=wallet.proofs, quote=quote_id),
"Melt is disabled. Please contact the operator.",
)
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_mint_melt_different_units(ledger: Ledger, wallet: Wallet):
"""Mint and melt different units."""
# load the wallet
mint_quote = await wallet.request_mint(64)
await wallet.mint(64, quote_id=mint_quote.quote)
amount = 32
# mint quote in sat
sat_mint_quote = await ledger.mint_quote(
quote_request=PostMintQuoteRequest(amount=amount, unit="sat")
)
sat_invoice = sat_mint_quote.request
assert sat_mint_quote.paid is False
# melt quote in usd
usd_melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(unit="usd", request=sat_invoice)
)
assert usd_melt_quote.paid is False
# pay melt quote with usd
await ledger.melt(proofs=wallet.proofs, quote=usd_melt_quote.quote)
output_amounts = [32]
secrets, rs, derivation_paths = await wallet.generate_n_secrets(len(output_amounts))
outputs, rs = wallet._construct_outputs(output_amounts, secrets, rs)
# mint in sat
mint_resp = await ledger.mint(outputs=outputs, quote_id=sat_mint_quote.quote)
assert len(mint_resp) == len(outputs)

View File

@@ -0,0 +1,452 @@
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.core.nuts import nut20
from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import get_real_invoice, is_fake, is_regtest, pay_if_regtest
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
@pytest_asyncio.fixture(scope="function")
async def wallet1(ledger: Ledger):
wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_melt_internal(wallet1: Wallet, ledger: Ledger):
# mint twice so we have enough to pay the second invoice back
mint_quote = await wallet1.request_mint(128)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(128, quote_id=mint_quote.quote)
assert wallet1.balance == 128
# create a mint quote so that we can melt to it internally
mint_quote_to_pay = await wallet1.request_mint(64)
invoice_payment_request = mint_quote_to_pay.request
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
)
assert not melt_quote.paid
assert melt_quote.state == MeltQuoteState.unpaid.value
assert melt_quote.amount == 64
assert melt_quote.fee_reserve == 0
if not settings.debug_mint_only_deprecated:
melt_quote_response_pre_payment = await wallet1.get_melt_quote(melt_quote.quote)
assert melt_quote_response_pre_payment
assert (
not melt_quote_response_pre_payment.state == MeltQuoteState.paid
), "melt quote should not be paid"
assert melt_quote_response_pre_payment.amount == 64
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
assert melt_quote_pre_payment.unpaid
keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 64)
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"
assert melt_quote_post_payment.paid
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_melt_external(wallet1: Wallet, ledger: Ledger):
# mint twice so we have enough to pay the second invoice back
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
assert wallet1.balance == 128
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
melt_quote = await wallet1.melt_quote(invoice_payment_request)
assert not melt_quote.paid, "mint quote should not be paid"
assert melt_quote.state == MeltQuoteState.unpaid
total_amount = melt_quote.amount + melt_quote.fee_reserve
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
)
if not settings.debug_mint_only_deprecated:
melt_quote_response_pre_payment = await wallet1.get_melt_quote(melt_quote.quote)
assert melt_quote_response_pre_payment
assert (
melt_quote_response_pre_payment.state == MeltQuoteState.unpaid
), "melt quote should not be paid"
assert melt_quote_response_pre_payment.amount == melt_quote.amount
melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
assert not melt_quote_pre_payment.paid, "melt quote should not be paid"
assert melt_quote_pre_payment.unpaid
assert not melt_quote.paid, "melt quote should not be paid"
await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)
melt_quote_post_payment = await ledger.get_melt_quote(melt_quote.quote)
assert melt_quote_post_payment.paid, "melt quote should be paid"
assert melt_quote_post_payment.paid
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
wallet_mint_quote = await wallet1.request_mint(128)
await ledger.get_mint_quote(wallet_mint_quote.quote)
mint_quote = await ledger.get_mint_quote(wallet_mint_quote.quote)
assert mint_quote.paid, "mint quote should be paid"
if not settings.debug_mint_only_deprecated:
mint_quote = await wallet1.get_mint_quote(mint_quote.quote)
assert mint_quote.state == MintQuoteState.paid, "mint quote should be paid"
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
assert wallet_mint_quote.privkey
signature = nut20.sign_mint_quote(
mint_quote.quote, outputs, wallet_mint_quote.privkey
)
await ledger.mint(outputs=outputs, quote_id=mint_quote.quote, signature=signature)
await assert_err(
ledger.mint(outputs=outputs, quote_id=mint_quote.quote),
"outputs have already been signed before.",
)
mint_quote_after_payment = await ledger.get_mint_quote(mint_quote.quote)
assert mint_quote_after_payment.issued, "mint quote should be issued"
assert mint_quote_after_payment.issued
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_mint_external(wallet1: Wallet, ledger: Ledger):
quote = await wallet1.request_mint(128)
mint_quote = await ledger.get_mint_quote(quote.quote)
assert not mint_quote.paid, "mint quote already paid"
assert mint_quote.unpaid
if not settings.debug_mint_only_deprecated:
mint_quote = await wallet1.get_mint_quote(quote.quote)
assert not mint_quote.paid, "mint quote should not be paid"
await assert_err(
wallet1.mint(128, quote_id=quote.quote),
"quote not paid",
)
await pay_if_regtest(quote.request)
mint_quote = await ledger.get_mint_quote(quote.quote)
assert mint_quote.paid, "mint quote should be paid"
assert mint_quote.paid
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
assert quote.privkey
signature = nut20.sign_mint_quote(quote.quote, outputs, quote.privkey)
await ledger.mint(outputs=outputs, quote_id=quote.quote, signature=signature)
mint_quote_after_payment = await ledger.get_mint_quote(quote.quote)
assert mint_quote_after_payment.issued, "mint quote should be issued"
@pytest.mark.asyncio
async def test_split(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 10)
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(len(send_proofs))
outputs, rs = wallet1._construct_outputs(
[p.amount for p in send_proofs], secrets, rs
)
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(outputs)
assert [p.amount for p in promises] == [p.amount for p in outputs]
@pytest.mark.asyncio
async def test_split_with_no_outputs(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 10, set_reserved=False)
await assert_err(
ledger.swap(proofs=send_proofs, outputs=[]),
"no outputs provided",
)
@pytest.mark.asyncio
async def test_split_with_input_less_than_outputs(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
keep_proofs, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 10, set_reserved=False
)
too_many_proofs = send_proofs + send_proofs
# generate more outputs than inputs
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(too_many_proofs)
)
outputs, rs = wallet1._construct_outputs(
[p.amount for p in too_many_proofs], secrets, rs
)
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"are not balanced",
)
# make sure we can still spend our tokens
keep_proofs, send_proofs = await wallet1.split(wallet1.proofs, 10)
@pytest.mark.asyncio
async def test_split_with_input_more_than_outputs(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
inputs = wallet1.proofs
# less outputs than inputs
output_amounts = [8]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
await assert_err(
ledger.swap(proofs=inputs, outputs=outputs),
"are not balanced",
)
# make sure we can still spend our tokens
keep_proofs, send_proofs = await wallet1.split(inputs, 10)
@pytest.mark.asyncio
async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, split=[64, 64], quote_id=mint_quote.quote)
inputs1 = wallet1.proofs[:1]
inputs2 = wallet1.proofs[1:]
assert inputs1[0].amount == 64
assert inputs2[0].amount == 64
output_amounts = [64]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
await ledger.swap(proofs=inputs1, outputs=outputs)
# try to spend other proofs with the same outputs again
await assert_err(
ledger.swap(proofs=inputs2, outputs=outputs),
"outputs have already been signed before.",
)
# try to spend inputs2 again with new outputs
output_amounts = [64]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
await ledger.swap(proofs=inputs2, outputs=outputs)
@pytest.mark.asyncio
async def test_mint_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
assert mint_quote.privkey
signature = nut20.sign_mint_quote(mint_quote.quote, outputs, mint_quote.privkey)
await ledger.mint(outputs=outputs, quote_id=mint_quote.quote, signature=signature)
# now try to mint with the same outputs again
mint_quote_2 = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote_2.request)
assert mint_quote_2.privkey
signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey)
await assert_err(
ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature),
"outputs have already been signed before.",
)
@pytest.mark.asyncio
async def test_melt_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(130)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(130, quote_id=mint_quote.quote)
output_amounts = [128]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# we use the outputs once for minting
mint_quote_2 = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote_2.request)
assert mint_quote_2.privkey
signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey)
await ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature)
# use the same outputs for melting
mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128))
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=mint_quote.request)
)
await assert_err(
ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs),
"outputs have already been signed before.",
)
@pytest.mark.asyncio
async def test_melt_with_less_inputs_than_invoice(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(32)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(32, quote_id=mint_quote.quote)
# outputs for fee return
output_amounts = [1, 1, 1, 1]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# create a mint quote to pay
mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128))
# prepare melt quote
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=mint_quote.request)
)
assert melt_quote.amount + melt_quote.fee_reserve > sum_proofs(wallet1.proofs)
# try to pay with not enough inputs
await assert_err(
ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs),
"not enough inputs provided for melt",
)
@pytest.mark.asyncio
async def test_melt_with_more_inputs_than_invoice(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(130)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(130, split=[64, 64, 2], quote_id=mint_quote.quote)
# outputs for fee return
output_amounts = [1, 1, 1, 1]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# create a mint quote to pay
mint_quote = await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128))
# prepare melt quote
melt_quote = await ledger.melt_quote(
PostMeltQuoteRequest(unit="sat", request=mint_quote.request)
)
# fees are 0 because it's internal
assert melt_quote.fee_reserve == 0
# make sure we have more inputs than the melt quote needs
assert sum_proofs(wallet1.proofs) >= melt_quote.amount + melt_quote.fee_reserve
melt_resp = await ledger.melt(
proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs
)
# we get 2 sats back because we overpaid
assert melt_resp.change
assert sum([o.amount for o in melt_resp.change]) == 2
@pytest.mark.asyncio
async def test_check_proof_state(wallet1: Wallet, ledger: Ledger):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
keep_proofs, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 10)
proof_states = await ledger.db_read.get_proofs_states(Ys=[p.Y for p in send_proofs])
assert all([p.state.value == "UNSPENT" for p in proof_states])
# TODO: test keeps running forever, needs to be fixed
# @pytest.mark.asyncio
# async def test_websocket_quote_updates(wallet1: Wallet, ledger: Ledger):
# mint_quote = await wallet1.request_mint(64)
# ws = websocket.create_connection(
# f"ws://localhost:{SERVER_PORT}/v1/quote/{invoice.id}"
# )
# await asyncio.sleep(0.1)
# await pay_if_regtest(mint_quote.request)
# await wallet1.mint(64, quote_id=mint_quote.quote)
# await asyncio.sleep(0.1)
# data = str(ws.recv())
# ws.close()
# n_lines = len(data.split("\n"))
# assert n_lines == 1

View File

@@ -0,0 +1,323 @@
import pytest
import pytest_asyncio
from cashu.core.base import P2PKWitness
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet as Wallet1
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import pay_if_regtest
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
@pytest_asyncio.fixture(scope="function")
async def wallet1(ledger: Ledger):
wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
async def test_ledger_inputs_require_sigall_detection(wallet1: Wallet1, ledger: Ledger):
"""Test the ledger function that detects if any inputs require SIG_ALL."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create two proofs: one with SIG_INPUTS and one with SIG_ALL
pubkey = await wallet1.create_p2pk_pubkey()
# Create a proof with SIG_INPUTS
secret_lock_inputs = await wallet1.create_p2pk_lock(pubkey, sig_all=False)
_, send_proofs_inputs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_inputs
)
# Create a new mint quote for the second mint operation
mint_quote_2 = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote_2.request)
await ledger.get_mint_quote(mint_quote_2.quote)
await wallet1.mint(64, quote_id=mint_quote_2.quote)
# Create a proof with SIG_ALL
secret_lock_all = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
_, send_proofs_all = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_all
)
# Test that _inputs_require_sigall correctly detects SIG_ALL flag
assert not ledger._inputs_require_sigall(
send_proofs_inputs
), "Should not detect SIG_ALL"
assert ledger._inputs_require_sigall(send_proofs_all), "Should detect SIG_ALL"
# Test with a mixed list of proofs (should detect SIG_ALL if any proof has it)
mixed_proofs = send_proofs_inputs + send_proofs_all
assert ledger._inputs_require_sigall(
mixed_proofs
), "Should detect SIG_ALL in mixed list"
@pytest.mark.asyncio
async def test_ledger_verify_p2pk_signature_validation(
wallet1: Wallet1, ledger: Ledger
):
"""Test the signature validation for P2PK inputs."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create a p2pk lock
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
# Create locked tokens
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Sign the tokens
signed_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs)
assert len(signed_proofs) > 0, "Should have signed proofs"
# Verify that a valid witness was added to the proofs
for proof in signed_proofs:
assert proof.witness is not None, "Proof should have a witness"
witness = P2PKWitness.from_witness(proof.witness)
assert len(witness.signatures) > 0, "Witness should have a signature"
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# The swap should succeed because the signatures are valid
promises = await ledger.swap(proofs=signed_proofs, outputs=outputs)
assert len(promises) == len(
outputs
), "Should have the same number of promises as outputs"
# Test for a failure
# Create a fake witness with an incorrect signature
fake_signature = "0" * 128 # Just a fake 64-byte hex string
for proof in send_proofs:
proof.witness = P2PKWitness(signatures=[fake_signature]).json()
# The swap should fail because the signatures are invalid
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"signature threshold not met",
)
@pytest.mark.asyncio
async def test_ledger_verify_incorrect_signature(wallet1: Wallet1, ledger: Ledger):
"""Test rejection of incorrect signatures for P2PK inputs."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create a p2pk lock
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
# Create locked tokens
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Create a fake witness with an incorrect signature
fake_signature = "0" * 128 # Just a fake 64-byte hex string
for proof in send_proofs:
proof.witness = P2PKWitness(signatures=[fake_signature]).json()
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# The swap should fail because the signatures are invalid
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"signature threshold not met",
)
@pytest.mark.asyncio
async def test_ledger_verify_sigall_validation(wallet1: Wallet1, ledger: Ledger):
"""Test validation of SIG_ALL signature that covers both inputs and outputs."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create a p2pk lock with SIG_ALL
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
# Create locked tokens
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# Create the message to sign (all inputs + all outputs)
message_to_sign = "".join([p.secret for p in send_proofs] + [o.B_ for o in outputs])
# Sign the message with the wallet's private key
signature = wallet1.schnorr_sign_message(message_to_sign)
# Add the signature to the first proof only (as required for SIG_ALL)
send_proofs[0].witness = P2PKWitness(signatures=[signature]).json()
# The swap should succeed because the SIG_ALL signature is valid
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(
outputs
), "Should have the same number of promises as outputs"
@pytest.mark.asyncio
async def test_ledger_verify_incorrect_sigall_signature(
wallet1: Wallet1, ledger: Ledger
):
"""Test rejection of incorrect SIG_ALL signatures."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create a p2pk lock with SIG_ALL
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
# Create locked tokens
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# Create a fake witness with an incorrect signature
fake_signature = "0" * 128 # Just a fake 64-byte hex string
send_proofs[0].witness = P2PKWitness(signatures=[fake_signature]).json()
# The swap should fail because the SIG_ALL signature is invalid
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"signature threshold not met",
)
@pytest.mark.asyncio
async def test_ledger_swap_p2pk_without_signature(wallet1: Wallet1, ledger: Ledger):
"""Test ledger swap with p2pk locked tokens without providing signatures."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
# Create a p2pk lock with wallet's own public key
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
# Use swap_to_send to create p2pk locked proofs
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# Attempt to swap WITHOUT adding signatures - this should fail
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"Witness is missing for p2pk signature",
)
@pytest.mark.asyncio
async def test_ledger_swap_p2pk_with_signature(wallet1: Wallet1, ledger: Ledger):
"""Test ledger swap with p2pk locked tokens with proper signatures."""
# Mint tokens to the wallet
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await ledger.get_mint_quote(mint_quote.quote)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
# Create a p2pk lock with wallet's own public key
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
# Use swap_to_send to create p2pk locked proofs
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Generate outputs for the swap
output_amounts = [32]
secrets, rs, derivation_paths = await wallet1.generate_n_secrets(
len(output_amounts)
)
outputs, rs = wallet1._construct_outputs(output_amounts, secrets, rs)
# Sign the p2pk inputs before sending to the ledger
signed_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs)
# Extract signed proofs and put them back in the send_proofs list
signed_proofs_secrets = [p.secret for p in signed_proofs]
for p in send_proofs:
if p.secret in signed_proofs_secrets:
send_proofs[send_proofs.index(p)] = signed_proofs[
signed_proofs_secrets.index(p.secret)
]
# Now swap with signatures - this should succeed
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
# Verify the result
assert len(promises) == len(outputs)
assert [p.amount for p in promises] == [o.amount for o in outputs]

View File

@@ -0,0 +1,635 @@
import copy
import time
from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import BlindedMessage, P2PKWitness
from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import P2PKSecret, SigFlags
from cashu.core.secret import Secret, SecretKind, Tags
from cashu.mint.ledger import Ledger
from cashu.wallet import migrations
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import pay_if_regtest
async def assert_err(f, msg):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
if msg not in str(exc.args[0]):
raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return
raise Exception(f"Expected error: {msg}, got no error")
@pytest_asyncio.fixture(scope="function")
async def wallet1(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1_p2pk_comprehensive",
name="wallet1",
)
await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2(ledger: Ledger):
wallet2 = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet2_p2pk_comprehensive",
name="wallet2",
)
await migrate_databases(wallet2.db, migrations)
await wallet2.load_mint()
yield wallet2
@pytest_asyncio.fixture(scope="function")
async def wallet3(ledger: Ledger):
wallet3 = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet3_p2pk_comprehensive",
name="wallet3",
)
await migrate_databases(wallet3.db, migrations)
await wallet3.load_mint()
yield wallet3
@pytest.mark.asyncio
async def test_p2pk_sig_inputs_basic(wallet1: Wallet, wallet2: Wallet, ledger: Ledger):
"""Test basic P2PK with SIG_INPUTS."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Verify wallet1 has tokens
assert wallet1.balance == 64
# Create locked tokens from wallet1 to wallet2
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Verify that sent tokens have P2PK secrets with SIG_INPUTS flag
for proof in send_proofs:
p2pk_secret = Secret.deserialize(proof.secret)
assert p2pk_secret.kind == SecretKind.P2PK.value
assert P2PKSecret.from_secret(p2pk_secret).sigflag == SigFlags.SIG_INPUTS
# Try to redeem without signatures (should fail)
unsigned_proofs = copy.deepcopy(send_proofs)
for proof in unsigned_proofs:
proof.witness = None
await assert_err(
ledger.swap(
proofs=unsigned_proofs, outputs=await create_test_outputs(wallet2, 16)
),
"Witness is missing for p2pk signature",
)
# Redeem with proper signatures
signed_proofs = wallet2.sign_p2pk_sig_inputs(send_proofs)
assert all(p.witness is not None for p in signed_proofs)
# Now swap should succeed
outputs = await create_test_outputs(wallet2, 16)
promises = await ledger.swap(proofs=signed_proofs, outputs=outputs)
assert len(promises) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_sig_all_valid(wallet1: Wallet, wallet2: Wallet, ledger: Ledger):
"""Test P2PK with SIG_ALL where the signature covers both inputs and outputs."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create locked tokens with SIG_ALL
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2, sig_all=True)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Verify that sent tokens have P2PK secrets with SIG_ALL flag
for proof in send_proofs:
p2pk_secret = Secret.deserialize(proof.secret)
assert p2pk_secret.kind == SecretKind.P2PK.value
assert P2PKSecret.from_secret(p2pk_secret).sigflag == SigFlags.SIG_ALL
# Create outputs for redemption
outputs = await create_test_outputs(wallet2, 16)
# Create a message from concatenated inputs and outputs
message_to_sign = "".join([p.secret for p in send_proofs] + [o.B_ for o in outputs])
# Sign with wallet2's private key
signature = wallet2.schnorr_sign_message(message_to_sign)
# Add the signature to the first proof only (since it's SIG_ALL)
send_proofs[0].witness = P2PKWitness(signatures=[signature]).json()
# Swap should succeed
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_sig_all_invalid(wallet1: Wallet, wallet2: Wallet, ledger: Ledger):
"""Test P2PK with SIG_ALL where the signature is invalid."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create locked tokens with SIG_ALL
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2, sig_all=True)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs for redemption
outputs = await create_test_outputs(wallet2, 16)
# Add an invalid signature
fake_signature = "0" * 128 # Just a fake 64-byte hex string
send_proofs[0].witness = P2PKWitness(signatures=[fake_signature]).json()
# Swap should fail
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs), "signature threshold not met"
)
@pytest.mark.asyncio
async def test_p2pk_sig_all_mixed(wallet1: Wallet, wallet2: Wallet, ledger: Ledger):
"""Test that attempting to use mixed SIG_ALL and SIG_INPUTS proofs fails."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
# Create outputs
outputs = await create_test_outputs(wallet2, 32) # 16 + 16
# Create a proof with SIG_ALL
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
secret_lock_all = await wallet1.create_p2pk_lock(pubkey_wallet2, sig_all=True)
_, proofs_sig_all = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_all
)
# sign proofs_sig_all
signed_proofs_sig_all = wallet2.add_witness_swap_sig_all(proofs_sig_all, outputs)
# Mint more tokens to wallet1 for the SIG_INPUTS test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create a proof with SIG_INPUTS
secret_lock_inputs = await wallet1.create_p2pk_lock(pubkey_wallet2, sig_all=False)
_, proofs_sig_inputs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_inputs
)
# sign proofs_sig_inputs
signed_proofs_sig_inputs = wallet2.sign_p2pk_sig_inputs(proofs_sig_inputs)
# Combine the proofs
mixed_proofs = signed_proofs_sig_all + signed_proofs_sig_inputs
# Add an invalid signature to the SIG_ALL proof
mixed_proofs[0].witness = P2PKWitness(signatures=["0" * 128]).json()
# Try to use the mixed proofs (should fail)
await assert_err(
ledger.swap(proofs=mixed_proofs, outputs=outputs),
"not all secrets are equal.",
)
@pytest.mark.asyncio
async def test_p2pk_multisig_2_of_3(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test P2PK with 2-of-3 multisig."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(6400)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(6400, quote_id=mint_quote.quote)
# Get pubkeys from all wallets
pubkey1 = await wallet1.create_p2pk_pubkey()
pubkey2 = await wallet2.create_p2pk_pubkey()
pubkey3 = await wallet3.create_p2pk_pubkey()
# Create 2-of-3 multisig tokens locked to all three wallets
tags = Tags([["pubkeys", pubkey2, pubkey3]])
secret_lock = await wallet1.create_p2pk_lock(pubkey1, tags=tags, n_sigs=2)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs for redemption
outputs = await create_test_outputs(wallet1, 16)
# Sign with wallet1 (first signature)
signed_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs)
# Try to redeem with only 1 signature (should fail)
await assert_err(
ledger.swap(proofs=signed_proofs, outputs=outputs),
"not enough pubkeys (3) or signatures (1) present for n_sigs (2).",
)
# Mint new tokens for the second test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create new locked tokens
_, send_proofs2 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Sign with wallet1 (first signature)
signed_proofs2 = wallet1.sign_p2pk_sig_inputs(send_proofs2)
# Add signature from wallet2 (second signature)
signed_proofs2 = wallet2.sign_p2pk_sig_inputs(signed_proofs2)
# Now redemption should succeed with 2 of 3 signatures
# Create outputs for redemption
outputs = await create_test_outputs(wallet1, 16)
promises = await ledger.swap(proofs=signed_proofs2, outputs=outputs)
assert len(promises) == len(outputs)
# Mint new tokens for the third test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create new locked tokens
_, send_proofs3 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Alternative: sign with wallet1 and wallet3
signed_proofs3 = wallet1.sign_p2pk_sig_inputs(send_proofs3)
signed_proofs3 = wallet3.sign_p2pk_sig_inputs(signed_proofs3)
# This should also succeed
# Create outputs for redemption
outputs = await create_test_outputs(wallet1, 16)
promises2 = await ledger.swap(proofs=signed_proofs3, outputs=outputs)
assert len(promises2) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_timelock(wallet1: Wallet, wallet2: Wallet, ledger: Ledger):
"""Test P2PK with a timelock that expires."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create tokens with a 2-second timelock
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# Set a past timestamp to ensure test works consistently
past_time = int(time.time()) - 10
tags = Tags([["locktime", str(past_time)]])
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2, tags=tags)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Store current time to check if locktime passed
locktime = 0
for proof in send_proofs:
secret = Secret.deserialize(proof.secret)
p2pk_secret = P2PKSecret.from_secret(secret)
locktime = p2pk_secret.locktime
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Verify that current time is past the locktime
assert locktime is not None, "Locktime should not be None"
assert (
int(time.time()) > locktime
), f"Current time ({int(time.time())}) should be greater than locktime ({locktime})"
# Try to redeem without signature after locktime (should succeed)
unsigned_proofs = copy.deepcopy(send_proofs)
for proof in unsigned_proofs:
proof.witness = None
promises = await ledger.swap(proofs=unsigned_proofs, outputs=outputs)
assert len(promises) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_timelock_with_refund_before_locktime(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test P2PK with a timelock and refund pubkeys before locktime."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Get pubkeys
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # Receiver
pubkey_wallet3 = await wallet3.create_p2pk_pubkey() # Refund key
# Create tokens with a 2-second timelock and refund key
future_time = int(time.time()) + 60 # 60 seconds in the future
refund_tags = Tags([["refund", pubkey_wallet3], ["locktime", str(future_time)]])
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2, tags=refund_tags)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Try to redeem without any signature before locktime (should fail)
unsigned_proofs = copy.deepcopy(send_proofs)
for proof in unsigned_proofs:
proof.witness = None
await assert_err(
ledger.swap(proofs=unsigned_proofs, outputs=outputs),
"Witness is missing for p2pk signature",
)
# Try to redeem with refund key signature before locktime (should fail)
refund_signed_proofs = wallet3.sign_p2pk_sig_inputs(send_proofs)
await assert_err(
ledger.swap(proofs=refund_signed_proofs, outputs=outputs),
"signature threshold not met", # Refund key can't be used before locktime
)
@pytest.mark.asyncio
async def test_p2pk_timelock_with_receiver_signature(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test P2PK with a timelock and refund pubkeys with receiver signature."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Get pubkeys
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # Receiver
pubkey_wallet3 = await wallet3.create_p2pk_pubkey() # Refund key
# Create tokens with a 2-second timelock and refund key
future_time = int(time.time()) + 60 # 60 seconds in the future
refund_tags = Tags([["refund", pubkey_wallet3], ["locktime", str(future_time)]])
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2, tags=refund_tags)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Try to redeem with the correct receiver signature (should succeed)
receiver_signed_proofs = wallet2.sign_p2pk_sig_inputs(send_proofs)
promises = await ledger.swap(proofs=receiver_signed_proofs, outputs=outputs)
assert len(promises) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_timelock_with_refund_after_locktime(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test P2PK with a timelock and refund pubkeys after locktime."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Get pubkeys
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # Receiver
pubkey_wallet3 = await wallet3.create_p2pk_pubkey() # Refund key
# Create tokens with a past timestamp for locktime testing
past_time = int(time.time()) - 10 # 10 seconds in the past
refund_tags_past = Tags([["refund", pubkey_wallet3], ["locktime", str(past_time)]])
secret_lock_past = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=refund_tags_past
)
_, send_proofs3 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_past
)
# Try to redeem with refund key after locktime (should succeed)
refund_signed_proofs2 = wallet3.sign_p2pk_sig_inputs(send_proofs3)
# This should work because locktime has passed and refund key is used
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
promises2 = await ledger.swap(proofs=refund_signed_proofs2, outputs=outputs)
assert len(promises2) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_n_sigs_refund(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test P2PK with a timelock and multiple refund pubkeys with n_sigs_refund."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Get pubkeys
pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # Receiver
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # Refund key 1
pubkey_wallet3 = await wallet3.create_p2pk_pubkey() # Refund key 2
# Create tokens with a future timelock and 2-of-2 refund requirement
future_time = int(time.time()) + 60 # 60 seconds in the future
refund_tags = Tags(
[
["refund", pubkey_wallet2, pubkey_wallet3],
["n_sigs_refund", "2"],
["locktime", str(future_time)],
]
)
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet1, tags=refund_tags)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Mint new tokens for receiver test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create new locked tokens
_, send_proofs2 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Try to redeem with receiver key (should succeed before locktime)
receiver_signed_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs2)
promises = await ledger.swap(proofs=receiver_signed_proofs, outputs=outputs)
assert len(promises) == len(outputs)
# Mint new tokens for the refund test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create tokens with a past locktime for refund testing
past_time = int(time.time()) - 10 # 10 seconds in the past
refund_tags_past = Tags(
[
["refund", pubkey_wallet2, pubkey_wallet3],
["n_sigs_refund", "2"],
["locktime", str(past_time)],
]
)
secret_lock_past = await wallet1.create_p2pk_lock(
pubkey_wallet1, tags=refund_tags_past
)
_, send_proofs3 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_past
)
# Try to redeem with only one refund key signature (should fail)
refund_signed_proofs = wallet2.sign_p2pk_sig_inputs(send_proofs3)
await assert_err(
ledger.swap(proofs=refund_signed_proofs, outputs=outputs),
"not enough pubkeys (2) or signatures (1) present for n_sigs (2).",
)
# Mint new tokens for the final test
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create tokens with same past locktime
_, send_proofs4 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_past
)
# Add both refund signatures
refund_signed_proofs2 = wallet2.sign_p2pk_sig_inputs(send_proofs4)
refund_signed_proofs2 = wallet3.sign_p2pk_sig_inputs(refund_signed_proofs2)
# Now it should succeed with 2-of-2 refund signatures
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
promises2 = await ledger.swap(proofs=refund_signed_proofs2, outputs=outputs)
assert len(promises2) == len(outputs)
@pytest.mark.asyncio
async def test_p2pk_invalid_pubkey_check(
wallet1: Wallet, wallet2: Wallet, ledger: Ledger
):
"""Test that an invalid public key is properly rejected."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create an invalid pubkey string (too short)
invalid_pubkey = "03aaff"
# Try to create a P2PK lock with invalid pubkey
# This should fail in create_p2pk_lock, but if it doesn't, let's handle it gracefully
try:
secret_lock = await wallet1.create_p2pk_lock(invalid_pubkey)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Verify it fails during validation
await assert_err(
ledger.swap(proofs=send_proofs, outputs=outputs),
"failed to deserialize pubkey", # Generic error for pubkey issues
)
except Exception as e:
# If it fails during creation, that's fine too
assert (
"pubkey" in str(e).lower() or "key" in str(e).lower()
), f"Expected error about invalid public key, got: {str(e)}"
@pytest.mark.asyncio
async def test_p2pk_sig_all_with_multiple_pubkeys(
wallet1: Wallet, wallet2: Wallet, wallet3: Wallet, ledger: Ledger
):
"""Test SIG_ALL combined with multiple pubkeys/n_sigs."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Get pubkeys
pubkey1 = await wallet1.create_p2pk_pubkey()
pubkey2 = await wallet2.create_p2pk_pubkey()
pubkey3 = await wallet3.create_p2pk_pubkey()
# Create tokens with SIG_ALL and 2-of-3 multisig
tags = Tags([["pubkeys", pubkey2, pubkey3]])
secret_lock = await wallet1.create_p2pk_lock(
pubkey1, tags=tags, n_sigs=2, sig_all=True
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock
)
# Create outputs
outputs = await create_test_outputs(wallet1, 16)
# Create message to sign (all inputs + all outputs)
message_to_sign = "".join([p.secret for p in send_proofs] + [o.B_ for o in outputs])
# Sign with wallet1's key
signature1 = wallet1.schnorr_sign_message(message_to_sign)
# Sign with wallet2's key
signature2 = wallet2.schnorr_sign_message(message_to_sign)
# Add both signatures to the first proof only (SIG_ALL)
send_proofs[0].witness = P2PKWitness(signatures=[signature1, signature2]).json()
# This should succeed with 2 valid signatures
promises = await ledger.swap(proofs=send_proofs, outputs=outputs)
assert len(promises) == len(outputs)
async def create_test_outputs(wallet: Wallet, amount: int) -> List[BlindedMessage]:
"""Helper to create blinded outputs for testing."""
output_amounts = [amount]
secrets, rs, _ = await wallet.generate_n_secrets(len(output_amounts))
outputs, _ = wallet._construct_outputs(output_amounts, secrets, rs)
return outputs

View File

@@ -0,0 +1,323 @@
import asyncio
import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Method, Unit
from cashu.core.models import PostMeltQuoteRequest
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,
get_real_invoice_cln,
is_fake,
pay_if_regtest,
pay_real_invoice,
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_lightning_create_invoice(ledger: Ledger):
invoice = await ledger.backends[Method.bolt11][Unit.sat].create_invoice(
Amount(Unit.sat, 1000)
)
assert invoice.ok
assert invoice.payment_request
assert invoice.checking_id
# TEST 2: check the invoice status
status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status(
invoice.checking_id
)
assert status.pending
# settle the invoice
await pay_if_regtest(invoice.payment_request)
# TEST 3: check the invoice status
status = await ledger.backends[Method.bolt11][Unit.sat].get_invoice_status(
invoice.checking_id
)
assert status.settled
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_get_payment_quote(ledger: Ledger):
invoice_dict = get_real_invoice(64)
request = invoice_dict["payment_request"]
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
assert payment_quote.amount == Amount(Unit.sat, 64)
assert payment_quote.checking_id
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice(ledger: Ledger):
invoice_dict = get_real_invoice(64)
request = invoice_dict["payment_request"]
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id="test",
amount=64,
fee_reserve=0,
)
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000)
assert payment.settled
assert payment.preimage
assert payment.checking_id
assert not payment.error_message
# TEST 2: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
payment.checking_id
)
assert status.settled
assert status.preimage
assert not status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_failure(ledger: Ledger):
# create an invoice with the external CLN node and pay it with the external LND so that our mint backend can't pay it
request = get_real_invoice_cln(64)
# pay the invoice so that the attempt later fails
pay_real_invoice(request)
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# TEST 1: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
checking_id
)
assert status.unknown
# TEST 2: pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id="test",
amount=64,
fee_reserve=0,
)
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000)
assert payment.failed
assert not payment.preimage
assert payment.error_message
assert not payment.checking_id
# TEST 3: check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
checking_id
)
assert status.failed or status.unknown
assert not status.preimage
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_pending_success(ledger: Ledger):
# create a hold invoice
preimage, invoice_dict = get_hold_invoice(64)
request = str(invoice_dict["payment_request"])
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id=checking_id,
amount=64,
fee_reserve=0,
)
async def pay():
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
quote, 1000
)
return payment
task = asyncio.create_task(pay())
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.pending
# settle the invoice
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
# collect the payment
payment = await task
assert payment.settled
assert payment.preimage
assert payment.checking_id
assert not payment.error_message
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.settled
assert status.preimage
assert not status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_lightning_pay_invoice_pending_failure(ledger: Ledger):
# create a hold invoice
preimage, invoice_dict = get_hold_invoice(64)
request = str(invoice_dict["payment_request"])
payment_hash = bolt11.decode(request).payment_hash
# we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
)
checking_id = payment_quote.checking_id
# pay the invoice
quote = MeltQuote(
quote="test",
method=Method.bolt11.name,
unit=Unit.sat.name,
state=MeltQuoteState.unpaid,
request=request,
checking_id=checking_id,
amount=64,
fee_reserve=0,
)
async def pay():
payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
quote, 1000
)
return payment
task = asyncio.create_task(pay())
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert status.pending
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
# collect the payment
payment = await task
assert payment.failed
assert not payment.preimage
# assert payment.error_message
# check the payment status
status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
quote.checking_id
)
assert (
status.failed or status.unknown
) # some backends send unknown instead of failed if they can't find the payment
assert not status.preimage
# assert status.error_message
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pending_quote(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(ledger.melt(proofs=send_proofs, quote=quote.quote))
# 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 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)
await asyncio.sleep(SLEEP_TIME)
# expect that proofs are now spent
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.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