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

555
tests/wallet/test_wallet.py Normal file
View File

@@ -0,0 +1,555 @@
import copy
from typing import List, Union
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, MintQuoteState, Proof
from cashu.core.errors import CashuError, KeysetNotFoundError
from cashu.core.helpers import sum_proofs
from cashu.core.settings import settings
from cashu.wallet.crud import (
get_bolt11_melt_quote,
get_bolt11_mint_quote,
get_keysets,
get_proofs,
)
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
is_deprecated_api_only,
is_fake,
is_github_actions,
is_regtest,
pay_if_regtest,
)
async def assert_err(f, msg: Union[str, CashuError]):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
error_message: str = str(exc.args[0])
if isinstance(msg, CashuError):
if msg.detail not in error_message:
raise Exception(
f"CashuError. Expected error: {msg.detail}, got: {error_message}"
)
return
if msg not in error_message:
raise Exception(f"Expected error: {msg}, got: {error_message}")
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")
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert sum([p.amount for p in proofs]) == expected
async def reset_wallet_db(wallet: Wallet):
await wallet.db.execute("DELETE FROM proofs")
await wallet.db.execute("DELETE FROM proofs_used")
await wallet.db.execute("DELETE FROM keysets")
await wallet.load_mint()
@pytest_asyncio.fixture(scope="function")
async def wallet1(mint):
wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = await Wallet2.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet2",
name="wallet2",
)
await wallet2.load_mint()
yield wallet2
@pytest.mark.asyncio
async def test_get_keys(wallet1: Wallet):
assert wallet1.keysets[wallet1.keyset_id].public_keys
assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order
keysets = await wallet1._get_keys()
keyset = keysets[0]
assert keyset.id is not None
# assert keyset.id_deprecated == "eGnEWtdJ0PIM"
assert keyset.id == "009a1f293253e41e"
assert isinstance(keyset.id, str)
assert len(keyset.id) > 0
@pytest.mark.asyncio
async def test_get_keyset(wallet1: Wallet):
assert wallet1.keysets[wallet1.keyset_id].public_keys
assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order
# let's get the keys first so we can get a keyset ID that we use later
keysets = await wallet1._get_keys()
keyset = keysets[0]
# gets the keys of a specific keyset
assert keyset.id is not None
assert keyset.public_keys is not None
keys2 = await wallet1._get_keyset(keyset.id)
assert keys2.public_keys is not None
assert len(keyset.public_keys) == len(keys2.public_keys)
@pytest.mark.asyncio
async def test_get_keyset_from_db(wallet1: Wallet):
# first load it from the mint
# await wallet1.activate_keyset()
# NOTE: conftest already called wallet.load_mint() which got the keys from the mint
keyset1 = copy.copy(wallet1.keysets[wallet1.keyset_id])
# then load it from the db
await wallet1.activate_keyset()
keyset2 = copy.copy(wallet1.keysets[wallet1.keyset_id])
assert keyset1.public_keys == keyset2.public_keys
assert keyset1.id == keyset2.id
# load it directly from the db
keysets_local = await get_keysets(db=wallet1.db, id=keyset1.id)
assert keysets_local[0]
keyset3 = keysets_local[0]
assert keyset1.public_keys == keyset3.public_keys
assert keyset1.id == keyset3.id
@pytest.mark.asyncio
async def test_get_info(wallet1: Wallet):
info = await wallet1._get_info()
assert info.name
@pytest.mark.asyncio
async def test_get_nonexistent_keyset(wallet1: Wallet):
await assert_err(
wallet1._get_keyset("nonexistent"),
KeysetNotFoundError(),
)
@pytest.mark.asyncio
async def test_get_keysets(wallet1: Wallet):
keysets = await wallet1._get_keysets()
assert isinstance(keysets, list)
assert len(keysets) > 0
assert wallet1.keyset_id in [k.id for k in keysets]
@pytest.mark.asyncio
async def test_request_mint(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
assert mint_quote.request
@pytest.mark.asyncio
async def test_mint(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
if not settings.debug_mint_only_deprecated:
mint_quote = await wallet1.get_mint_quote(mint_quote.quote)
assert mint_quote.request == mint_quote.request
assert mint_quote.state == MintQuoteState.paid
expected_proof_amounts = wallet1.split_wallet_state(64)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
# verify that proofs in proofs_used db have the same mint_id as the invoice in the db
mint_quote_2 = await get_bolt11_mint_quote(db=wallet1.db, quote=mint_quote.quote)
assert mint_quote_2
proofs_minted = await get_proofs(
db=wallet1.db, mint_id=mint_quote_2.quote, table="proofs"
)
assert len(proofs_minted) == len(expected_proof_amounts)
assert all([p.amount in expected_proof_amounts for p in proofs_minted])
assert all([p.mint_id == mint_quote_2.quote for p in proofs_minted])
@pytest.mark.asyncio
async def test_mint_amounts(wallet1: Wallet):
"""Mint predefined amounts"""
amts = [1, 1, 1, 2, 2, 4, 16]
mint_quote = await wallet1.request_mint(sum(amts))
await pay_if_regtest(mint_quote.request)
await wallet1.mint(amount=sum(amts), split=amts, quote_id=mint_quote.quote)
assert wallet1.balance == 27
assert wallet1.proof_amounts == amts
@pytest.mark.asyncio
async def test_mint_amounts_wrong_sum(wallet1: Wallet):
"""Mint predefined amounts"""
amts = [1, 1, 1, 2, 2, 4, 16]
mint_quote = await wallet1.request_mint(sum(amts))
await assert_err(
wallet1.mint(amount=sum(amts) + 1, split=amts, quote_id=mint_quote.quote),
"split must sum to amount",
)
@pytest.mark.asyncio
async def test_mint_amounts_wrong_order(wallet1: Wallet):
"""Mint amount that is not part in 2^n"""
amts = [1, 2, 3]
mint_quote = await wallet1.request_mint(sum(amts))
allowed_amounts = wallet1.get_allowed_amounts()
await assert_err(
wallet1.mint(amount=sum(amts), split=[1, 2, 3], quote_id=mint_quote.quote),
f"Can only mint amounts supported by the mint: {allowed_amounts}",
)
@pytest.mark.asyncio
async def test_split(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
# the outputs we keep that we expect after the split
expected_proof_amounts = wallet1.split_wallet_state(44)
p1, p2 = await wallet1.split(wallet1.proofs, 20)
assert wallet1.balance == 64
assert sum_proofs(p1) == 44
# what we keep should have the expected amounts
assert [p.amount for p in p1] == expected_proof_amounts
assert sum_proofs(p2) == 20
# what we send should be the optimal split
assert [p.amount for p in p2] == [4, 16]
assert all([p.id == wallet1.keyset_id for p in p1])
assert all([p.id == wallet1.keyset_id for p in p2])
@pytest.mark.asyncio
async def test_swap_to_send(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
# this will select 32 sats and them (nothing to keep)
keep_proofs, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, set_reserved=True
)
assert_amt(send_proofs, 32)
assert_amt(keep_proofs, 0)
spendable_proofs = wallet1.coinselect(wallet1.proofs, 32)
assert sum_proofs(spendable_proofs) == 32
assert sum_proofs(send_proofs) == 32
assert wallet1.balance == 64
assert wallet1.available_balance == 32
@pytest.mark.asyncio
async def test_split_more_than_balance(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await assert_err(
wallet1.split(wallet1.proofs, 128),
# "Mint Error: inputs do not have same amount as outputs",
"amount too large.",
)
assert wallet1.balance == 64
@pytest.mark.asyncio
async def test_melt(wallet1: Wallet):
# mint twice so we have enough to pay the second invoice back
topup_mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
await wallet1.mint(128, quote_id=topup_mint_quote.quote)
assert wallet1.balance == 128
invoice_payment_request = ""
if is_regtest:
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
if is_fake:
mint_quote = await wallet1.request_mint(64)
invoice_payment_request = mint_quote.request
quote = await wallet1.melt_quote(invoice_payment_request)
total_amount = quote.amount + quote.fee_reserve
if is_regtest:
# we expect a fee reserve of 2 sat for regtest
assert total_amount == 66
assert quote.fee_reserve == 2
if is_fake:
# we expect a fee reserve of 0 sat for fake
assert total_amount == 64
assert quote.fee_reserve == 0
if not settings.debug_mint_only_deprecated:
quote_resp = await wallet1.get_melt_quote(quote.quote)
assert quote_resp
assert quote_resp.amount == quote.amount
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
melt_response = await wallet1.melt(
proofs=send_proofs,
invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
if is_regtest:
assert melt_response.change, "No change returned"
assert len(melt_response.change) == 1, "More than one change returned"
# NOTE: we assume that we will get a token back from the same keyset as the ones we melted
# this could be wrong if we melted tokens from an old keyset but the returned ones are
# from a newer one.
assert melt_response.change[0].id == send_proofs[0].id, "Wrong keyset returned"
# verify that proofs in proofs_used db have the same melt_id as the invoice in the db
melt_quote_db = await get_bolt11_melt_quote(
db=wallet1.db, request=invoice_payment_request
)
assert melt_quote_db, "No melt quote in db"
# compare melt quote from API against db
if not settings.debug_mint_only_deprecated:
melt_quote_api_resp = await wallet1.get_melt_quote(melt_quote_db.quote)
assert melt_quote_api_resp, "No melt quote from API"
assert melt_quote_api_resp.quote == melt_quote_db.quote, "Wrong quote ID"
assert melt_quote_api_resp.amount == melt_quote_db.amount, "Wrong amount"
assert melt_quote_api_resp.fee_reserve == melt_quote_db.fee_reserve, "Wrong fee"
assert melt_quote_api_resp.request == melt_quote_db.request, "Wrong request"
assert melt_quote_api_resp.state == melt_quote_db.state, "Wrong state"
assert melt_quote_api_resp.unit == melt_quote_db.unit, "Wrong unit"
proofs_used = await get_proofs(
db=wallet1.db, melt_id=melt_quote_db.quote, table="proofs_used"
)
assert len(proofs_used) == len(send_proofs), "Not all proofs used"
assert all([p.melt_id == melt_quote_db.quote for p in proofs_used]), "Wrong melt_id"
# the payment was without fees so we need to remove it from the total amount
assert wallet1.balance == 128 - (total_amount - quote.fee_reserve), "Wrong balance"
assert wallet1.balance == 64, "Wrong balance"
@pytest.mark.asyncio
@pytest.mark.skipif(is_deprecated_api_only, reason="Deprecated API only")
async def test_get_melt_quote_state(wallet1: Wallet):
mint_quote = await wallet1.request_mint(128)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(128, quote_id=mint_quote.quote)
invoice_payment_request = ""
if is_regtest:
invoice_dict = get_real_invoice(64)
invoice_payment_request = invoice_dict["payment_request"]
if is_fake:
mint_quote = await wallet1.request_mint(64)
invoice_payment_request = mint_quote.request
quote = await wallet1.melt_quote(invoice_payment_request)
assert quote.state == MeltQuoteState.unpaid
assert quote.request == invoice_payment_request
total_amount = quote.amount + quote.fee_reserve
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
melt_response = await wallet1.melt(
proofs=send_proofs,
invoice=invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
melt_quote_wallet = MeltQuote.from_resp_wallet(
melt_response,
mint="test",
unit=quote.unit or "sat",
request=quote.request or invoice_payment_request,
)
# compare melt quote from API against db
melt_quote_api_resp = await wallet1.get_melt_quote(melt_quote_wallet.quote)
assert melt_quote_api_resp, "No melt quote from API"
assert melt_quote_api_resp.quote == melt_quote_wallet.quote, "Wrong quote ID"
assert melt_quote_api_resp.amount == melt_quote_wallet.amount, "Wrong amount"
assert melt_quote_api_resp.fee_reserve == melt_quote_wallet.fee_reserve, "Wrong fee"
assert melt_quote_api_resp.request == melt_quote_wallet.request, "Wrong request"
assert melt_quote_api_resp.state == melt_quote_wallet.state, "Wrong state"
assert melt_quote_api_resp.unit == melt_quote_wallet.unit, "Wrong unit"
@pytest.mark.asyncio
async def test_swap_to_send_more_than_balance(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await assert_err(
wallet1.swap_to_send(wallet1.proofs, 128, set_reserved=True),
"Balance too low",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
async def test_double_spend(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
doublespend = await wallet1.mint(64, quote_id=mint_quote.quote)
await wallet1.split(wallet1.proofs, 20)
await assert_err(
wallet1.split(doublespend, 20),
"Token already spent.",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
async def test_duplicate_proofs_double_spent(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
doublespend = await wallet1.mint(64, quote_id=mint_quote.quote)
await assert_err(
wallet1.split(wallet1.proofs + doublespend, 20),
"Mint Error: Duplicate inputs provided",
)
assert wallet1.balance == 64
assert wallet1.available_balance == 64
@pytest.mark.asyncio
@pytest.mark.skipif(is_github_actions, reason="GITHUB_ACTIONS")
async def test_split_race_condition(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# run two splits in parallel
import asyncio
await assert_err_multiple(
asyncio.gather(
wallet1.split(wallet1.proofs, 20),
wallet1.split(wallet1.proofs, 20),
),
["proofs are pending.", "already spent."],
)
@pytest.mark.asyncio
async def test_send_and_redeem(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
_, spendable_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, set_reserved=True
)
await wallet2.redeem(spendable_proofs)
assert wallet2.balance == 32
assert wallet1.balance == 64
assert wallet1.available_balance == 32
await wallet1.invalidate(spendable_proofs)
assert wallet1.balance == 32
assert wallet1.available_balance == 32
@pytest.mark.asyncio
async def test_invalidate_all_proofs(wallet1: Wallet):
"""Try to invalidate proofs that have not been spent yet. Should not work!"""
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await wallet1.invalidate(wallet1.proofs)
assert wallet1.balance == 0
@pytest.mark.asyncio
async def test_invalidate_unspent_proofs_with_checking(wallet1: Wallet):
"""Try to invalidate proofs that have not been spent yet but force no check."""
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await wallet1.invalidate(wallet1.proofs, check_spendable=True)
assert wallet1.balance == 64
@pytest.mark.asyncio
async def test_invalidate_batch_many_proofs(wallet1: Wallet):
"""Try to invalidate proofs that have not been spent yet but force no check."""
amount_to_mint = 500 # nutshell default value is 1000
mint_quote = await wallet1.request_mint(amount_to_mint)
await pay_if_regtest(mint_quote.request)
proofs = await wallet1.mint(
amount_to_mint, quote_id=mint_quote.quote, split=[1] * amount_to_mint
)
assert len(proofs) == amount_to_mint
states = await wallet1.check_proof_state(proofs)
assert all([s.unspent for s in states.states])
spent_proofs = await wallet1.get_spent_proofs_check_states_batched(proofs)
assert len(spent_proofs) == 0
assert wallet1.balance == amount_to_mint
@pytest.mark.asyncio
async def test_split_invalid_amount(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await assert_err(
wallet1.split(wallet1.proofs, -1),
"amount can't be negative",
)
@pytest.mark.asyncio
async def test_token_state(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
assert wallet1.balance == 64
resp = await wallet1.check_proof_state(wallet1.proofs)
assert resp.states[0].state.value == "UNSPENT"
@pytest.mark.asyncio
async def testactivate_keyset_specific_keyset(wallet1: Wallet):
await wallet1.activate_keyset()
assert list(wallet1.keysets.keys()) == ["009a1f293253e41e"]
await wallet1.activate_keyset(keyset_id=wallet1.keyset_id)
await wallet1.activate_keyset(keyset_id="009a1f293253e41e")
# expect deprecated keyset id to be present
await assert_err(
wallet1.activate_keyset(keyset_id="nonexistent"),
KeysetNotFoundError("nonexistent"),
)

View File

@@ -0,0 +1,199 @@
import asyncio
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from cashu.lightning.base import InvoiceResponse, PaymentResult, PaymentStatus
from cashu.wallet.api.app import app
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import is_regtest
@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.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_invoice(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/lightning/create_invoice?amount=100")
assert response.status_code == 200
invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(result=PaymentResult.PENDING)
while state.pending:
print("checking invoice state")
response2 = client.get(
f"/lightning/invoice_state?payment_request={invoice_response.payment_request}"
)
state = PaymentStatus.parse_obj(response2.json())
await asyncio.sleep(0.1)
print("state:", state)
print("paid")
await wallet.load_proofs()
assert wallet.available_balance >= 100
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_balance():
with TestClient(app) as client:
response = client.get("/balance")
assert response.status_code == 200
assert "balance" in response.json()
assert response.json()["keysets"]
assert response.json()["mints"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_send(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/send?amount=10")
assert response.status_code == 200
assert response.json()["balance"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_send_without_split(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/send?amount=2&offline=true")
assert response.status_code == 200
assert response.json()["balance"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_send_too_much(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/send?amount=110000")
assert response.status_code == 400
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_pending():
with TestClient(app) as client:
response = client.get("/pending")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_receive_all(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/receive?all=true")
assert response.status_code == 200
assert response.json()["initial_balance"]
assert response.json()["balance"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_burn_all(wallet: Wallet):
with TestClient(app) as client:
response = client.post("/send?amount=20")
assert response.status_code == 200
response = client.post("/burn?all=true")
assert response.status_code == 200
assert response.json()["balance"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_pay():
with TestClient(app) as client:
invoice = (
"lnbc100n1pjjcqzfdq4gdshx6r4ypjx2ur0wd5hgpp58xvj8yn00d5"
"7uhshwzcwgy9uj3vwf5y2lr5fjf78s4w9l4vhr6xssp5stezsyty9r"
"hv3lat69g4mhqxqun56jyehhkq3y8zufh83xyfkmmq4usaqwrt5q4f"
"adm44g6crckp0hzvuyv9sja7t65hxj0ucf9y46qstkay7gfnwhuxgr"
"krf7djs38rml39l8wpn5ug9shp3n55quxhdecqfwxg23"
)
response = client.post(f"/lightning/pay_invoice?bolt11={invoice}")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_lock():
with TestClient(app) as client:
response = client.get("/lock")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_locks():
with TestClient(app) as client:
response = client.get("/locks")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_invoices():
with TestClient(app) as client:
response = client.get("/invoices")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_wallets():
with TestClient(app) as client:
response = client.get("/wallets")
assert response.status_code == 200
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_info():
with TestClient(app) as client:
response = client.get("/info")
assert response.status_code == 200
assert response.json()["version"]
@pytest.mark.skipif(is_regtest, reason="regtest")
@pytest.mark.asyncio
async def test_flow(wallet: Wallet):
with TestClient(app) as client:
response = client.get("/balance")
initial_balance = response.json()["balance"]
response = client.post("/lightning/create_invoice?amount=100")
invoice_response = InvoiceResponse.parse_obj(response.json())
state = PaymentStatus(result=PaymentResult.PENDING)
while state.pending:
print("checking invoice state")
response2 = client.get(
f"/lightning/invoice_state?payment_request={invoice_response.payment_request}"
)
state = PaymentStatus.parse_obj(response2.json())
await asyncio.sleep(0.1)
print("state:", state)
response = client.get("/balance")
assert response.json()["balance"] == initial_balance + 100
response = client.post("/send?amount=50")
response = client.get("/balance")
assert response.json()["balance"] == initial_balance + 50
response = client.post("/send?amount=50")
response = client.get("/balance")
assert response.json()["balance"] == initial_balance
response = client.get("/pending")
token = response.json()["pending_token"]["0"]["token"]
amount = response.json()["pending_token"]["0"]["amount"]
response = client.post(f"/receive?token={token}")
response = client.get("/balance")
assert response.json()["balance"] == initial_balance + amount

View File

@@ -0,0 +1,251 @@
import hashlib
import os
import shutil
from pathlib import Path
import pytest
import pytest_asyncio
from cashu.core.base import Unit
from cashu.core.crypto.keys import random_hash
from cashu.core.crypto.secp import PrivateKey
from cashu.core.errors import (
BlindAuthFailedError,
BlindAuthRateLimitExceededError,
ClearAuthFailedError,
)
from cashu.core.settings import settings
from cashu.wallet.auth.auth import WalletAuth
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import assert_err
@pytest_asyncio.fixture(scope="function")
async def wallet():
dirpath = Path("test_data/wallet")
if dirpath.exists() and dirpath.is_dir():
shutil.rmtree(dirpath)
wallet = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet",
name="wallet",
)
await wallet.load_mint()
yield wallet
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_password(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(
wallet.mint_info, mint_auth_proofs=False
)
assert requires_auth
# expect JWT (CAT) with format ey*.ey*
assert auth_wallet.oidc_client.access_token
assert auth_wallet.oidc_client.access_token.split(".")[0].startswith("ey")
assert auth_wallet.oidc_client.access_token.split(".")[1].startswith("ey")
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_wrong_password(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="wrong_password",
)
await assert_err(auth_wallet.init_auth_wallet(wallet.mint_info), "401 Unauthorized")
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_mint(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
assert requires_auth
await auth_wallet.load_proofs()
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_mint_manually(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(
wallet.mint_info, mint_auth_proofs=False
)
assert requires_auth
assert len(auth_wallet.proofs) == 0
await auth_wallet.mint_blind_auth()
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_mint_manually_invalid_cat(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(
wallet.mint_info, mint_auth_proofs=False
)
assert requires_auth
assert len(auth_wallet.proofs) == 0
# invalidate CAT in the database
auth_wallet.oidc_client.access_token = random_hash()
# this is the code executed in auth_wallet.mint_blind_auth():
clear_auth_token = auth_wallet.oidc_client.access_token
if not clear_auth_token:
raise Exception("No clear auth token available.")
amounts = auth_wallet.mint_info.bat_max_mint * [1] # 1 AUTH tokens
secrets = [hashlib.sha256(os.urandom(32)).hexdigest() for _ in amounts]
rs = [PrivateKey(privkey=os.urandom(32), raw=True) for _ in amounts]
outputs, rs = auth_wallet._construct_outputs(amounts, secrets, rs)
# should fail because of invalid CAT
await assert_err(
auth_wallet.blind_mint_blind_auth(clear_auth_token, outputs),
ClearAuthFailedError.detail,
)
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_invoice(wallet: Wallet):
# should fail, wallet error
await assert_err(wallet.mint_quote(10, Unit.sat), "Mint requires blind auth")
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
assert requires_auth
await auth_wallet.load_proofs()
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
wallet.auth_db = auth_wallet.db
wallet.auth_keyset_id = auth_wallet.keyset_id
# should succeed
await wallet.mint_quote(10, Unit.sat)
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_invoice_invalid_bat(wallet: Wallet):
# should fail, wallet error
await assert_err(wallet.mint_quote(10, Unit.sat), "Mint requires blind auth")
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
assert requires_auth
await auth_wallet.load_proofs()
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
# invalidate blind auth proofs
for p in auth_wallet.proofs:
await auth_wallet.db.execute(
f"UPDATE proofs SET secret = '{random_hash()}' WHERE secret = '{p.secret}'"
)
wallet.auth_db = auth_wallet.db
wallet.auth_keyset_id = auth_wallet.keyset_id
# blind auth failed
await assert_err(wallet.mint_quote(10, Unit.sat), BlindAuthFailedError.detail)
@pytest.mark.skipif(
not settings.mint_require_auth,
reason="settings.mint_require_auth is False",
)
@pytest.mark.asyncio
async def test_wallet_auth_rate_limit(wallet: Wallet):
auth_wallet = await WalletAuth.with_db(
url=wallet.url,
db=wallet.db.db_location,
username="asd@asd.com",
password="asdasd",
)
requires_auth = await auth_wallet.init_auth_wallet(
wallet.mint_info, mint_auth_proofs=False
)
assert requires_auth
errored = False
for _ in range(100):
try:
await auth_wallet.mint_blind_auth()
except Exception as e:
assert BlindAuthRateLimitExceededError.detail in str(e)
errored = True
break
assert errored
# should have minted at least twice
assert len(auth_wallet.proofs) > auth_wallet.mint_info.bat_max_mint

View File

@@ -0,0 +1,587 @@
import asyncio
from typing import Tuple
import bolt11
import pytest
from click.testing import CliRunner
from cashu.core.base import TokenV4
from cashu.core.settings import settings
from cashu.wallet.cli.cli import cli
from cashu.wallet.wallet import Wallet
from tests.helpers import (
get_real_invoice,
is_deprecated_api_only,
is_fake,
is_regtest,
pay_if_regtest,
)
@pytest.fixture(autouse=True, scope="session")
def cli_prefix():
yield ["--wallet", "test_cli_wallet", "--host", settings.mint_url, "--tests"]
def get_bolt11_and_invoice_id_from_invoice_command(output: str) -> Tuple[str, str]:
invoice = [
line.split(" ")[1] for line in output.split("\n") if line.startswith("Invoice")
][0]
invoice_id = [
line.split(" ")[-1] for line in output.split("\n") if line.startswith("You can")
][0]
return invoice, invoice_id
def get_invoice_from_invoices_command(output: str) -> dict[str, str]:
splitted = output.split("\n")
removed_empty_and_hiphens = [
value for value in splitted if value and not value.startswith("-----")
]
# filter only lines that have ": " in them
removed_empty_and_hiphens = [
value for value in removed_empty_and_hiphens if ": " in value
]
dict_output = {
f"{value.split(': ')[0]}": value.split(": ")[1]
for value in removed_empty_and_hiphens
}
return dict_output
async def reset_invoices(wallet: Wallet):
await wallet.db.execute("DELETE FROM bolt11_melt_quotes")
await wallet.db.execute("DELETE FROM bolt11_mint_quotes")
async def init_wallet():
settings.debug = False
wallet = await Wallet.with_db(
url=settings.mint_url,
db="test_data/test_cli_wallet",
name="test_cli_wallet",
)
await wallet.load_proofs()
return wallet
def test_info(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "info"],
)
assert result.exception is None
print("INFO")
print(result.output)
result.output.startswith(f"Version: {settings.version}")
assert result.exit_code == 0
def test_info_with_mint(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "info", "--mint"],
)
assert result.exception is None
print("INFO --MINT")
print(result.output)
assert "Mint name" in result.output
assert result.exit_code == 0
def test_info_with_mnemonic(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "info", "--mnemonic"],
)
assert result.exception is None
print("INFO --MNEMONIC")
print(result.output)
assert "Mnemonic" in result.output
assert result.exit_code == 0
def test_balance(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "balance"],
)
assert result.exception is None
print("------ BALANCE ------")
print(result.output)
w = asyncio.run(init_wallet())
assert f"Balance: {w.available_balance} sat" in result.output
assert result.exit_code == 0
@pytest.mark.skipif(is_fake, reason="only works with FakeWallet")
def test_pay_invoice_regtest(mint, cli_prefix):
invoice_dict = get_real_invoice(10)
invoice_payment_request = invoice_dict["payment_request"]
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "pay", invoice_payment_request, "-y"],
)
assert result.exception is None
print("PAY INVOICE")
print(result.output)
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
def test_invoice(mint, cli_prefix):
if settings.debug_mint_only_deprecated:
pytest.skip("only works with v1 API")
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "1000"],
)
wallet = asyncio.run(init_wallet())
assert wallet.available_balance >= 1000
assert result.exit_code == 0
def test_invoice_return_immediately(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "-n", "1000"],
)
assert result.exception is None
invoice, invoice_id = get_bolt11_and_invoice_id_from_invoice_command(result.output)
asyncio.run(pay_if_regtest(invoice))
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "1000", "--id", invoice_id],
)
assert result.exception is None
wallet = asyncio.run(init_wallet())
assert wallet.available_balance >= 1000
assert result.exit_code == 0
@pytest.mark.skipif(is_deprecated_api_only, reason="only works with v1 API")
def test_invoice_with_memo(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "-n", "1000", "-m", "test memo"],
)
assert result.exception is None
# find word starting with ln in the output
lines = result.output.split("\n")
invoice_str = ""
for line in lines:
for word in line.split(" "):
if word.startswith("ln"):
invoice_str = word
break
if not invoice_str:
raise Exception("No invoice found in the output")
invoice_obj = bolt11.decode(invoice_str)
assert invoice_obj.amount_msat == 1000_000
assert invoice_obj.description == "test memo"
def test_invoice_with_split(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[
*cli_prefix,
"invoice",
"10",
"-s",
"1",
"-n",
],
)
assert result.exception is None
invoice, invoice_id = get_bolt11_and_invoice_id_from_invoice_command(result.output)
asyncio.run(pay_if_regtest(invoice))
result = runner.invoke(
cli,
[*cli_prefix, "invoice", "10", "-s", "1", "--id", invoice_id],
)
assert result.exception is None
assert result.exception is None
wallet = asyncio.run(init_wallet())
assert wallet.proof_amounts.count(1) >= 10
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
mint_quote = asyncio.run(wallet1.request_mint(64))
asyncio.run(pay_if_regtest(mint_quote.request))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--mint"],
)
# assert
print("INVOICES --mint")
assert result.exception is None
assert result.exit_code == 0
assert "Received 64 sat" in result.output
def test_invoices_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
mint_quote = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices"],
)
# assert
print("INVOICES")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "State" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == mint_quote.quote
assert get_invoice_from_invoices_command(result.output)["State"] == str(
mint_quote.state
)
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_onlypaid_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid"],
)
# assert
print("INVOICES --only-paid")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_onlypaid_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid"],
)
# assert
print("INVOICES --only-paid")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_onlyunpaid_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-unpaid", "--mint"],
)
# assert
print("INVOICES --only-unpaid --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_onlyunpaid_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
mint_quote = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-unpaid"],
)
# assert
print("INVOICES --only-unpaid")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "State" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == mint_quote.quote
assert get_invoice_from_invoices_command(result.output)["State"] == str(
mint_quote.state
)
def test_invoices_with_both_onlypaid_and_onlyunpaid_options(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--only-paid", "--only-unpaid"],
)
assert result.exception is None
print("INVOICES --only-paid --only-unpaid")
assert result.exit_code == 0
assert (
"You should only choose one option: either --only-paid or --only-unpaid"
in result.output
)
@pytest.mark.skipif(not is_fake, reason="only on fakewallet")
def test_invoices_with_pending_option(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--pending", "--mint"],
)
# assert
print("INVOICES --pending --mint")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." in result.output
def test_invoices_with_pending_option_without_minting(cli_prefix):
# arrange
wallet1 = asyncio.run(init_wallet())
asyncio.run(reset_invoices(wallet=wallet1))
mint_quote = asyncio.run(wallet1.request_mint(64))
# act
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "invoices", "--pending"],
)
# assert
print("INVOICES --pending")
assert result.exception is None
assert result.exit_code == 0
assert "No invoices found." not in result.output
assert "ID" in result.output
assert "State" in result.output
assert get_invoice_from_invoices_command(result.output)["ID"] == mint_quote.quote
assert get_invoice_from_invoices_command(result.output)["State"] == str(
mint_quote.state
)
def test_wallets(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "wallets"],
)
assert result.exception is None
print("WALLETS")
# on github this is empty
if len(result.output):
assert "wallet" in result.output
assert result.exit_code == 0
def test_send(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "10"],
)
assert result.exception is None
print("test_send", result.output)
token_str = result.output.split("\n")[0]
assert "cashuB" in token_str, "output does not have a token"
token = TokenV4.deserialize(token_str).to_tokenv3()
assert token.token[0].proofs[0].dleq is None, "dleq included"
def test_send_with_dleq(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "10", "--dleq"],
)
assert result.exception is None
print("test_send_with_dleq", result.output)
token_str = result.output.split("\n")[0]
assert "cashuB" in token_str, "output does not have a token"
token = TokenV4.deserialize(token_str).to_tokenv3()
assert token.token[0].proofs[0].dleq is not None, "no dleq included"
def test_send_legacy(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "10", "--legacy"],
)
assert result.exception is None
print("test_send_legacy", result.output)
# this is the legacy token in the output
token_str = result.output.split("\n")[0]
assert token_str.startswith("cashuAey"), "output is not as expected"
def test_send_offline(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "2", "--offline"],
)
assert result.exception is None
print("SEND")
print("test_send_without_split", result.output)
assert "cashuB" in result.output, "output does not have a token"
def test_send_too_much(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "100000"],
)
assert "Balance too low" in str(result.exception)
def test_receive_tokenv3(mint, cli_prefix):
runner = CliRunner()
token = "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjAwOWExZjI5MzI1M2U0MWUiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICI0NzlkY2E0MzUzNzU4MTM4N2Q1ODllMDU1MGY0Y2Q2MjFmNjE0MDM1MGY5M2Q4ZmI1OTA2YjJlMGRiNmRjYmI3IiwgIkMiOiAiMDM1MGQ0ZmI0YzdiYTMzNDRjMWRjYWU1ZDExZjNlNTIzZGVkOThmNGY4ODdkNTQwZmYyMDRmNmVlOWJjMjkyZjQ1In0sIHsiaWQiOiAiMDA5YTFmMjkzMjUzZTQxZSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogIjZjNjAzNDgwOGQyNDY5N2IyN2YxZTEyMDllNjdjNjVjNmE2MmM2Zjc3NGI4NWVjMGQ5Y2Y3MjE0M2U0NWZmMDEiLCAiQyI6ICIwMjZkNDlhYTE0MmFlNjM1NWViZTJjZGQzYjFhOTdmMjE1MDk2NTlkMDE3YWU0N2FjNDY3OGE4NWVkY2E4MGMxYmQifV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyJ9XX0=" # noqa
result = runner.invoke(
cli,
[
*cli_prefix,
"receive",
token,
],
)
assert result.exception is None
print("RECEIVE")
print(result.output)
def test_nostr_send(mint, cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[
*cli_prefix,
"send",
"1",
"-n",
"aafa164a8ab54a6b6c67bbac98a5d5aec7ea4075af8928a11478ab9d74aec4ca",
"-y",
],
)
assert result.exception is None
print("NOSTR_SEND")
print(result.output)
def test_pending(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "pending"],
)
assert result.exception is None
print(result.output)
assert result.exit_code == 0
def test_selfpay(cli_prefix):
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "selfpay"],
)
assert result.exception is None
print(result.output)
assert result.exit_code == 0
def test_send_with_lock(mint, cli_prefix):
# call "cashu locks" first and get the lock
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "locks"],
)
assert result.exception is None
print("test_send_with_lock", result.output)
# iterate through all words and get the word that starts with "P2PK:"
lock = None
for word in result.output.split(" "):
# strip the word
word = word.strip()
if word.startswith("P2PK:"):
lock = word
break
assert lock is not None, "no lock found"
pubkey = lock.split(":")[1]
# now lock the token
runner = CliRunner()
result = runner.invoke(
cli,
[*cli_prefix, "send", "10", "--lock", lock],
)
assert result.exception is None
print("test_send_with_lock", result.output)
token_str = result.output.split("\n")[0]
assert "cashuB" in token_str, "output does not have a token"
token = TokenV4.deserialize(token_str).to_tokenv3()
assert pubkey in token.token[0].proofs[0].secret

