mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-19 10:04:19 +01:00
* wip blank outputs * wip: working * store ids for promises correctly * tests * fix migraiton * revert * fix tests * fix auth server * fix last tests * retroactively change migration, initial and m017_foreign_keys_proof_tables, remove c_b and replace with c_ (same for b_) * fix constraint * oops * msg stack fix * fix test foreign key constraint * fix postgres tests * foreign key constraint test * should fix psql error * foreign key constraint sqlite * rename to update_blinded_message_signature * drop outputs and change columns from melt_quotes table * switch migration order * reorder migrations again * fix migration * add tests * fix postgres migration too * create signed_at column postgres * foreign key constraingt promises table * migration tool * readme
756 lines
25 KiB
Python
756 lines
25 KiB
Python
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.models import PostMeltQuoteRequest
|
|
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",
|
|
"balance_log",
|
|
"balance",
|
|
"balance_issued",
|
|
"balance_redeemed",
|
|
]
|
|
|
|
tables.sort()
|
|
tables_expected.sort()
|
|
if ledger.db.type == db.SQLITE:
|
|
# SQLite does not return views
|
|
tables_expected.remove("balance")
|
|
tables_expected.remove("balance_issued")
|
|
tables_expected.remove("balance_redeemed")
|
|
assert tables == tables_expected
|
|
|
|
|
|
@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.keysets
|
|
),
|
|
ledger.db_write._verify_spent_proofs_and_set_pending(
|
|
wallet.proofs, ledger.keysets
|
|
),
|
|
),
|
|
[
|
|
"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, ledger.keysets
|
|
)
|
|
|
|
await assert_err(
|
|
asyncio.gather(
|
|
ledger.db_write._verify_spent_proofs_and_set_pending(
|
|
wallet.proofs, ledger.keysets
|
|
),
|
|
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.keysets
|
|
),
|
|
ledger.db_write._verify_spent_proofs_and_set_pending(
|
|
wallet.proofs[1:], ledger.keysets
|
|
),
|
|
)
|
|
|
|
|
|
@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, ledger.keysets
|
|
),
|
|
"failed to acquire database lock",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_and_sign_blinded_message(ledger: Ledger):
|
|
# Localized imports to avoid polluting module scope
|
|
from cashu.core.crypto.b_dhke import step1_alice, step2_bob
|
|
from cashu.core.crypto.secp import PublicKey
|
|
|
|
# Arrange: prepare a blinded message tied to current active keyset
|
|
amount = 8
|
|
keyset_id = ledger.keyset.id
|
|
B_pubkey, _ = step1_alice("test_store_and_sign_blinded_message")
|
|
B_hex = B_pubkey.serialize().hex()
|
|
|
|
# Act: store the blinded message (unsinged promise row)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=B_hex,
|
|
id=keyset_id,
|
|
)
|
|
|
|
# Act: compute a valid blind signature for the stored row and persist it
|
|
private_key_amount = ledger.keyset.private_keys[amount]
|
|
B_point = PublicKey(bytes.fromhex(B_hex), raw=True)
|
|
C_point, e, s = step2_bob(B_point, private_key_amount)
|
|
|
|
await ledger.crud.update_blinded_message_signature(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=B_hex,
|
|
c_=C_point.serialize().hex(),
|
|
e=e.serialize(),
|
|
s=s.serialize(),
|
|
)
|
|
|
|
# Assert: row is now a full promise and can be read back via get_promise
|
|
promise = await ledger.crud.get_promise(db=ledger.db, b_=B_hex)
|
|
assert promise is not None
|
|
assert promise.amount == amount
|
|
assert promise.C_ == C_point.serialize().hex()
|
|
assert promise.id == keyset_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_blinded_messages_by_melt_id(wallet: Wallet, ledger: Ledger):
|
|
# Arrange
|
|
from cashu.core.crypto.b_dhke import step1_alice
|
|
|
|
amount = 8
|
|
keyset_id = ledger.keyset.id
|
|
# Create a real melt quote to satisfy FK on promises.melt_quote
|
|
mint_quote = await wallet.request_mint(64)
|
|
melt_quote = await ledger.melt_quote(
|
|
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
|
|
)
|
|
melt_id = melt_quote.quote
|
|
|
|
# Create two blinded messages
|
|
B1, _ = step1_alice("get_by_melt_id_1")
|
|
B2, _ = step1_alice("get_by_melt_id_2")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
# Persist as unsigned messages with proper melt_id FK
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
|
|
# Act
|
|
rows = await ledger.crud.get_blinded_messages_melt_id(db=ledger.db, melt_id=melt_id)
|
|
|
|
# Assert
|
|
assert len(rows) == 2
|
|
assert {r.B_ for r in rows} == {b1_hex, b2_hex}
|
|
assert all(r.id == keyset_id for r in rows)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_blinded_messages_by_melt_id(wallet: Wallet, ledger: Ledger):
|
|
from cashu.core.crypto.b_dhke import step1_alice
|
|
|
|
amount = 4
|
|
keyset_id = ledger.keyset.id
|
|
# Create a real melt quote to satisfy FK on promises.melt_quote
|
|
mint_quote = await wallet.request_mint(64)
|
|
melt_quote = await ledger.melt_quote(
|
|
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
|
|
)
|
|
melt_id = melt_quote.quote
|
|
|
|
# Create two blinded messages
|
|
B1, _ = step1_alice("delete_by_melt_id_1")
|
|
B2, _ = step1_alice("delete_by_melt_id_2")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
# Persist as unsigned messages
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
|
|
rows_before = await ledger.crud.get_blinded_messages_melt_id(
|
|
db=ledger.db, melt_id=melt_id
|
|
)
|
|
assert len(rows_before) == 2
|
|
|
|
# Act: delete all unsigned messages for this melt_id
|
|
await ledger.crud.delete_blinded_messages_melt_id(db=ledger.db, melt_id=melt_id)
|
|
|
|
# Assert: now none left for that melt_id
|
|
rows_after = await ledger.crud.get_blinded_messages_melt_id(
|
|
db=ledger.db, melt_id=melt_id
|
|
)
|
|
assert rows_after == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_blinded_messages_by_melt_id_filters_signed(
|
|
wallet: Wallet, ledger: Ledger
|
|
):
|
|
from cashu.core.crypto.b_dhke import step1_alice, step2_bob
|
|
from cashu.core.crypto.secp import PublicKey
|
|
|
|
amount = 2
|
|
keyset_id = ledger.keyset.id
|
|
# Create a real melt quote to satisfy FK on promises.melt_quote
|
|
mint_quote = await wallet.request_mint(64)
|
|
melt_quote = await ledger.melt_quote(
|
|
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
|
|
)
|
|
melt_id = melt_quote.quote
|
|
|
|
B1, _ = step1_alice("filter_by_melt_id_1")
|
|
B2, _ = step1_alice("filter_by_melt_id_2")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
# Persist two unsigned messages
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
|
|
# Sign one of them (it should no longer be returned by get_blinded_messages_melt_id which filters c_ IS NULL)
|
|
priv = ledger.keyset.private_keys[amount]
|
|
C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex), raw=True), priv)
|
|
await ledger.crud.update_blinded_message_signature(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=b1_hex,
|
|
c_=C_point.serialize().hex(),
|
|
e=e.serialize(),
|
|
s=s.serialize(),
|
|
)
|
|
|
|
# Act
|
|
rows = await ledger.crud.get_blinded_messages_melt_id(db=ledger.db, melt_id=melt_id)
|
|
|
|
# Assert: only the unsigned one remains (b2_hex)
|
|
assert len(rows) == 1
|
|
assert rows[0].B_ == b2_hex
|
|
assert rows[0].id == keyset_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_blinded_message(ledger: Ledger):
|
|
from cashu.core.crypto.b_dhke import step1_alice
|
|
|
|
amount = 8
|
|
keyset_id = ledger.keyset.id
|
|
B_pub, _ = step1_alice("test_store_blinded_message")
|
|
b_hex = B_pub.serialize().hex()
|
|
|
|
# Act: store unsigned blinded message
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b_hex, id=keyset_id
|
|
)
|
|
|
|
# Assert: row exists and is unsigned (c_ IS NULL)
|
|
async with ledger.db.connect() as conn:
|
|
row = await conn.fetchone(
|
|
f"SELECT amount, id, b_, c_, created FROM {ledger.db.table_with_schema('promises')} WHERE b_ = :b_",
|
|
{"b_": b_hex},
|
|
)
|
|
assert row is not None
|
|
assert int(row["amount"]) == amount
|
|
assert row["id"] == keyset_id
|
|
assert row["b_"] == b_hex
|
|
assert row["c_"] is None
|
|
assert row["created"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_blinded_message_signature_before_store_blinded_message_errors(
|
|
ledger: Ledger,
|
|
):
|
|
from cashu.core.crypto.b_dhke import step1_alice, step2_bob
|
|
from cashu.core.crypto.secp import PublicKey
|
|
|
|
amount = 8
|
|
# Generate a blinded message that we will NOT store
|
|
B_pub, _ = step1_alice("test_sign_before_store_blinded_message")
|
|
b_hex = B_pub.serialize().hex()
|
|
|
|
# Create a valid signature tuple for that blinded message
|
|
priv = ledger.keyset.private_keys[amount]
|
|
C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b_hex), raw=True), priv)
|
|
|
|
# Expect a DB-level error; on SQLite/Postgres this is typically a no-op update, so this test is xfail.
|
|
await assert_err(
|
|
ledger.crud.update_blinded_message_signature(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=b_hex,
|
|
c_=C_point.serialize().hex(),
|
|
e=e.serialize(),
|
|
s=s.serialize(),
|
|
),
|
|
"blinded message does not exist",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_blinded_message_duplicate_b_(ledger: Ledger):
|
|
from cashu.core.crypto.b_dhke import step1_alice
|
|
|
|
amount = 2
|
|
keyset_id = ledger.keyset.id
|
|
B_pub, _ = step1_alice("test_duplicate_b_")
|
|
b_hex = B_pub.serialize().hex()
|
|
|
|
# First insert should succeed
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b_hex, id=keyset_id
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_blind_signatures_by_melt_id_returns_signed(
|
|
wallet: Wallet, ledger: Ledger
|
|
):
|
|
from cashu.core.crypto.b_dhke import step1_alice, step2_bob
|
|
from cashu.core.crypto.secp import PublicKey
|
|
|
|
amount = 4
|
|
keyset_id = ledger.keyset.id
|
|
# Create a real melt quote to satisfy FK on promises.melt_quote
|
|
mint_quote = await wallet.request_mint(64)
|
|
melt_quote = await ledger.melt_quote(
|
|
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
|
|
)
|
|
melt_id = melt_quote.quote
|
|
|
|
# Prepare two blinded messages under the same melt_id
|
|
B1, _ = step1_alice("signed_promises_by_melt_id_1")
|
|
B2, _ = step1_alice("signed_promises_by_melt_id_2")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
|
|
# Sign only one of them -> should be returned by get_blind_signatures_melt_id
|
|
priv = ledger.keyset.private_keys[amount]
|
|
C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex), raw=True), priv)
|
|
await ledger.crud.update_blinded_message_signature(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=b1_hex,
|
|
c_=C_point.serialize().hex(),
|
|
e=e.serialize(),
|
|
s=s.serialize(),
|
|
)
|
|
|
|
# Act
|
|
signed = await ledger.crud.get_blind_signatures_melt_id(
|
|
db=ledger.db, melt_id=melt_id
|
|
)
|
|
|
|
# Assert: only the signed one is returned
|
|
assert len(signed) == 1
|
|
assert signed[0].amount == amount
|
|
assert signed[0].id == keyset_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_melt_quote_includes_change_signatures(
|
|
wallet: Wallet, ledger: Ledger
|
|
):
|
|
from cashu.core.crypto.b_dhke import step1_alice, step2_bob
|
|
from cashu.core.crypto.secp import PublicKey
|
|
|
|
amount = 8
|
|
keyset_id = ledger.keyset.id
|
|
|
|
# Create melt quote and attach outputs/promises under its melt_id
|
|
mint_quote = await wallet.request_mint(64)
|
|
melt_quote = await ledger.melt_quote(
|
|
PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
|
|
)
|
|
|
|
melt_id = melt_quote.quote
|
|
|
|
# Create two blinded messages, sign one -> becomes change
|
|
B1, _ = step1_alice("melt_quote_change_1")
|
|
B2, _ = step1_alice("melt_quote_change_2")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
await ledger.crud.store_blinded_message(
|
|
db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
|
|
)
|
|
|
|
# Sign one -> should appear in change loaded by get_melt_quote
|
|
priv = ledger.keyset.private_keys[amount]
|
|
C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex), raw=True), priv)
|
|
await ledger.crud.update_blinded_message_signature(
|
|
db=ledger.db,
|
|
amount=amount,
|
|
b_=b1_hex,
|
|
c_=C_point.serialize().hex(),
|
|
e=e.serialize(),
|
|
s=s.serialize(),
|
|
)
|
|
|
|
# Act
|
|
quote_db = await ledger.crud.get_melt_quote(quote_id=melt_id, db=ledger.db)
|
|
|
|
# Assert: change contains the signed promise(s)
|
|
assert quote_db is not None
|
|
assert quote_db.quote == melt_id
|
|
assert quote_db.change is not None
|
|
assert len(quote_db.change) == 1
|
|
assert quote_db.change[0].amount == amount
|
|
assert quote_db.change[0].id == keyset_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_promises_fk_constraints_enforced(ledger: Ledger):
|
|
from cashu.core.crypto.b_dhke import step1_alice
|
|
|
|
keyset_id = ledger.keyset.id
|
|
B1, _ = step1_alice("fk_check_melt")
|
|
B2, _ = step1_alice("fk_check_mint")
|
|
b1_hex = B1.serialize().hex()
|
|
b2_hex = B2.serialize().hex()
|
|
|
|
# Use a single connection and enable FK enforcement on SQLite
|
|
async with ledger.db.connect() as conn:
|
|
# Fake melt_id should violate FK on promises.melt_quote
|
|
await assert_err_multiple(
|
|
ledger.crud.store_blinded_message(
|
|
db=ledger.db,
|
|
amount=1,
|
|
b_=b1_hex,
|
|
id=keyset_id,
|
|
melt_id="nonexistent-melt-id",
|
|
conn=conn,
|
|
),
|
|
[
|
|
"FOREIGN KEY", # SQLite
|
|
"violates foreign key constraint", # Postgres
|
|
],
|
|
)
|
|
|
|
async with ledger.db.connect() as conn:
|
|
# Fake mint_id should violate FK on promises.mint_quote
|
|
await assert_err_multiple(
|
|
ledger.crud.store_blinded_message(
|
|
db=ledger.db,
|
|
amount=1,
|
|
b_=b2_hex,
|
|
id=keyset_id,
|
|
mint_id="nonexistent-mint-id",
|
|
conn=conn,
|
|
),
|
|
[
|
|
"FOREIGN KEY", # SQLite
|
|
"violates foreign key constraint", # Postgres
|
|
],
|
|
)
|
|
|
|
# Done. This test only checks FK enforcement paths.
|