View File

@@ -0,0 +1,552 @@
import asyncio
import copy
import hashlib
import secrets
from typing import List
import pytest
import pytest_asyncio
from cashu.core.base import HTLCWitness, Proof
from cashu.core.crypto.secp import PrivateKey
from cashu.core.htlc import HTLCSecret
from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import SigFlags
from cashu.core.secret import SecretKind
from cashu.wallet import migrations
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2
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")
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 wallet1():
wallet1 = await Wallet1.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1"
)
await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = await Wallet2.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2"
)
await migrate_databases(wallet2.db, migrations)
wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet2.load_mint()
yield wallet2
@pytest.mark.asyncio
async def test_create_htlc_secret(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(preimage=preimage)
assert secret.data == preimage_hash
@pytest.mark.asyncio
async def test_htlc_split(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(preimage=preimage)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
for p in send_proofs:
assert HTLCSecret.deserialize(p.secret).data == preimage_hash
@pytest.mark.asyncio
async def test_htlc_redeem_with_preimage(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(preimage=preimage)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
for p in send_proofs:
p.witness = HTLCWitness(preimage=preimage).json()
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_with_wrong_preimage(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=f"{preimage[:-5]}11111"
) # wrong preimage
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
for p in send_proofs:
p.witness = HTLCWitness(preimage=preimage).json()
await assert_err(
wallet1.redeem(send_proofs), "Mint Error: HTLC preimage does not match"
)
@pytest.mark.asyncio
async def test_htlc_redeem_with_no_signature(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage, hashlock_pubkeys=[pubkey_wallet1]
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
for p in send_proofs:
p.witness = HTLCWitness(preimage=preimage).json()
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: no signatures in proof.",
)
@pytest.mark.asyncio
async def test_htlc_redeem_with_wrong_signature(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage, hashlock_pubkeys=[pubkey_wallet1]
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures = wallet1.signatures_proofs_sig_inputs(send_proofs)
for p, s in zip(send_proofs, signatures):
p.witness = HTLCWitness(
preimage=preimage, signatures=[f"{s[:-5]}11111"]
).json() # wrong signature
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: signature threshold not met",
)
@pytest.mark.asyncio
async def test_htlc_redeem_with_correct_signature(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage, hashlock_pubkeys=[pubkey_wallet1]
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures = wallet1.signatures_proofs_sig_inputs(send_proofs)
for p, s in zip(send_proofs, signatures):
p.witness = HTLCWitness(preimage=preimage, signatures=[s]).json()
await wallet1.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_with_2_of_1_signatures(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet1, pubkey_wallet2],
hashlock_n_sigs=1,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs)
for p, s1, s2 in zip(send_proofs, signatures1, signatures2):
p.witness = HTLCWitness(preimage=preimage, signatures=[s1, s2]).json()
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_with_2_of_2_signatures(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet1, pubkey_wallet2],
hashlock_n_sigs=2,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs)
for p, s1, s2 in zip(send_proofs, signatures1, signatures2):
p.witness = HTLCWitness(preimage=preimage, signatures=[s1, s2]).json()
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_with_2_of_2_signatures_with_duplicate_pubkeys(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = pubkey_wallet1
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet1, pubkey_wallet2],
hashlock_n_sigs=2,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs)
for p, s1, s2 in zip(send_proofs, signatures1, signatures2):
p.witness = HTLCWitness(preimage=preimage, signatures=[s1, s2]).json()
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: pubkeys must be unique.",
)
@pytest.mark.asyncio
async def test_htlc_redeem_with_3_of_3_signatures_but_only_2_provided(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet1, pubkey_wallet2],
hashlock_n_sigs=3,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs)
for p, s1, s2 in zip(send_proofs, signatures1, signatures2):
p.witness = HTLCWitness(preimage=preimage, signatures=[s1, s2]).json()
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: not enough pubkeys (2) or signatures (2) present for n_sigs (3).",
)
@pytest.mark.asyncio
async def test_htlc_redeem_with_2_of_3_signatures_with_2_valid_and_1_invalid_provided(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
privatekey_wallet3 = PrivateKey(secrets.token_bytes(32), raw=True)
assert privatekey_wallet3.pubkey
pubkey_wallet3 = privatekey_wallet3.pubkey.serialize().hex()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet1, pubkey_wallet2, pubkey_wallet3],
hashlock_n_sigs=2,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs)
signatures3 = [f"{s[:-5]}11111" for s in signatures1] # wrong signature
for p, s1, s2, s3 in zip(send_proofs, signatures1, signatures2, signatures3):
p.witness = HTLCWitness(preimage=preimage, signatures=[s1, s2, s3]).json()
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_hashlock_wrong_signature_timelock_correct_signature(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet2],
locktime_seconds=2,
locktime_pubkeys=[pubkey_wallet1],
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures = wallet1.signatures_proofs_sig_inputs(send_proofs)
for p, s in zip(send_proofs, signatures):
p.witness = HTLCWitness(preimage=preimage, signatures=[s]).json()
# should error because we used wallet2 signatures for the hash lock
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met",
)
await asyncio.sleep(2)
# should succeed since lock time has passed and we provided wallet1 signature for timelock
await wallet1.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_redeem_hashlock_wrong_signature_timelock_wrong_signature(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet2],
locktime_seconds=2,
locktime_pubkeys=[pubkey_wallet1, pubkey_wallet2],
locktime_n_sigs=2,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
signatures = wallet1.signatures_proofs_sig_inputs(send_proofs)
for p, s in zip(send_proofs, signatures):
p.witness = HTLCWitness(
preimage=preimage, signatures=[f"{s[:-5]}11111"]
).json() # wrong signature
# should error because we used wallet2 signatures for the hash lock
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 0 < 1.",
)
await asyncio.sleep(2)
# should fail since lock time has passed and we provided not enough signatures for the timelock locktime_n_sigs
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 1 < 2.",
)
@pytest.mark.asyncio
async def test_htlc_redeem_timelock_2_of_2_signatures(wallet1: Wallet, wallet2: Wallet):
"""Testing the 2-of-2 timelock (refund) signature case."""
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet2],
locktime_seconds=2,
locktime_pubkeys=[pubkey_wallet1, pubkey_wallet2],
locktime_n_sigs=2,
)
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
send_proofs_copy = send_proofs.copy()
signatures = wallet1.signatures_proofs_sig_inputs(send_proofs)
for p, s in zip(send_proofs, signatures):
p.witness = HTLCWitness(preimage=preimage, signatures=[s]).json()
# should error because we used wallet2 signatures for the hash lock
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 0 < 1.",
)
await asyncio.sleep(2)
# locktime has passed
# should fail. lock time has passed but we provided only wallet1 signature for timelock, we need 2 though
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: not enough pubkeys (2) or signatures (1) present for n_sigs (2).",
)
# let's add the second signature
send_proofs_copy = wallet2.sign_p2pk_sig_inputs(send_proofs_copy)
# now we can redeem it
await wallet1.redeem(send_proofs_copy)
@pytest.mark.asyncio
async def test_htlc_sigall_behavior(wallet1: Wallet, wallet2: Wallet):
"""Test HTLC with SIG_ALL flag, requiring signatures on both inputs and outputs."""
# Mint tokens for testing
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Setup HTLC parameters
preimage = "00000000000000000000000000000000"
# preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# Create HTLC lock with SIG_ALL flag
secret = await wallet1.create_htlc_lock(
preimage=preimage,
hashlock_pubkeys=[pubkey_wallet2],
hashlock_n_sigs=1,
)
# Modify the secret to use SIG_ALL
secret.tags["sigflag"] = SigFlags.SIG_ALL.value
# Send tokens with this HTLC lock
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
# verify sigflag is SIG_ALL
assert HTLCSecret.from_secret(secret).kind == SecretKind.HTLC.value
assert HTLCSecret.from_secret(secret).sigflag == SigFlags.SIG_ALL
# first redeem fails because no preimage
await assert_err(
wallet2.redeem(send_proofs), "Mint Error: no HTLC preimage provided"
)
# we add the preimage to the proof
for p in send_proofs:
p.witness = HTLCWitness(preimage=preimage).json()
# Should succeed, redeem adds signatures to the proof
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_htlc_n_sigs_refund_locktime(wallet1: Wallet, wallet2: Wallet):
"""Test HTLC with n_sigs_refund parameter requiring multiple signatures for refund after locktime."""
# Create a third wallet for the third signature
wallet3 = await Wallet.with_db(
SERVER_ENDPOINT, "test_data/wallet_htlc_3", "wallet3"
)
await migrate_databases(wallet3.db, migrations)
wallet3.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet3.load_mint()
# Mint tokens for testing
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Setup parameters
preimage = "00000000000000000000000000000000"
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
pubkey_wallet3 = await wallet3.create_p2pk_pubkey()
# Wrong preimage - making it so we can only spend via locktime
wrong_preimage = "11111111111111111111111111111111"
# Create HTLC with:
# 1. Timelock in the past
# 2. Three refund pubkeys with 2-of-3 signature requirement
secret = await wallet1.create_htlc_lock(
preimage=wrong_preimage, # this ensures we can't redeem via preimage
hashlock_pubkeys=[pubkey_wallet2],
locktime_seconds=-200000,
locktime_pubkeys=[pubkey_wallet1, pubkey_wallet2, pubkey_wallet3],
locktime_n_sigs=2, # require 2 of 3 signatures for refund
)
# # Send tokens with this lock
_, send_proofs = await wallet1.swap_to_send(wallet1.proofs, 8, secret_lock=secret)
send_proofs_copy = copy.deepcopy(send_proofs)
# # First, try correct preimage but should fail as we're using wrong preimage hash
# for p in send_proofs:
# p.witness = HTLCWitness(preimage=preimage).json()
# await assert_err(
# wallet2.redeem(send_proofs), "Mint Error: HTLC preimage does not match"
# )
# # Wait for locktime to pass
# await asyncio.sleep(2)
# Try redeeming with only 1 signature after locktime
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs_copy)
for p, sig in zip(send_proofs_copy, signatures1):
p.witness = HTLCWitness(preimage=preimage, signatures=[sig]).json()
# Should fail because we need 2 signatures
await assert_err(
wallet1.redeem(send_proofs_copy),
"Mint Error: not enough pubkeys (3) or signatures (1) present for n_sigs (2)",
)
# Make a fresh copy and add 2 signatures
send_proofs_copy2 = copy.deepcopy(send_proofs)
signatures1 = wallet1.signatures_proofs_sig_inputs(send_proofs_copy2)
signatures2 = wallet2.signatures_proofs_sig_inputs(send_proofs_copy2)
for p, sig1, sig2 in zip(send_proofs_copy2, signatures1, signatures2):
p.witness = HTLCWitness(preimage=preimage, signatures=[sig1, sig2]).json()
# Should succeed with 2 of 3 signatures after locktime
await wallet1.redeem(send_proofs_copy2)

View File

@@ -0,0 +1,140 @@
from typing import List, Union
import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import Proof
from cashu.core.errors import CashuError
from cashu.wallet.lightning import LightningWallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
is_deprecated_api_only,
is_fake,
is_regtest,
pay_if_regtest,
)
async def assert_err(f, msg: Union[str, CashuError]):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
error_message: str = str(exc.args[0])
if isinstance(msg, CashuError):
if msg.detail not in error_message:
raise Exception(
f"CashuError. Expected error: {msg.detail}, got: {error_message}"
)
return
if msg not in error_message:
raise Exception(f"Expected error: {msg}, got: {error_message}")
return
raise Exception(f"Expected error: {msg}, got no error")
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p.amount for p in proofs] == expected
async def reset_wallet_db(wallet: LightningWallet):
await wallet.db.execute("DELETE FROM proofs")
await wallet.db.execute("DELETE FROM proofs_used")
await wallet.db.execute("DELETE FROM keysets")
await wallet.load_mint()
@pytest_asyncio.fixture(scope="function")
async def wallet():
wallet = await LightningWallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet.async_init()
yield wallet
@pytest.mark.asyncio
async def test_create_invoice(wallet: LightningWallet):
invoice = await wallet.create_invoice(64)
assert invoice.payment_request
assert invoice.payment_request.startswith("ln")
@pytest.mark.asyncio
@pytest.mark.skipif(is_deprecated_api_only, reason="only works with v1 API")
async def test_create_invoice_with_description(wallet: LightningWallet):
invoice = await wallet.create_invoice(64, "test description")
assert invoice.payment_request
assert invoice.payment_request.startswith("ln")
invoiceObj = bolt11.decode(invoice.payment_request)
assert invoiceObj.description == "test description"
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_check_invoice_internal(wallet: LightningWallet):
# fill wallet
invoice = await wallet.create_invoice(64)
assert invoice.payment_request
status = await wallet.get_invoice_status(invoice.payment_request)
assert status.settled
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_check_invoice_external(wallet: LightningWallet):
# fill wallet
invoice = await wallet.create_invoice(64)
assert invoice.payment_request
status = await wallet.get_invoice_status(invoice.payment_request)
assert not status.settled
await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.payment_request)
assert status.settled
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_pay_invoice_internal(wallet: LightningWallet):
# fill wallet
invoice = await wallet.create_invoice(64)
assert invoice.payment_request
await wallet.get_invoice_status(invoice.payment_request)
assert wallet.available_balance >= 64
# pay invoice
invoice2 = await wallet.create_invoice(16)
assert invoice2.payment_request
status = await wallet.pay_invoice(invoice2.payment_request)
assert status.settled
# check payment
status = await wallet.get_payment_status(invoice2.payment_request)
assert status.settled
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only works with Regtest")
async def test_pay_invoice_external(wallet: LightningWallet):
# fill wallet
invoice = await wallet.create_invoice(64)
assert invoice.payment_request
await pay_if_regtest(invoice.payment_request)
status = await wallet.get_invoice_status(invoice.payment_request)
assert status.settled
assert wallet.available_balance >= 64
# pay invoice
invoice_real = get_real_invoice(16)
status = await wallet.pay_invoice(invoice_real["payment_request"])
assert status.settled
# check payment)
assert status.settled

View File

@@ -0,0 +1,704 @@
import asyncio
import copy
import hashlib
import json
import secrets
from typing import List
import pytest
import pytest_asyncio
from coincurve import PrivateKey as CoincurvePrivateKey
from cashu.core.base import P2PKWitness, Proof
from cashu.core.crypto.secp import PrivateKey, PublicKey
from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import P2PKSecret, SigFlags
from cashu.core.secret import Secret, SecretKind, Tags
from cashu.wallet import migrations
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import is_deprecated_api_only, 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")
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 wallet1():
wallet1 = await Wallet1.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_1", "wallet1"
)
await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = await Wallet2.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_2", "wallet2"
)
await migrate_databases(wallet2.db, migrations)
wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet2.load_mint()
yield wallet2
@pytest.mark.asyncio
async def test_create_p2pk_pubkey(wallet1: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey = await wallet1.create_p2pk_pubkey()
PublicKey(bytes.fromhex(pubkey), raw=True)
@pytest.mark.asyncio
async def test_p2pk(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
await wallet2.redeem(send_proofs)
proof_states = await wallet2.check_proof_state(send_proofs)
assert all([p.spent for p in proof_states.states])
if not is_deprecated_api_only:
for state in proof_states.states:
assert state.witness is not None
witness_obj = json.loads(state.witness)
assert len(witness_obj["signatures"]) == 1
assert len(witness_obj["signatures"][0]) == 128
@pytest.mark.asyncio
async def test_p2pk_sig_all(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, sig_all=True
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_p2pk_receive_with_wrong_private_key(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
# sender side
secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# receiver side: wrong private key
wallet2.private_key = PrivateKey() # wrong private key
await assert_err(
wallet2.redeem(send_proofs),
"",
)
@pytest.mark.asyncio
async def test_p2pk_short_locktime_receive_with_wrong_private_key(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
# sender side
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, locktime_seconds=2
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# receiver side: wrong private key
wallet2.private_key = PrivateKey() # wrong private key
send_proofs_copy = copy.deepcopy(send_proofs)
await assert_err(
wallet2.redeem(send_proofs),
"",
)
await asyncio.sleep(2)
# should succeed because even with the wrong private key we
# can redeem the tokens after the locktime
await wallet2.redeem(send_proofs_copy)
@pytest.mark.asyncio
async def test_p2pk_locktime_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
# sender side
garbage_priv = PrivateKey(secrets.token_bytes(32), raw=True)
garbage_pubkey = garbage_priv.pubkey
assert garbage_pubkey
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags([["refund", pubkey_wallet2]]), # refund pubkey
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
send_proofs_copy = copy.deepcopy(send_proofs)
# receiver side: can't redeem since we used a garbage pubkey
# and locktime has not passed
await assert_err(
wallet2.redeem(send_proofs),
"",
)
await asyncio.sleep(2)
# we can now redeem because of the refund locktime
await wallet2.redeem(send_proofs_copy)
@pytest.mark.asyncio
async def test_p2pk_locktime_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await wallet2.create_p2pk_pubkey() # receiver side
# sender side
garbage_priv = PrivateKey(secrets.token_bytes(32), raw=True)
garbage_pubkey = garbage_priv.pubkey
garbage_pubkey_2 = PrivateKey().pubkey
assert garbage_pubkey
assert garbage_pubkey_2
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags([["refund", garbage_pubkey_2.serialize().hex()]]), # refund pubkey
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
send_proofs_copy = copy.deepcopy(send_proofs)
# receiver side: can't redeem since we used a garbage pubkey
# and locktime has not passed
await assert_err(
wallet2.redeem(send_proofs),
"",
)
await asyncio.sleep(2)
# we still can't redeem it because we used garbage_pubkey_2 as a refund pubkey
await assert_err(
wallet2.redeem(send_proofs_copy),
"",
)
@pytest.mark.asyncio
async def test_p2pk_locktime_with_second_refund_pubkey(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # receiver side
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
# sender side
garbage_priv = PrivateKey(secrets.token_bytes(32), raw=True)
garbage_pubkey = garbage_priv.pubkey
assert garbage_pubkey
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags(
[["refund", pubkey_wallet2, pubkey_wallet1]]
), # multiple refund pubkeys
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
send_proofs_copy = copy.deepcopy(send_proofs)
# receiver side: can't redeem since we used a garbage pubkey
# and locktime has not passed
# WALLET WILL ADD A SIGNATURE BECAUSE IT SEES ITS REFUND PUBKEY (it adds a signature even though the locktime hasn't passed)
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 0 < 1.",
)
await asyncio.sleep(2)
# we can now redeem because of the refund locktime
await wallet1.redeem(send_proofs_copy)
@pytest.mark.asyncio
async def test_p2pk_locktime_with_2_of_2_refund_pubkeys(
wallet1: Wallet, wallet2: Wallet
):
"""Testing the case where we expect a 2-of-2 signature from the refund pubkeys"""
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey() # receiver side
pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side
# sender side
garbage_priv = PrivateKey(secrets.token_bytes(32), raw=True)
garbage_pubkey = garbage_priv.pubkey
assert garbage_pubkey
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags(
[["refund", pubkey_wallet2, pubkey_wallet1], ["n_sigs_refund", "2"]],
), # multiple refund pubkeys
) # sender side
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# we need to copy the send_proofs because the redeem function
# modifies the send_proofs in place by adding the signatures
send_proofs_copy = copy.deepcopy(send_proofs)
send_proofs_copy2 = copy.deepcopy(send_proofs)
# receiver side: can't redeem since we used a garbage pubkey
# and locktime has not passed
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 0 < 1.",
)
await asyncio.sleep(2)
# now is the refund time, but we can't redeem it because we need 2 signatures
await assert_err(
wallet1.redeem(send_proofs_copy),
"not enough pubkeys (2) or signatures (1) present for n_sigs (2)",
)
# let's add the second signature
send_proofs_copy2 = wallet2.sign_p2pk_sig_inputs(send_proofs_copy2)
# now we can redeem it
await wallet1.redeem(send_proofs_copy2)
@pytest.mark.asyncio
async def test_p2pk_multisig_2_of_2(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
assert pubkey_wallet1 != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# add signatures of wallet1
send_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs)
# here we add the signatures of wallet2
await wallet2.redeem(send_proofs)
@pytest.mark.asyncio
async def test_p2pk_multisig_duplicate_signature(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
assert pubkey_wallet1 != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# add signatures of wallet2 this is a duplicate signature
send_proofs = wallet2.sign_p2pk_sig_inputs(send_proofs)
# wallet does not add a second signature if it finds its own signature already in the witness
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: not enough pubkeys (2) or signatures (1) present for n_sigs (2).",
)
@pytest.mark.asyncio
async def test_p2pk_multisig_two_signatures_same_pubkey(
wallet1: Wallet, wallet2: Wallet
):
# we generate two different signatures from the same private key
mint_quote = await wallet2.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet2.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
assert pubkey_wallet1 != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2
)
_, send_proofs = await wallet2.swap_to_send(
wallet2.proofs, 1, secret_lock=secret_lock
)
assert len(send_proofs) == 1
proof = send_proofs[0]
# create coincurve private key so we can sign the message
coincurve_privatekey2 = CoincurvePrivateKey(
bytes.fromhex(wallet2.private_key.serialize())
)
# check if private keys are the same
assert coincurve_privatekey2.to_hex() == wallet2.private_key.serialize()
msg = hashlib.sha256(proof.secret.encode("utf-8")).digest()
coincurve_signature = coincurve_privatekey2.sign_schnorr(msg)
# add signatures of wallet2 this is a duplicate signature
send_proofs = wallet2.sign_p2pk_sig_inputs(send_proofs)
# the signatures from coincurve are not the same as the ones from wallet2
assert coincurve_signature.hex() != proof.p2pksigs[0]
# verify both signatures:
assert PublicKey(bytes.fromhex(pubkey_wallet2), raw=True).schnorr_verify(
msg, bytes.fromhex(proof.p2pksigs[0]), None, raw=True
)
assert PublicKey(bytes.fromhex(pubkey_wallet2), raw=True).schnorr_verify(
msg, coincurve_signature, None, raw=True
)
# add coincurve signature, and the wallet2 signature will be added during .redeem
send_proofs[0].witness = P2PKWitness(signatures=[coincurve_signature.hex()]).json()
# here we add the signatures of wallet2
await assert_err(
wallet2.redeem(send_proofs), "Mint Error: signature threshold not met. 1 < 2."
)
@pytest.mark.asyncio
async def test_p2pk_multisig_quorum_not_met_1_of_2(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
assert pubkey_wallet1 != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet1]]), n_sigs=2
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: not enough pubkeys (2) or signatures (1) present for n_sigs (2)",
)
@pytest.mark.asyncio
async def test_p2pk_multisig_quorum_not_met_2_of_3(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
garbage_priv = PrivateKey(secrets.token_bytes(32), raw=True)
garbage_pubkey = garbage_priv.pubkey
assert garbage_pubkey
assert pubkey_wallet1 != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2,
tags=Tags([["pubkeys", pubkey_wallet1, garbage_pubkey.serialize().hex()]]),
n_sigs=3,
)
# create locked proofs
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# add signatures of wallet1
send_proofs = wallet1.sign_p2pk_sig_inputs(send_proofs)
# here we add the signatures of wallet2
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: not enough pubkeys (3) or signatures (2) present for n_sigs (3)",
)
@pytest.mark.asyncio
async def test_p2pk_multisig_with_duplicate_publickey(wallet1: Wallet, wallet2: Wallet):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", pubkey_wallet2]]), n_sigs=2
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
await assert_err(wallet2.redeem(send_proofs), "Mint Error: pubkeys must be unique.")
@pytest.mark.asyncio
async def test_p2pk_multisig_with_wrong_first_private_key(
wallet1: Wallet, wallet2: Wallet
):
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
wrong_pubklic_key = PrivateKey().pubkey
assert wrong_pubklic_key
wrong_public_key_hex = wrong_pubklic_key.serialize().hex()
assert wrong_public_key_hex != pubkey_wallet2
# p2pk test
secret_lock = await wallet1.create_p2pk_lock(
pubkey_wallet2, tags=Tags([["pubkeys", wrong_public_key_hex]]), n_sigs=2
)
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
await assert_err(
wallet2.redeem(send_proofs),
"Mint Error: not enough pubkeys (2) or signatures (1) present for n_sigs (2).",
)
def test_tags():
tags = Tags(
[["key1", "value1"], ["key2", "value2", "value2_1"], ["key2", "value3"]]
)
assert tags.get_tag("key1") == "value1"
assert tags["key1"] == "value1"
assert tags.get_tag("key2") == "value2"
assert tags["key2"] == "value2"
assert tags.get_tag("key3") is None
assert tags["key3"] is None
assert tags.get_tag_all("key2") == ["value2", "value2_1", "value3"]
# set multiple values of the same key
tags["key3"] = "value3"
assert tags.get_tag_all("key3") == ["value3"]
tags["key4"] = ["value4", "value4_2"]
assert tags.get_tag_all("key4") == ["value4", "value4_2"]
@pytest.mark.asyncio
async def test_secret_initialized_with_tags(wallet1: Wallet):
tags = Tags([["locktime", "100"], ["n_sigs", "3"], ["sigflag", "SIG_ALL"]])
pubkey = PrivateKey().pubkey
assert pubkey
secret = await wallet1.create_p2pk_lock(
data=pubkey.serialize().hex(),
tags=tags,
)
assert secret.locktime == 100
assert secret.n_sigs == 3
assert secret.sigflag == SigFlags.SIG_ALL
@pytest.mark.asyncio
async def test_secret_initialized_with_arguments(wallet1: Wallet):
pubkey = PrivateKey().pubkey
assert pubkey
secret = await wallet1.create_p2pk_lock(
data=pubkey.serialize().hex(),
locktime_seconds=100,
n_sigs=3,
sig_all=True,
)
assert secret.locktime
assert secret.locktime > 1689000000
assert secret.n_sigs == 3
assert secret.sigflag == SigFlags.SIG_ALL
@pytest.mark.asyncio
async def test_wallet_verify_is_p2pk_input(wallet1: Wallet1):
"""Test the wallet correctly identifies P2PK inputs."""
# Mint tokens to the wallet
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 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
)
# Now get a proof and check if it's detected as P2PK
proof = send_proofs[0]
# This tests the internal method that recognizes a P2PK input
secret = Secret.deserialize(proof.secret)
assert secret.kind == SecretKind.P2PK.value, "Secret should be of kind P2PK"
# We can verify that we can convert it to a P2PKSecret
p2pk_secret = P2PKSecret.from_secret(secret)
assert p2pk_secret.data == pubkey, "P2PK secret data should contain the pubkey"
@pytest.mark.asyncio
async def test_wallet_verify_p2pk_sigflag_is_sig_inputs(wallet1: Wallet1):
"""Test the wallet correctly identifies the SIG_INPUTS flag."""
# Mint tokens to the wallet
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 p2pk lock with SIG_INPUTS (default)
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=False)
# Use swap_to_send to create p2pk locked proofs
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Check if sigflag is correctly identified as SIG_INPUTS
proof = send_proofs[0]
secret = Secret.deserialize(proof.secret)
p2pk_secret = P2PKSecret.from_secret(secret)
assert p2pk_secret.sigflag == SigFlags.SIG_INPUTS, "Sigflag should be SIG_INPUTS"
@pytest.mark.asyncio
async def test_wallet_verify_p2pk_sigflag_is_sig_all(wallet1: Wallet1):
"""Test the wallet correctly identifies the SIG_ALL flag."""
# Mint tokens to the wallet
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 p2pk lock with SIG_ALL
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
# Use swap_to_send to create p2pk locked proofs
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 32, secret_lock=secret_lock
)
# Check if sigflag is correctly identified as SIG_ALL
proof = send_proofs[0]
secret = Secret.deserialize(proof.secret)
p2pk_secret = P2PKSecret.from_secret(secret)
assert p2pk_secret.sigflag == SigFlags.SIG_ALL, "Sigflag should be SIG_ALL"
@pytest.mark.asyncio
async def test_p2pk_locktime_with_3_of_3_refund_pubkeys(
wallet1: Wallet, wallet2: Wallet
):
"""Testing the case where we expect a 3-of-3 signature from the refund pubkeys"""
# Create a third wallet for this test
wallet3 = await Wallet.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_3", "wallet3"
)
await migrate_databases(wallet3.db, migrations)
wallet3.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet3.load_mint()
# Get tokens and create public keys for all wallets
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
pubkey_wallet1 = await wallet1.create_p2pk_pubkey()
pubkey_wallet2 = await wallet2.create_p2pk_pubkey()
pubkey_wallet3 = await wallet3.create_p2pk_pubkey()
# Create an unspendable lock with refund conditions requiring 3 signatures
garbage_pubkey = PrivateKey().pubkey
assert garbage_pubkey
secret_lock = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey
locktime_seconds=2, # locktime
tags=Tags(
[
["refund", pubkey_wallet1, pubkey_wallet2, pubkey_wallet3],
["n_sigs_refund", "3"],
],
), # multiple refund pubkeys with required 3 signatures
)
# Send tokens with this lock
_, send_proofs = await wallet1.swap_to_send(
wallet1.proofs, 8, secret_lock=secret_lock
)
# Create copies for different test scenarios
send_proofs_copy1 = copy.deepcopy(send_proofs)
send_proofs_copy2 = copy.deepcopy(send_proofs)
# Verify tokens can't be redeemed before locktime
await assert_err(
wallet1.redeem(send_proofs),
"Mint Error: signature threshold not met. 0 < 1.",
)
# Wait for locktime to expire
await asyncio.sleep(2)
# Try with only 1 signature (wallet1) - should fail
await assert_err(
wallet1.redeem(send_proofs_copy1),
"not enough pubkeys (3) or signatures (1) present for n_sigs (3)",
)
# Add second signature (wallet2)
send_proofs_copy2 = wallet2.sign_p2pk_sig_inputs(send_proofs_copy2)
# Try with 2 signatures - should still fail
await assert_err(
wallet1.redeem(send_proofs_copy2),
"not enough pubkeys (3) or signatures (2) present for n_sigs (3)",
)
# Add the third signature (wallet3)
send_proofs_copy2 = wallet3.sign_p2pk_sig_inputs(send_proofs_copy2)
# Now with 3 signatures it should succeed
await wallet1.redeem(send_proofs_copy2)

View File

@@ -0,0 +1,468 @@
import copy
import hashlib
import secrets
import pytest
import pytest_asyncio
from cashu.core.base import P2PKWitness
from cashu.core.crypto.secp import PrivateKey
from cashu.core.migrations import migrate_databases
from cashu.core.p2pk import P2PKSecret, SigFlags
from cashu.core.secret import SecretKind, Tags
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():
wallet1 = await Wallet.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_methods_1", "wallet1"
)
await migrate_databases(wallet1.db, migrations)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = await Wallet.with_db(
SERVER_ENDPOINT, "test_data/wallet_p2pk_methods_2", "wallet2"
)
await migrate_databases(wallet2.db, migrations)
wallet2.private_key = PrivateKey(secrets.token_bytes(32), raw=True)
await wallet2.load_mint()
yield wallet2
@pytest.mark.asyncio
async def test_create_p2pk_lock_default(wallet1: Wallet):
"""Test creating a P2PK lock with default parameters."""
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
# Verify created lock properties
assert isinstance(secret_lock, P2PKSecret)
assert secret_lock.kind == SecretKind.P2PK.value
assert secret_lock.data == pubkey
assert secret_lock.locktime is None
assert secret_lock.sigflag == SigFlags.SIG_INPUTS
assert secret_lock.n_sigs == 1
@pytest.mark.asyncio
async def test_create_p2pk_lock_with_options(wallet1: Wallet):
"""Test creating a P2PK lock with all options specified."""
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(
pubkey,
locktime_seconds=3600,
sig_all=True,
n_sigs=2,
tags=Tags([["custom_tag", "custom_value"]]),
)
# Verify created lock properties
assert isinstance(secret_lock, P2PKSecret)
assert secret_lock.kind == SecretKind.P2PK.value
assert secret_lock.data == pubkey
assert secret_lock.locktime is not None
assert secret_lock.sigflag == SigFlags.SIG_ALL
assert secret_lock.n_sigs == 2
assert secret_lock.tags.get_tag("custom_tag") == "custom_value"
@pytest.mark.asyncio
async def test_signatures_proofs_sig_inputs(wallet1: Wallet):
"""Test signing proofs with the private key."""
# Mint tokens
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 proofs
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
_, proofs = await wallet1.swap_to_send(wallet1.proofs, 32, secret_lock=secret_lock)
# Test signatures_proofs_sig_inputs
signatures = wallet1.signatures_proofs_sig_inputs(proofs)
# Verify signatures were created
assert len(signatures) == len(proofs)
assert all(isinstance(sig, str) for sig in signatures)
assert all(len(sig) == 128 for sig in signatures) # 64-byte hex signatures
# Verify the signatures are valid
for proof, signature in zip(proofs, signatures):
message = proof.secret.encode("utf-8")
sig_bytes = bytes.fromhex(signature)
# Make sure wallet has a pubkey
assert wallet1.private_key.pubkey is not None
assert wallet1.private_key.pubkey.schnorr_verify(
hashlib.sha256(message).digest(), sig_bytes, None, raw=True
)
@pytest.mark.asyncio
async def test_schnorr_sign_message(wallet1: Wallet):
"""Test signing an arbitrary message."""
# Define a test message
message = "test message to sign"
# Sign the message
signature = wallet1.schnorr_sign_message(message)
# Verify signature format
assert isinstance(signature, str)
assert len(signature) == 128 # 64-byte hex signature
# Verify signature is valid
sig_bytes = bytes.fromhex(signature)
# Make sure wallet has a pubkey
assert wallet1.private_key.pubkey is not None
assert wallet1.private_key.pubkey.schnorr_verify(
hashlib.sha256(message.encode("utf-8")).digest(), sig_bytes, None, raw=True
)
@pytest.mark.asyncio
async def test_inputs_require_sigall_detection(wallet1: Wallet):
"""Test detection of SIG_ALL flag in proof inputs."""
# Mint tokens
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create proofs with SIG_INPUTS
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock_inputs = await wallet1.create_p2pk_lock(pubkey, sig_all=False)
_, proofs_sig_inputs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_inputs
)
# Create proofs with SIG_ALL
mint_quote_2 = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote_2.request)
await wallet1.mint(64, quote_id=mint_quote_2.quote)
secret_lock_all = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
_, proofs_sig_all = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_all
)
# Test detection of SIG_ALL
assert not wallet1._inputs_require_sigall(proofs_sig_inputs)
assert wallet1._inputs_require_sigall(proofs_sig_all)
# Test mixed list of proofs
mixed_proofs = proofs_sig_inputs + proofs_sig_all
assert wallet1._inputs_require_sigall(mixed_proofs)
@pytest.mark.asyncio
async def test_add_witness_swap_sig_all(wallet1: Wallet):
"""Test adding a witness to the first proof for SIG_ALL."""
# Mint tokens
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create proofs with SIG_ALL
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
_, proofs = await wallet1.swap_to_send(wallet1.proofs, 16, secret_lock=secret_lock)
# Create some outputs
output_amounts = [16]
secrets, rs, _ = await wallet1.generate_n_secrets(len(output_amounts))
outputs, _ = wallet1._construct_outputs(output_amounts, secrets, rs)
# Add witness
signed_proofs = wallet1.add_witness_swap_sig_all(proofs, outputs)
# Verify the first proof has a witness
assert signed_proofs[0].witness is not None
witness = P2PKWitness.from_witness(signed_proofs[0].witness)
assert len(witness.signatures) == 1
# Verify the signature includes both inputs and outputs
message_to_sign = "".join([p.secret for p in proofs] + [o.B_ for o in outputs])
signature = wallet1.schnorr_sign_message(message_to_sign)
assert witness.signatures[0] == signature
@pytest.mark.asyncio
async def test_sign_proofs_inplace_swap(wallet1: Wallet):
"""Test signing proofs in place for a swap operation."""
# Mint tokens
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create SIG_ALL proofs
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
_, proofs = await wallet1.swap_to_send(wallet1.proofs, 16, secret_lock=secret_lock)
# Create some outputs
output_amounts = [16]
secrets, rs, _ = await wallet1.generate_n_secrets(len(output_amounts))
outputs, _ = wallet1._construct_outputs(output_amounts, secrets, rs)
# Sign proofs
signed_proofs = wallet1.sign_proofs_inplace_swap(proofs, outputs)
# Verify the first proof has a witness with a signature
assert signed_proofs[0].witness is not None
witness = P2PKWitness.from_witness(signed_proofs[0].witness)
assert len(witness.signatures) == 1
@pytest.mark.asyncio
async def test_add_signatures_to_proofs(wallet1: Wallet):
"""Test adding signatures to proofs."""
# Mint tokens
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Create P2PK proofs
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
_, proofs = await wallet1.swap_to_send(wallet1.proofs, 16, secret_lock=secret_lock)
# Generate signatures
signatures = wallet1.signatures_proofs_sig_inputs(proofs)
# Add signatures to proofs
signed_proofs = wallet1.add_signatures_to_proofs(proofs, signatures)
# Verify signatures were added to the proofs
for proof in signed_proofs:
assert proof.witness is not None
witness = P2PKWitness.from_witness(proof.witness)
assert len(witness.signatures) == 1
# Test adding same signatures to already signed proofs (should not duplicate)
signed_proofs = wallet1.add_signatures_to_proofs(signed_proofs, signatures)
# Verify the signatures were not duplicated
for proof in signed_proofs:
assert proof.witness
witness = P2PKWitness.from_witness(proof.witness)
# Should still have 1 signature because duplicates aren't added
assert len(witness.signatures) == 1
@pytest.mark.asyncio
async def test_filter_proofs_locked_to_our_pubkey(wallet1: Wallet, wallet2: Wallet):
"""Test filtering proofs locked to our public key."""
# Mint tokens to wallet1
mint_quote = await wallet1.request_mint(640)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(640, quote_id=mint_quote.quote)
# Get pubkeys for both wallets
pubkey1 = await wallet1.create_p2pk_pubkey()
pubkey2 = await wallet2.create_p2pk_pubkey()
# Create proofs locked to wallet1's pubkey
secret_lock1 = await wallet1.create_p2pk_lock(pubkey1)
_, proofs1 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock1
)
# Create proofs locked to wallet2's pubkey
secret_lock2 = await wallet1.create_p2pk_lock(pubkey2)
_, proofs2 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock2
)
# Create proofs with multiple pubkeys
secret_lock3 = await wallet1.create_p2pk_lock(
pubkey1, tags=Tags([["pubkeys", pubkey2]])
)
_, proofs3 = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock3
)
# Sign the proofs to avoid witness errors
signed_proofs1 = wallet1.sign_p2pk_sig_inputs(proofs1)
signed_proofs2 = wallet2.sign_p2pk_sig_inputs(proofs2)
signed_proofs3 = wallet1.sign_p2pk_sig_inputs(proofs3)
signed_proofs3 = wallet2.sign_p2pk_sig_inputs(signed_proofs3)
# Ensure pubkeys are available
assert wallet1.private_key.pubkey is not None
assert wallet2.private_key.pubkey is not None
# Filter using wallet1
filtered1 = wallet1.filter_proofs_locked_to_our_pubkey(
signed_proofs1 + signed_proofs2 + signed_proofs3
)
# wallet1 should find proofs1 and proofs3
assert len(filtered1) == len(signed_proofs1) + len(signed_proofs3)
# Filter using wallet2
filtered2 = wallet2.filter_proofs_locked_to_our_pubkey(
signed_proofs1 + signed_proofs2 + signed_proofs3
)
# wallet2 should find proofs2 and proofs3
assert len(filtered2) == len(signed_proofs2) + len(signed_proofs3)
@pytest.mark.asyncio
async def test_sign_p2pk_sig_inputs(wallet1: Wallet):
"""Test signing P2PK SIG_INPUTS proofs."""
# Mint tokens
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 mix of P2PK and non-P2PK proofs
pubkey = await wallet1.create_p2pk_pubkey()
# Regular proofs (not P2PK)
_, regular_proofs = await wallet1.swap_to_send(wallet1.proofs, 16)
# P2PK SIG_INPUTS proofs
secret_lock_inputs = await wallet1.create_p2pk_lock(pubkey, sig_all=False)
_, p2pk_input_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_inputs
)
# P2PK SIG_ALL proofs - these won't be signed by sign_p2pk_sig_inputs
secret_lock_all = await wallet1.create_p2pk_lock(pubkey, sig_all=True)
_, p2pk_all_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_all
)
# P2PK locked to a different pubkey - these won't be signed
garbage_pubkey_p = PrivateKey().pubkey
assert garbage_pubkey_p is not None
garbage_pubkey = garbage_pubkey_p.serialize().hex()
secret_lock_other = await wallet1.create_p2pk_lock(garbage_pubkey)
_, p2pk_other_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_other
)
# Mix all proofs
mixed_proofs = (
regular_proofs + p2pk_input_proofs + p2pk_all_proofs + p2pk_other_proofs
)
# Sign the mixed proofs
signed_proofs = wallet1.sign_p2pk_sig_inputs(mixed_proofs)
# Only P2PK SIG_INPUTS proofs locked to our pubkey should be signed
assert len(signed_proofs) == len(p2pk_input_proofs)
# Verify the signatures were added
for proof in signed_proofs:
assert proof.witness is not None
witness = P2PKWitness.from_witness(proof.witness)
assert len(witness.signatures) == 1
@pytest.mark.asyncio
async def test_add_witnesses_sig_inputs(wallet1: Wallet):
"""Test adding witnesses to P2PK SIG_INPUTS proofs."""
# Mint tokens
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 mix of P2PK and non-P2PK proofs
pubkey = await wallet1.create_p2pk_pubkey()
# Regular proofs (not P2PK)
_, regular_proofs = await wallet1.swap_to_send(wallet1.proofs, 16)
# P2PK SIG_INPUTS proofs
secret_lock_inputs = await wallet1.create_p2pk_lock(pubkey, sig_all=False)
_, p2pk_input_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_inputs
)
# Mix all proofs and make a copy for comparison
mixed_proofs = regular_proofs + p2pk_input_proofs
mixed_proofs_copy = copy.deepcopy(mixed_proofs)
# Add witnesses to the proofs
signed_proofs = wallet1.add_witnesses_sig_inputs(mixed_proofs)
# Verify that only P2PK proofs have witnesses added
for i, (orig_proof, signed_proof) in enumerate(
zip(mixed_proofs_copy, signed_proofs)
):
if i < len(regular_proofs):
# Regular proofs should be unchanged
assert signed_proof.witness == orig_proof.witness
else:
# P2PK proofs should have witnesses added
assert signed_proof.witness is not None
witness = P2PKWitness.from_witness(signed_proof.witness)
assert len(witness.signatures) == 1
@pytest.mark.asyncio
async def test_edge_cases(wallet1: Wallet, wallet2: Wallet):
"""Test various edge cases for the WalletP2PK methods."""
# Mint tokens
mint_quote = await wallet1.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(64, quote_id=mint_quote.quote)
# Case 1: Empty list of proofs
assert wallet1.signatures_proofs_sig_inputs([]) == []
assert wallet1.add_signatures_to_proofs([], []) == []
assert wallet1.filter_proofs_locked_to_our_pubkey([]) == []
assert wallet1.sign_p2pk_sig_inputs([]) == []
assert wallet1.add_witnesses_sig_inputs([]) == []
# Case 2: Mismatched number of proofs and signatures
pubkey = await wallet1.create_p2pk_pubkey()
secret_lock = await wallet1.create_p2pk_lock(pubkey)
_, proofs = await wallet1.swap_to_send(wallet1.proofs, 16, secret_lock=secret_lock)
assert len(proofs) == 1
# Create fake signatures but we have only one proof - this should fail
signatures = ["fake_signature1", "fake_signature2"]
assert len(signatures) != len(proofs)
# This should raise an assertion error
with pytest.raises(AssertionError, match="wrong number of signatures"):
wallet1.add_signatures_to_proofs(proofs, signatures)
# Case 3: SIG_ALL with proofs locked to different public keys
assert wallet1.private_key.pubkey is not None
garbage_pubkey = PrivateKey().pubkey
assert garbage_pubkey is not None
secret_lock_other = await wallet1.create_p2pk_lock(
garbage_pubkey.serialize().hex(), sig_all=True
)
_, other_proofs = await wallet1.swap_to_send(
wallet1.proofs, 16, secret_lock=secret_lock_other
)
output_amounts = [16]
secrets, rs, _ = await wallet1.generate_n_secrets(len(output_amounts))
outputs, _ = wallet1._construct_outputs(output_amounts, secrets, rs)
# wallet1 shouldn't add signatures because proofs are locked to a different pubkey
signed_proofs = wallet1.add_witness_swap_sig_all(other_proofs, outputs)
# Check each proof for None witness
for proof in signed_proofs:
assert proof.witness is None

View File

@@ -0,0 +1,293 @@
import asyncio
import bolt11
import pytest
import pytest_asyncio
from cashu.mint.ledger import Ledger
from cashu.wallet.crud import get_proofs
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
SLEEP_TIME,
cancel_invoice,
get_hold_invoice,
is_fake,
pay_if_regtest,
settle_invoice,
)
@pytest_asyncio.fixture(scope="function")
async def wallet():
wallet = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet",
name="wallet",
)
await wallet.load_mint()
yield wallet
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
# fill wallet
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)
states = await wallet.check_proof_state(send_proofs)
assert all([s.pending for s in states.states])
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.spent for s in states.states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_failed_quote(wallet: Wallet, ledger: Ledger):
# fill wallet
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)
states = await wallet.check_proof_state(send_proofs)
assert all([s.pending for s in states.states])
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.unspent for s in states.states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_get_melt_quote_melt_fail_restore_pending_batch_check(
wallet: Wallet, ledger: Ledger
):
# simulates a payment that fails on the mint and whether the wallet is able to
# restore the state of all proofs (set unreserved)
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, set_reserved=True
)
# verify that the proofs are reserved
proofs_db = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert all([p.reserved for p in proofs_db])
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)
states = await wallet.check_proof_state(send_proofs)
assert all([s.pending for s in states.states])
# fail the payment, melt will unset the proofs as reserved
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
# test get_spent_proofs_check_states_batched: verify that no proofs are spent
spent_proofs = await wallet.get_spent_proofs_check_states_batched(send_proofs)
assert len(spent_proofs) == 0
proofs_db_later = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert all([p.reserved is False for p in proofs_db_later])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_get_melt_quote_wallet_crash_melt_fail_restore_pending_batch_check(
wallet: Wallet, ledger: Ledger
):
# simulates a payment failure but the wallet crashed, we confirm that wallet.get_melt_quote() will correctly
# recover the state of the proofs and set them as unreserved
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, set_reserved=True
)
assert len(send_proofs) == 2
task = 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)
# verify that the proofs are reserved
proofs_db = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert len(proofs_db) == 2
assert all([p.reserved for p in proofs_db])
# simulate a and kill the task
task.cancel()
await asyncio.sleep(SLEEP_TIME)
states = await wallet.check_proof_state(send_proofs)
assert all([s.pending for s in states.states])
# fail the payment, melt will unset the proofs as reserved
cancel_invoice(preimage_hash=preimage_hash)
await asyncio.sleep(SLEEP_TIME)
# get the melt quote, this should restore the state of the proofs
melt_quote = await wallet.get_melt_quote(quote.quote)
assert melt_quote
assert melt_quote.unpaid
# verify that get_melt_quote unset all proofs as not pending anymore
proofs_db_later = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert len(proofs_db_later) == 0
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_wallet_crash_melt_succeed_restore_pending_batch_check(
wallet: Wallet, ledger: Ledger
):
# simulates a payment that succeeds but the wallet crashes in the mean time
# we then call get_spent_proofs_check_states_batched to check the proof states
# and the wallet should then invalidate the reserved proofs
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, set_reserved=True
)
# verify that the proofs are reserved
proofs_db = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert all([p.reserved for p in proofs_db])
task = 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)
# simulate a and kill the task
task.cancel()
await asyncio.sleep(SLEEP_TIME)
# verify that the proofs are still reserved
proofs_db = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert all([p.reserved for p in proofs_db])
# verify that the proofs are still pending
states = await wallet.check_proof_state(send_proofs)
assert all([s.pending for s in states.states])
# succeed the payment
settle_invoice(preimage=preimage)
await asyncio.sleep(SLEEP_TIME)
# get the melt quote
melt_quote = await wallet.get_melt_quote(quote.quote)
assert melt_quote
assert melt_quote.paid
# verify that get_melt_quote unset all proofs as not pending anymore
proofs_db_later = await get_proofs(db=wallet.db, melt_id=quote.quote)
assert len(proofs_db_later) == 0

View File

@@ -0,0 +1,272 @@
import asyncio
import threading
from typing import List
import bolt11
import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Method, Proof
from cashu.lightning.base import PaymentResponse
from cashu.lightning.clnrest import CLNRestWallet
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
SLEEP_TIME,
assert_err,
cancel_invoice,
get_hold_invoice,
get_real_invoice,
is_fake,
partial_pay_real_invoice,
pay_if_regtest,
)
@pytest_asyncio.fixture(scope="function")
async def wallet():
wallet = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet",
name="wallet",
)
await wallet.load_mint()
yield wallet
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet twice so we have enough for two payments
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs1 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 128
# this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64)
invoice_payment_request = str(invoice_dict["payment_request"])
async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice, amount_msat=amount * 1000)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
asyncio.run(_mint_pay_mpp(invoice, amount, proofs))
# call pay_mpp twice in parallel to pay the full invoice
t1 = threading.Thread(
target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)
)
t2 = threading.Thread(
target=partial_pay_real_invoice, args=(invoice_payment_request, 32, 1)
)
t1.start()
t2.start()
t1.join()
t2.join()
assert wallet.balance == 64
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# This test cannot be done with CLN because we only have one mint
# and CLN hates multiple partial payment requests
if isinstance(ledger.backends[Method["bolt11"]][wallet.unit], CLNRestWallet):
pytest.skip("CLN cannot perform this test")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet twice so we have enough for three payments
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs1 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 128
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs2 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 256
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs3 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 384
# this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64)
invoice_payment_request = str(invoice_dict["payment_request"])
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(
invoice_payment_request, amount_msat=amount * 1000
)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice_payment_request,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
# instead: call pay_mpp twice in the background, sleep for a bit, then check if the payment was successful (it should not be)
asyncio.create_task(pay_mpp(32, proofs1))
asyncio.create_task(pay_mpp(16, proofs2, delay=0.5))
await asyncio.sleep(2)
# payment is still pending because the full amount has not been paid
assert wallet.balance == 384
# send the remaining 16 sat to complete the payment
asyncio.create_task(pay_mpp(16, proofs3, delay=0.5))
await asyncio.sleep(2)
assert wallet.balance <= 384 - 64
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_internal_mpp_melt_quotes(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# create a mint quote
mint_quote = await wallet.request_mint(128)
# try and create a multi-part melt quote
await assert_err(
wallet.melt_quote(mint_quote.request, 100 * 1000), "internal mpp not allowed"
)
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_cancel_payment(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet so we have enough for the payment
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs1 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 128
# create a hold invoice that we can cancel
preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = str(invoice_dict.get("payment_request", ""))
invoice_obj = bolt11.decode(invoice_payment_request)
payment_hash = invoice_obj.payment_hash
async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice, amount_msat=amount * 1000)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
asyncio.run(_mint_pay_mpp(invoice, amount, proofs))
# start the MPP payment
t1 = threading.Thread(
target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)
)
t1.start()
await asyncio.sleep(SLEEP_TIME)
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method["bolt11"]][wallet.unit].get_payment_status(
payment_hash
)
assert status.failed # some backends return unknown instead of failed
assert not status.preimage # no preimage since payment failed
# check that the proofs are unspent since payment failed
states = await wallet.check_proof_state(proofs1)
assert all([s.unspent for s in states.states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_cancel_payment_pay_partial_invoice(
wallet: Wallet, ledger: Ledger
):
# create a hold invoice that we can cancel
preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = str(invoice_dict.get("payment_request", ""))
invoice_obj = bolt11.decode(invoice_payment_request)
payment_hash = invoice_obj.payment_hash
# Use a shared container to store the result
result_container = []
async def _mint_pay_mpp(invoice: str, amount: int) -> PaymentResponse:
ret = await ledger.backends[Method["bolt11"]][wallet.unit].pay_invoice(
MeltQuote(
request=invoice,
amount=amount,
fee_reserve=0,
quote="",
method="bolt11",
checking_id="",
unit=wallet.unit.name,
state=MeltQuoteState.pending,
),
0,
)
return ret
# Create a wrapper function that will store the result
def thread_func():
result = asyncio.run(_mint_pay_mpp(invoice_payment_request, 32))
result_container.append(result)
t1 = threading.Thread(target=thread_func)
t1.start()
await asyncio.sleep(SLEEP_TIME)
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
t1.join()
# Get the result from the container
assert result_container[0].failed

View File

@@ -0,0 +1,57 @@
import json
import pytest
import pytest_asyncio
import respx
from httpx import Request, Response
from cashu.core.base import BlindedSignature
from cashu.core.crypto.b_dhke import hash_to_curve
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import pay_if_regtest
@pytest_asyncio.fixture(scope="function")
async def wallet1(mint):
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_swap_outputs_are_sorted(wallet1: Wallet):
await wallet1.load_mint()
mint_quote = await wallet1.request_mint(16)
await pay_if_regtest(mint_quote.request)
await wallet1.mint(16, quote_id=mint_quote.quote, split=[16])
assert wallet1.balance == 16
test_url = f"{wallet1.url}/v1/swap"
key = hash_to_curve("test".encode("utf-8"))
mock_blind_signature = BlindedSignature(
id=wallet1.keyset_id,
amount=8,
C_=key.serialize().hex(),
)
mock_response_data = {"signatures": [mock_blind_signature.dict()]}
with respx.mock() as mock:
route = mock.post(test_url).mock(
return_value=Response(200, json=mock_response_data)
)
await wallet1.select_to_send(wallet1.proofs, 5)
assert route.called
assert route.call_count == 1
request: Request = route.calls[0].request
assert request.method == "POST"
assert request.url == test_url
request_data = json.loads(request.content.decode("utf-8"))
output_amounts = [o["amount"] for o in request_data["outputs"]]
# assert that output amounts are sorted
assert output_amounts == sorted(output_amounts)

View File

@@ -0,0 +1,394 @@
import shutil
from pathlib import Path
from typing import Dict, List, Union
import pytest
import pytest_asyncio
from cashu.core.base import Proof
from cashu.core.crypto.secp import PrivateKey
from cashu.core.errors import CashuError
from cashu.wallet.wallet import Wallet
from cashu.wallet.wallet import Wallet as Wallet1
from cashu.wallet.wallet import Wallet as Wallet2
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import pay_if_regtest
async def assert_err(f, msg: Union[str, CashuError]):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
error_message: str = str(exc.args[0])
if isinstance(msg, CashuError):
if msg.detail not in error_message:
raise Exception(
f"CashuError. Expected error: {msg.detail}, got: {error_message}"
)
return
if msg not in error_message:
raise Exception(f"Expected error: {msg}, got: {error_message}")
return
raise Exception(f"Expected error: {msg}, got no error")
def assert_amt(proofs: List[Proof], expected: int):
"""Assert amounts the proofs contain."""
assert [p.amount for p in proofs] == expected
async def reset_wallet_db(wallet: Wallet):
await wallet.db.execute("DELETE FROM proofs")
await wallet.db.execute("DELETE FROM proofs_used")
await wallet.db.execute("DELETE FROM keysets")
await wallet.load_mint()
@pytest_asyncio.fixture(scope="function")
async def wallet1():
wallet1 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet1",
name="wallet1",
)
await wallet1.load_mint()
yield wallet1
@pytest_asyncio.fixture(scope="function")
async def wallet2():
wallet2 = await Wallet2.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet2",
name="wallet2",
)
await wallet2.load_mint()
yield wallet2
@pytest_asyncio.fixture(scope="function")
async def wallet3():
dirpath = Path("test_data/wallet3")
if dirpath.exists() and dirpath.is_dir():
shutil.rmtree(dirpath)
wallet3 = await Wallet1.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet3",
name="wallet3",
)
await wallet3.db.execute("DELETE FROM proofs")
await wallet3.db.execute("DELETE FROM proofs_used")
await wallet3.load_mint()
yield wallet3
@pytest.mark.asyncio
async def test_bump_secret_derivation(wallet3: Wallet):
await wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture"
" humble"
)
secrets1, rs1, derivation_paths1 = await wallet3.generate_n_secrets(5)
secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(0, 4)
assert wallet3.keyset_id == "009a1f293253e41e"
assert secrets1 == secrets2
assert [r.private_key for r in rs1] == [r.private_key for r in rs2]
assert derivation_paths1 == derivation_paths2
for s in secrets1:
print(f'"{s}",')
assert secrets1 == [
"485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae",
"8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270",
"bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8",
"59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf",
"576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0",
]
assert [r.private_key.hex() for r in rs1 if r.private_key] == [
"ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679",
"967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248",
"b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899",
"fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29",
"5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9",
]
for d in derivation_paths1:
print(f'"{d}",')
assert derivation_paths1 == [
"m/129372'/0'/864559728'/0'",
"m/129372'/0'/864559728'/1'",
"m/129372'/0'/864559728'/2'",
"m/129372'/0'/864559728'/3'",
"m/129372'/0'/864559728'/4'",
]
@pytest.mark.asyncio
async def test_bump_secret_derivation_two_steps(wallet3: Wallet):
await wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture"
" humble"
)
secrets1_1, rs1_1, derivation_paths1 = await wallet3.generate_n_secrets(2)
secrets1_2, rs1_2, derivation_paths2 = await wallet3.generate_n_secrets(3)
secrets1 = secrets1_1 + secrets1_2
rs1 = rs1_1 + rs1_2
secrets2, rs2, derivation_paths = await wallet3.generate_secrets_from_to(0, 4)
assert secrets1 == secrets2
assert [r.private_key for r in rs1] == [r.private_key for r in rs2]
@pytest.mark.asyncio
async def test_generate_secrets_from_to(wallet3: Wallet):
await wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture"
" humble"
)
secrets1, rs1, derivation_paths1 = await wallet3.generate_secrets_from_to(0, 4)
assert len(secrets1) == 5
secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(2, 4)
assert len(secrets2) == 3
assert secrets1[2:] == secrets2
assert [r.private_key for r in rs1[2:]] == [r.private_key for r in rs2]
@pytest.mark.asyncio
async def test_restore_wallet_after_mint(wallet3: Wallet):
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(64, quote_id=mint_quote.quote)
assert wallet3.balance == 64
await reset_wallet_db(wallet3)
await wallet3.load_proofs()
wallet3.proofs = []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 20)
assert wallet3.balance == 64
# expect that DLEQ proofs are restored
assert all([p.dleq for p in wallet3.proofs])
assert all([p.dleq.e for p in wallet3.proofs]) # type: ignore
assert all([p.dleq.s for p in wallet3.proofs]) # type: ignore
@pytest.mark.asyncio
async def test_restore_wallet_with_invalid_mnemonic(wallet3: Wallet):
await assert_err(
wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture"
" picture"
),
"Invalid mnemonic",
)
@pytest.mark.asyncio
async def test_restore_wallet_after_swap_to_send(wallet3: Wallet):
await wallet3._init_private_key(
"half depart obvious quality work element tank gorilla view sugar picture"
" humble"
)
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(64, quote_id=mint_quote.quote)
assert wallet3.balance == 64
_, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 32, set_reserved=True
) # type: ignore
await reset_wallet_db(wallet3)
await wallet3.load_proofs()
wallet3.proofs = []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 100)
assert wallet3.balance == 96
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64
@pytest.mark.asyncio
async def test_restore_wallet_after_send_and_receive(wallet3: Wallet, wallet2: Wallet):
await wallet3._init_private_key(
"hello rug want adapt talent together lunar method bean expose beef position"
)
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(64, quote_id=mint_quote.quote)
assert wallet3.balance == 64
_, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 32, set_reserved=True
) # type: ignore
await wallet2.redeem(spendable_proofs)
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 100)
assert wallet3.balance == 96
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 32
class ProofBox:
proofs: Dict[str, Proof] = {}
def add(self, proofs: List[Proof]) -> None:
for proof in proofs:
if proof.secret in self.proofs:
if self.proofs[proof.secret].C != proof.C:
print("Proofs are not equal")
print(self.proofs[proof.secret])
print(proof)
else:
self.proofs[proof.secret] = proof
@pytest.mark.asyncio
async def test_restore_wallet_after_send_and_self_receive(wallet3: Wallet):
await wallet3._init_private_key(
"lucky broken tell exhibit shuffle tomato ethics virus rabbit spread measure"
" text"
)
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(64, quote_id=mint_quote.quote)
assert wallet3.balance == 64
_, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 32, set_reserved=True
) # type: ignore
await wallet3.redeem(spendable_proofs)
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 100)
assert wallet3.balance == 128
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64
@pytest.mark.asyncio
async def test_restore_wallet_after_send_twice(
wallet3: Wallet,
):
box = ProofBox()
wallet3.private_key = PrivateKey()
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(2)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(2, quote_id=mint_quote.quote)
box.add(wallet3.proofs)
assert wallet3.balance == 2
keep_proofs, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 1, set_reserved=True
) # type: ignore
box.add(wallet3.proofs)
assert wallet3.available_balance == 1
await wallet3.redeem(spendable_proofs)
box.add(wallet3.proofs)
assert wallet3.available_balance == 2
assert wallet3.balance == 2
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 10)
box.add(wallet3.proofs)
assert wallet3.balance == 4
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 2
# again
_, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 1, set_reserved=True
) # type: ignore
box.add(wallet3.proofs)
assert wallet3.available_balance == 1
await wallet3.redeem(spendable_proofs)
box.add(wallet3.proofs)
assert wallet3.available_balance == 2
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 15)
box.add(wallet3.proofs)
assert wallet3.balance == 6
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 2
@pytest.mark.asyncio
async def test_restore_wallet_after_send_and_self_receive_nonquadratic_value(
wallet3: Wallet,
):
box = ProofBox()
await wallet3._init_private_key(
"casual demise flight cradle feature hub link slim remember anger front asthma"
)
await reset_wallet_db(wallet3)
mint_quote = await wallet3.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet3.mint(64, quote_id=mint_quote.quote)
box.add(wallet3.proofs)
assert wallet3.balance == 64
keep_proofs, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 10, set_reserved=True
) # type: ignore
box.add(wallet3.proofs)
assert wallet3.available_balance == 64 - 10
await wallet3.redeem(spendable_proofs)
box.add(wallet3.proofs)
assert wallet3.available_balance == 64
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 20)
box.add(wallet3.proofs)
assert wallet3.balance == 84
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64
# again
_, spendable_proofs = await wallet3.swap_to_send(
wallet3.proofs, 12, set_reserved=True
) # type: ignore
assert wallet3.available_balance == 64 - 12
await wallet3.redeem(spendable_proofs)
assert wallet3.available_balance == 64
await reset_wallet_db(wallet3)
await wallet3.load_proofs(reload=True)
assert wallet3.proofs == []
assert wallet3.balance == 0
await wallet3.restore_promises_from_to(wallet3.keyset_id, 0, 50)
assert wallet3.balance == 108
await wallet3.invalidate(wallet3.proofs, check_spendable=True)
assert wallet3.balance == 64

View File

@@ -0,0 +1,112 @@
import asyncio
import pytest
import pytest_asyncio
from cashu.core.base import Method, MintQuoteState, ProofState
from cashu.core.json_rpc.base import JSONRPCNotficationParams
from cashu.core.nuts.nuts import WEBSOCKETS_NUT
from cashu.core.settings import settings
from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
is_fake,
pay_if_regtest,
)
@pytest_asyncio.fixture(scope="function")
async def wallet(mint):
wallet1 = await Wallet.with_db(
url=SERVER_ENDPOINT,
db="test_data/wallet_subscriptions",
name="wallet_subscriptions",
)
await wallet1.load_mint()
yield wallet1
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_wallet_subscription_mint(wallet: Wallet):
if not wallet.mint_info.supports_nut(WEBSOCKETS_NUT):
pytest.skip("No websocket support")
if not wallet.mint_info.supports_websocket_mint_quote(
Method["bolt11"], wallet.unit
):
pytest.skip("No websocket support for bolt11_mint_quote")
triggered = False
msg_stack: list[JSONRPCNotficationParams] = []
def callback(msg: JSONRPCNotficationParams):
nonlocal triggered, msg_stack
triggered = True
msg_stack.append(msg)
asyncio.run(wallet.mint(int(mint_quote.amount), quote_id=mint_quote.quote))
mint_quote, sub = await wallet.request_mint_with_callback(128, callback=callback)
await pay_if_regtest(mint_quote.request)
wait = settings.fakewallet_delay_incoming_payment or 2
await asyncio.sleep(wait + 2)
assert triggered
assert len(msg_stack) == 3
assert msg_stack[0].payload["state"] == MintQuoteState.unpaid.value
assert msg_stack[1].payload["state"] == MintQuoteState.paid.value
assert msg_stack[2].payload["state"] == MintQuoteState.issued.value
@pytest.mark.asyncio
async def test_wallet_subscription_swap(wallet: Wallet):
if not wallet.mint_info.supports_nut(WEBSOCKETS_NUT):
pytest.skip("No websocket support")
mint_quote = await wallet.request_mint(64)
await pay_if_regtest(mint_quote.request)
await wallet.mint(64, quote_id=mint_quote.quote)
triggered = False
msg_stack: list[JSONRPCNotficationParams] = []
def callback(msg: JSONRPCNotficationParams):
nonlocal triggered, msg_stack
triggered = True
msg_stack.append(msg)
n_subscriptions = len(wallet.proofs)
state, sub = await wallet.check_proof_state_with_callback(
wallet.proofs, callback=callback
)
_ = await wallet.swap_to_send(wallet.proofs, 64)
wait = 1
await asyncio.sleep(wait)
assert triggered
# we receive 3 messages for each subscription:
# initial state (UNSPENT), pending state (PENDING), spent state (SPENT)
assert len(msg_stack) == n_subscriptions * 3
# the first one is the UNSPENT state
pending_stack = msg_stack[:n_subscriptions]
for msg in pending_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.unspent
# the second one is the PENDING state
spent_stack = msg_stack[n_subscriptions : n_subscriptions * 2]
for msg in spent_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.pending
# the third one is the SPENT state
spent_stack = msg_stack[n_subscriptions * 2 :]
for msg in spent_stack:
proof_state = ProofState.parse_obj(msg.payload)
assert proof_state.spent

View File

@@ -0,0 +1,63 @@
from typing import List, Union
from cashu.core.errors import CashuError
from cashu.wallet.utils import sanitize_url
async def assert_err(f, msg: Union[str, CashuError]):
"""Compute f() and expect an error message 'msg'."""
try:
await f
except Exception as exc:
error_message: str = str(exc.args[0])
if isinstance(msg, CashuError):
if msg.detail not in error_message:
raise Exception(
f"CashuError. Expected error: {msg.detail}, got: {error_message}"
)
return
if msg not in error_message:
raise Exception(f"Expected error: {msg}, got: {error_message}")
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")
def test_sanitize_url():
url = "https://localhost:3338"
assert sanitize_url(url) == "https://localhost:3338"
url = "https://mint.com:3338"
assert sanitize_url(url) == "https://mint.com:3338"
url = "https://Mint.com:3338"
assert sanitize_url(url) == "https://mint.com:3338"
url = "https://mint.com:3338/"
assert sanitize_url(url) == "https://mint.com:3338"
url = "https://mint.com:3338/abc"
assert sanitize_url(url) == "https://mint.com:3338/abc"
url = "https://mint.com:3338/Abc"
assert sanitize_url(url) == "https://mint.com:3338/Abc"
url = "https://mint.com:3338/abc/"
assert sanitize_url(url) == "https://mint.com:3338/abc"
url = "https://mint.com:3338/Abc/"
assert sanitize_url(url) == "https://mint.com:3338/Abc"
url = "https://Mint.com:3338/Abc/def"
assert sanitize_url(url) == "https://mint.com:3338/Abc/def"