Merge branch 'cashubtc:main' into main

This commit is contained in:
2025-11-20 16:49:06 +01:00
committed by GitHub
17 changed files with 342 additions and 101 deletions

View File

@@ -58,6 +58,10 @@ MINT_DERIVATION_PATH="m/0'/0'/0'"
# In this example, we have 2 keysets for sat, 1 for msat and 1 for usd
# MINT_DERIVATION_PATH_LIST=["m/0'/0'/0'", "m/0'/0'/1'", "m/0'/1'/0'", "m/0'/2'/0'"]
# Input fee per 1000 inputs (ppk = per kilo).
# e.g. for 100 ppk: up to 10 inputs = 1 sat / 1 cent fee, for up to 20 inputs = 2 sat / 2 cent fee
MINT_INPUT_FEE_PPK=100
# To use SQLite, choose a directory to store the database
MINT_DATABASE=data/mint
# To use PostgreSQL, set the connection string
@@ -122,10 +126,11 @@ LIGHTNING_FEE_PERCENT=1.0
LIGHTNING_RESERVE_FEE_MIN=2000
# Mint Management gRPC service configurations
# Run the script in cashu/mint/management_rpc/generate_certificates.sh to generate certificates for the server and client.
# Use `poetry run mint-cli get-info` to test the connection.
MINT_RPC_SERVER_ENABLE=FALSE
MINT_RPC_SERVER_ADDR=localhost
MINT_RPC_SERVER_PORT=8086
MINT_RPC_SERVER_MUTUAL_TLS=TRUE
MINT_RPC_SERVER_KEY="./server_private.pem"
MINT_RPC_SERVER_CERT="./server_cert.pem"
MINT_RPC_SERVER_CA="./ca_cert.pem"

View File

@@ -177,7 +177,7 @@ This command runs the mint on your local computer. Skip this step if you want to
## Docker
```
docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.1 poetry run mint
docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.18.2 poetry run mint
```
## From this repository

View File

@@ -733,7 +733,7 @@ class WalletKeyset:
self.valid_from = valid_from
self.valid_to = valid_to
self.first_seen = first_seen
self.active = active
self.active = bool(active)
self.mint_url = mint_url
self.input_fee_ppk = input_fee_ppk

View File

@@ -20,7 +20,7 @@ class NotAllowedError(CashuError):
class OutputsAlreadySignedError(CashuError):
detail = "outputs have already been signed before."
detail = "outputs have already been signed before or are pending."
code = 10002
def __init__(self, detail: Optional[str] = None, code: Optional[int] = None):

View File

@@ -13,7 +13,7 @@ except ImportError:
env = Env()
VERSION = "0.18.1"
VERSION = "0.18.2"
def find_env_file():
@@ -73,7 +73,7 @@ class MintSettings(CashuSettings):
mint_test_database: str = Field(default="test_data/test_mint")
mint_max_secret_length: int = Field(default=1024)
mint_input_fee_ppk: int = Field(default=0)
mint_input_fee_ppk: int = Field(default=100)
mint_disable_melt_on_error: bool = Field(default=False)
mint_regular_tasks_interval_seconds: int = Field(
@@ -228,13 +228,27 @@ class MintInformation(CashuSettings):
class MintManagementRPCSettings(MintSettings):
mint_rpc_server_enable: bool = Field(default=False)
mint_rpc_server_ca: str = Field(default=None)
mint_rpc_server_cert: str = Field(default=None)
mint_rpc_server_enable: bool = Field(
default=False, description="Enable the management RPC server."
)
mint_rpc_server_ca: str = Field(
default=None,
description="CA certificate file path for the management RPC server.",
)
mint_rpc_server_cert: str = Field(
default=None,
description="Server certificate file path for the management RPC server.",
)
mint_rpc_server_key: str = Field(default=None)
mint_rpc_server_addr: str = Field(default="localhost")
mint_rpc_server_port: int = Field(default=8086)
mint_rpc_server_mutual_tls: bool = Field(default=True)
mint_rpc_server_addr: str = Field(
default="localhost", description="Address for the management RPC server."
)
mint_rpc_server_port: int = Field(
default=8086, gt=0, lt=65536, description="Port for the management RPC server."
)
mint_rpc_server_mutual_tls: bool = Field(
default=True, description="Require client certificates."
)
class WalletSettings(CashuSettings):

View File

@@ -99,6 +99,7 @@ class StrikeWallet(LightningBackend):
unit: Unit,
) -> int:
fee_str = strike_quote.totalFee.amount
fee: int = 0
if strike_quote.totalFee.currency == self.currency_map[Unit.sat]:
if unit == Unit.sat:
fee = int(float(fee_str) * 1e8)
@@ -107,8 +108,13 @@ class StrikeWallet(LightningBackend):
elif strike_quote.totalFee.currency in [
self.currency_map[Unit.usd],
self.currency_map[Unit.eur],
USDT,
]:
fee = int(float(fee_str) * 100)
else:
raise Exception(
f"Unexpected currency {strike_quote.totalFee.currency} in fee"
)
return fee
def __init__(self, unit: Unit, **kwargs):
@@ -154,7 +160,7 @@ class StrikeWallet(LightningBackend):
balance=Amount.from_float(float(balance["total"]), self.unit),
)
# if no the unit is USD but no USD balance was found, we try USDT
# if the unit is USD but no USD balance was found, we try USDT
if self.unit == Unit.usd:
for balance in data:
if balance["currency"] == USDT:

View File

@@ -3,6 +3,7 @@ from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from ...core.base import (
BlindedMessage,
BlindedSignature,
MeltQuote,
MintKeyset,
@@ -129,7 +130,7 @@ class AuthLedgerCrud(ABC):
) -> None: ...
@abstractmethod
async def get_promise(
async def get_blind_signature(
self,
*,
db: Database,
@@ -138,13 +139,13 @@ class AuthLedgerCrud(ABC):
) -> Optional[BlindedSignature]: ...
@abstractmethod
async def get_promises(
async def get_outputs(
self,
*,
db: Database,
b_s: List[str],
conn: Optional[Connection] = None,
) -> List[BlindedSignature]: ...
) -> List[BlindedMessage]: ...
class AuthLedgerCrudSqlite(AuthLedgerCrud):
@@ -234,7 +235,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
},
)
async def get_promise(
async def get_blind_signature(
self,
*,
db: Database,
@@ -248,15 +249,15 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
""",
{"b_": str(b_)},
)
return BlindedSignature.from_row(row) if row else None
return BlindedSignature.from_row(row) if row else None # type: ignore
async def get_promises(
async def get_outputs(
self,
*,
db: Database,
b_s: List[str],
conn: Optional[Connection] = None,
) -> List[BlindedSignature]:
) -> List[BlindedMessage]:
rows = await (conn or db).fetchall(
f"""
SELECT * from {db.table_with_schema('promises')}
@@ -264,7 +265,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
""",
{f"b_{i}": b_s[i] for i in range(len(b_s))},
)
return [BlindedSignature.from_row(r) for r in rows] if rows else []
return [BlindedMessage.from_row(r) for r in rows] if rows else []
async def invalidate_proof(
self,
@@ -303,7 +304,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
SELECT * from {db.table_with_schema('melt_quotes')} WHERE quote in (SELECT DISTINCT melt_quote FROM {db.table_with_schema('proofs_pending')})
"""
)
return [MeltQuote.from_row(r) for r in rows]
return [MeltQuote.from_row(r) for r in rows] if rows else [] # type: ignore
async def get_pending_proofs_for_quote(
self,
@@ -441,7 +442,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
)
if row is None:
return None
return MintQuote.from_row(row) if row else None
return MintQuote.from_row(row) if row else None # type: ignore
async def get_mint_quote_by_request(
self,
@@ -457,7 +458,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
""",
{"request": request},
)
return MintQuote.from_row(row) if row else None
return MintQuote.from_row(row) if row else None # type: ignore
async def update_mint_quote(
self,
@@ -549,7 +550,7 @@ class AuthLedgerCrudSqlite(AuthLedgerCrud):
)
if row is None:
return None
return MeltQuote.from_row(row) if row else None
return MeltQuote.from_row(row) if row else None # type: ignore
async def update_melt_quote(
self,

View File

@@ -206,7 +206,7 @@ class LedgerCrud(ABC):
) -> List[BlindedSignature]: ...
@abstractmethod
async def get_promise(
async def get_blind_signature(
self,
*,
db: Database,
@@ -215,13 +215,13 @@ class LedgerCrud(ABC):
) -> Optional[BlindedSignature]: ...
@abstractmethod
async def get_promises(
async def get_outputs(
self,
*,
db: Database,
b_s: List[str],
conn: Optional[Connection] = None,
) -> List[BlindedSignature]: ...
) -> List[BlindedMessage]: ...
@abstractmethod
async def store_mint_quote(
@@ -451,7 +451,7 @@ class LedgerCrudSqlite(LedgerCrud):
},
)
async def get_promise(
async def get_blind_signature(
self,
*,
db: Database,
@@ -467,21 +467,22 @@ class LedgerCrudSqlite(LedgerCrud):
)
return BlindedSignature.from_row(row) if row else None # type: ignore
async def get_promises(
async def get_outputs(
self,
*,
db: Database,
b_s: List[str],
conn: Optional[Connection] = None,
) -> List[BlindedSignature]:
) -> List[BlindedMessage]:
rows = await (conn or db).fetchall(
f"""
SELECT * from {db.table_with_schema('promises')}
WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])}) AND c_ IS NOT NULL
WHERE b_ IN ({','.join([f":b_{i}" for i in range(len(b_s))])})
""",
{f"b_{i}": b_s[i] for i in range(len(b_s))},
)
return [BlindedSignature.from_row(r) for r in rows] if rows else [] # type: ignore
# could be unsigned (BlindedMessage) or signed (BlindedSignature), but BlindedMessage is a subclass of BlindedSignature
return [BlindedMessage.from_row(r) for r in rows] if rows else [] # type: ignore
async def invalidate_proof(
self,
@@ -1037,7 +1038,7 @@ class LedgerCrudSqlite(LedgerCrud):
)
return MintBalanceLogEntry.from_row(row) if row else None
async def get_melt_quotes_by_checking_id(
self,
*,
@@ -1050,6 +1051,6 @@ class LedgerCrudSqlite(LedgerCrud):
SELECT * FROM {db.table_with_schema('melt_quotes')}
WHERE checking_id = :checking_id
""",
{"checking_id": checking_id}
{"checking_id": checking_id},
)
return [MeltQuote.from_row(row) for row in results] # type: ignore
return [MeltQuote.from_row(row) for row in results] # type: ignore

View File

@@ -224,8 +224,11 @@ class Ledger(
proofs (List[Proof]): Proofs to add to known secret table.
conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None.
"""
# sum_proofs = sum([p.amount for p in proofs])
fees_proofs = self.get_fees_for_proofs(proofs)
# Group proofs by keyset_id to calculate fees per keyset
proofs_by_keyset: Dict[str, List[Proof]] = {}
for p in proofs:
proofs_by_keyset.setdefault(p.id, []).append(p)
async with self.db.get_connection(conn) as conn:
# store in db
for p in proofs:
@@ -241,9 +244,18 @@ class Ledger(
Y=p.Y, state=ProofSpentState.spent, witness=p.witness or None
)
)
await self.crud.bump_keyset_fees_paid(
keyset=self.keyset, amount=fees_proofs, db=self.db, conn=conn
)
# Calculate and increment fees for each keyset separately
for keyset_id, keyset_proofs in proofs_by_keyset.items():
keyset_fees = self.get_fees_for_proofs(keyset_proofs)
if keyset_fees > 0:
logger.trace(f"Adding fees {keyset_fees} to keyset {keyset_id}")
await self.crud.bump_keyset_fees_paid(
keyset=self.keysets[keyset_id],
amount=keyset_fees,
db=self.db,
conn=conn,
)
async def _generate_change_promises(
self,
@@ -1086,7 +1098,7 @@ class Ledger(
async with self.db.get_connection() as conn:
for output in outputs:
logger.trace(f"looking for promise: {output}")
promise = await self.crud.get_promise(
promise = await self.crud.get_blind_signature(
b_=output.B_, db=self.db, conn=conn
)
if promise is not None:

View File

@@ -133,17 +133,19 @@ class LedgerVerification(
if not self._verify_no_duplicate_outputs(outputs):
raise TransactionDuplicateOutputsError()
# verify that outputs have not been signed previously
signed_before = await self._check_outputs_issued_before(outputs, conn)
signed_before = await self._check_outputs_pending_or_issued_before(
outputs, conn
)
if any(signed_before):
raise OutputsAlreadySignedError()
logger.trace(f"Verified {len(outputs)} outputs.")
async def _check_outputs_issued_before(
async def _check_outputs_pending_or_issued_before(
self,
outputs: List[BlindedMessage],
conn: Optional[Connection] = None,
) -> List[bool]:
"""Checks whether the provided outputs have previously been signed by the mint
"""Checks whether the provided outputs have previously stored (as blinded messages) been signed (as blind signatures) by the mint
(which would lead to a duplication error later when trying to store these outputs again).
Args:
@@ -153,7 +155,7 @@ class LedgerVerification(
result (List[bool]): Whether outputs are already present in the database.
"""
async with self.db.get_connection(conn) as conn:
promises = await self.crud.get_promises(
promises = await self.crud.get_outputs(
b_s=[output.B_ for output in outputs], db=self.db, conn=conn
)
return [True if promise else False for promise in promises]

View File

@@ -243,12 +243,13 @@ async def update_keyset(
await (conn or db).execute(
"""
UPDATE keysets
SET active = :active
SET active = :active, input_fee_ppk = :input_fee_ppk
WHERE id = :id
""",
{
"active": keyset.active,
"id": keyset.id,
"input_fee_ppk": keyset.input_fee_ppk,
},
)

View File

@@ -343,6 +343,9 @@ class Wallet(
].input_fee_ppk = mint_keyset.input_fee_ppk
changed = True
if changed:
logger.debug(
f"Updating mint keyset: {mint_keyset.id} ({mint_keyset.unit}) fee: {mint_keyset.input_fee_ppk} ppk, active: {mint_keyset.active}"
)
await update_keyset(
keyset=keysets_in_db_dict[mint_keyset.id], db=self.db
)

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "cashu"
version = "0.18.1"
version = "0.18.2"
description = "Ecash wallet and mint"
authors = ["calle <callebtc@protonmail.com>"]
license = "MIT"

View File

@@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]}
setuptools.setup(
name="cashu",
version="0.18.1",
version="0.18.2",
description="Ecash wallet and mint",
long_description=long_description,
long_description_content_type="text/markdown",

View File

@@ -390,8 +390,8 @@ async def test_store_and_sign_blinded_message(ledger: Ledger):
s=s.serialize(),
)
# Assert: row is now a full promise and can be read back via get_promise
promise = await ledger.crud.get_promise(db=ledger.db, b_=B_hex)
# Assert: row is now a full promise and can be read back via get_blind_signature
promise = await ledger.crud.get_blind_signature(db=ledger.db, b_=B_hex)
assert promise is not None
assert promise.amount == amount
assert promise.C_ == C_point.serialize().hex()
@@ -760,9 +760,9 @@ async def test_promises_fk_constraints_enforced(ledger: Ledger):
async def test_concurrent_set_melt_quote_pending_same_checking_id(ledger: Ledger):
"""Test that concurrent attempts to set quotes with same checking_id as pending are handled correctly."""
from cashu.core.base import MeltQuote, MeltQuoteState
checking_id = "test_checking_id_concurrent"
# Create two quotes with the same checking_id
quote1 = MeltQuote(
quote="quote_id_conc_1",
@@ -784,24 +784,24 @@ async def test_concurrent_set_melt_quote_pending_same_checking_id(ledger: Ledger
fee_reserve=2,
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db)
await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db)
# Try to set both as pending concurrently
results = await asyncio.gather(
ledger.db_write._set_melt_quote_pending(quote=quote1),
ledger.db_write._set_melt_quote_pending(quote=quote2),
return_exceptions=True
return_exceptions=True,
)
# One should succeed, one should fail
success_count = sum(1 for r in results if isinstance(r, MeltQuote))
error_count = sum(1 for r in results if isinstance(r, Exception))
assert success_count == 1, "Exactly one quote should be set as pending"
assert error_count == 1, "Exactly one should fail"
# The error should be about the quote already being pending
error = next(r for r in results if isinstance(r, Exception))
assert "Melt quote already paid or pending." in str(error)

View File

@@ -4,7 +4,7 @@ import pytest
import pytest_asyncio
from cashu.core.base import MeltQuote, MeltQuoteState, Proof
from cashu.core.errors import LightningPaymentFailedError
from cashu.core.errors import LightningPaymentFailedError, OutputsAlreadySignedError
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.core.settings import settings
from cashu.lightning.base import PaymentResult
@@ -13,6 +13,7 @@ from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT
from tests.helpers import (
get_real_invoice,
is_deprecated_api_only,
is_fake,
is_regtest,
pay_if_regtest,
@@ -85,6 +86,174 @@ async def create_pending_melts(
return pending_proof, quote
@pytest.mark.asyncio
@pytest.mark.skipif(
not is_fake or is_deprecated_api_only,
reason="only fakewallet and non-deprecated api",
)
async def test_pending_melt_quote_outputs_registration_regression(
wallet, ledger: Ledger
):
"""When paying a request results in a PENDING melt quote,
the change outputs should be registered properly
and further requests with the same outputs should result in an expected error.
"""
settings.fakewallet_payment_state = PaymentResult.PENDING.name
settings.fakewallet_pay_invoice_state = PaymentResult.PENDING.name
mint_quote1 = await wallet.request_mint(100)
mint_quote2 = await wallet.request_mint(100)
# await pay_if_regtest(mint_quote1.request)
# await pay_if_regtest(mint_quote2.request)
proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote)
proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote)
invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
# Get two melt quotes
melt_quote1 = await wallet.melt_quote(invoice_64_sat)
melt_quote2 = await wallet.melt_quote(invoice_62_sat)
n_change_outputs = 7
(
change_secrets,
change_rs,
change_derivation_paths,
) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True)
change_outputs, change_rs = wallet._construct_outputs(
n_change_outputs * [1], change_secrets, change_rs
)
response1 = await ledger.melt(
proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs
)
assert response1.state == "PENDING"
await assert_err(
ledger.melt(
proofs=proofs2,
quote=melt_quote2.quote,
outputs=change_outputs,
),
OutputsAlreadySignedError.detail,
)
# use get_melt_quote to verify that the quote state is updated
melt_quote1_updated = await ledger.get_melt_quote(melt_quote1.quote)
assert melt_quote1_updated.state == MeltQuoteState.pending
melt_quote2_updated = await ledger.get_melt_quote(melt_quote2.quote)
assert melt_quote2_updated.state == MeltQuoteState.unpaid
@pytest.mark.asyncio
@pytest.mark.skipif(
not is_fake or is_deprecated_api_only,
reason="only fakewallet and non-deprecated api",
)
async def test_settled_melt_quote_outputs_registration_regression(
wallet, ledger: Ledger
):
"""Verify that if one melt request fails, we can still use the same outputs in another request"""
settings.fakewallet_payment_state = PaymentResult.FAILED.name
settings.fakewallet_pay_invoice_state = PaymentResult.FAILED.name
mint_quote1 = await wallet.request_mint(100)
mint_quote2 = await wallet.request_mint(100)
# await pay_if_regtest(mint_quote1.request)
# await pay_if_regtest(mint_quote2.request)
proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote)
proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote)
invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
# Get two melt quotes
melt_quote1 = await wallet.melt_quote(invoice_64_sat)
melt_quote2 = await wallet.melt_quote(invoice_62_sat)
n_change_outputs = 7
(
change_secrets,
change_rs,
change_derivation_paths,
) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True)
change_outputs, change_rs = wallet._construct_outputs(
n_change_outputs * [1], change_secrets, change_rs
)
await assert_err(
ledger.melt(proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs),
"Lightning payment failed.",
)
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name
response2 = await ledger.melt(
proofs=proofs2,
quote=melt_quote2.quote,
outputs=change_outputs,
)
assert response2.state == "PAID"
# use get_melt_quote to verify that the quote state is updated
melt_quote2_updated = await ledger.get_melt_quote(melt_quote2.quote)
assert melt_quote2_updated.state == MeltQuoteState.paid
@pytest.mark.asyncio
@pytest.mark.skipif(
not is_fake or is_deprecated_api_only,
reason="only fakewallet and non-deprecated api",
)
async def test_melt_quote_reuse_same_outputs(wallet, ledger: Ledger):
"""Verify that if the same outputs are used in two melt requests,
the second one fails.
"""
settings.fakewallet_payment_state = PaymentResult.SETTLED.name
settings.fakewallet_pay_invoice_state = PaymentResult.SETTLED.name
mint_quote1 = await wallet.request_mint(100)
mint_quote2 = await wallet.request_mint(100)
# await pay_if_regtest(mint_quote1.request)
# await pay_if_regtest(mint_quote2.request)
proofs1 = await wallet.mint(amount=100, quote_id=mint_quote1.quote)
proofs2 = await wallet.mint(amount=100, quote_id=mint_quote2.quote)
invoice_64_sat = "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
invoice_62_sat = "lnbcrt620n1pn0r3vepp5zljn7g09fsyeahl4rnhuy0xax2puhua5r3gspt7ttlfrley6valqdqqcqzzsxqyz5vqsp577h763sel3q06tfnfe75kvwn5pxn344sd5vnays65f9wfgx4fpzq9qxpqysgqg3re9afz9rwwalytec04pdhf9mvh3e2k4r877tw7dr4g0fvzf9sny5nlfggdy6nduy2dytn06w50ls34qfldgsj37x0ymxam0a687mspp0ytr8"
# Get two melt quotes
melt_quote1 = await wallet.melt_quote(invoice_64_sat)
melt_quote2 = await wallet.melt_quote(invoice_62_sat)
n_change_outputs = 7
(
change_secrets,
change_rs,
change_derivation_paths,
) = await wallet.generate_n_secrets(n_change_outputs, skip_bump=True)
change_outputs, change_rs = wallet._construct_outputs(
n_change_outputs * [1], change_secrets, change_rs
)
(ledger.melt(proofs=proofs1, quote=melt_quote1.quote, outputs=change_outputs),)
await assert_err(
ledger.melt(
proofs=proofs2,
quote=melt_quote2.quote,
outputs=change_outputs,
),
OutputsAlreadySignedError.detail,
)
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only fake wallet")
async def test_fakewallet_pending_quote_get_melt_quote_success(ledger: Ledger):
@@ -379,7 +548,7 @@ async def test_mint_melt_different_units(ledger: Ledger, wallet: Wallet):
async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger):
"""Test that setting a melt quote as pending without a checking_id raises an error."""
from cashu.core.errors import TransactionError
quote = MeltQuote(
quote="quote_id_no_checking",
method="bolt11",
@@ -391,10 +560,10 @@ async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger):
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote, db=ledger.db)
# Set checking_id to empty to simulate the error condition
quote.checking_id = ""
try:
await ledger.db_write._set_melt_quote_pending(quote=quote)
raise AssertionError("Expected TransactionError")
@@ -406,9 +575,9 @@ async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger):
async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Ledger):
"""Test that setting a melt quote as pending fails if another quote with same checking_id is already pending."""
from cashu.core.errors import TransactionError
checking_id = "test_checking_id_duplicate"
quote1 = MeltQuote(
quote="quote_id_dup_first",
method="bolt11",
@@ -429,26 +598,30 @@ async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Led
fee_reserve=2,
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db)
await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db)
# Set the first quote as pending
await ledger.db_write._set_melt_quote_pending(quote=quote1)
# Verify the first quote is pending
quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_dup_first", db=ledger.db)
quote1_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_dup_first", db=ledger.db
)
assert quote1_db.state == MeltQuoteState.pending
# Attempt to set the second quote as pending should fail
try:
await ledger.db_write._set_melt_quote_pending(quote=quote2)
raise AssertionError("Expected TransactionError")
except TransactionError as e:
assert "Melt quote already paid or pending." in str(e)
# Verify the second quote is still unpaid
quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_dup_second", db=ledger.db)
quote2_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_dup_second", db=ledger.db
)
assert quote2_db.state == MeltQuoteState.unpaid
@@ -457,7 +630,7 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge
"""Test that setting melt quotes as pending succeeds when they have different checking_ids."""
checking_id_1 = "test_checking_id_allow_1"
checking_id_2 = "test_checking_id_allow_2"
quote1 = MeltQuote(
quote="quote_id_allow_1",
method="bolt11",
@@ -478,17 +651,21 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge
fee_reserve=2,
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db)
await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db)
# Set both quotes as pending - should succeed
await ledger.db_write._set_melt_quote_pending(quote=quote1)
await ledger.db_write._set_melt_quote_pending(quote=quote2)
# Verify both quotes are pending
quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_allow_1", db=ledger.db)
quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_allow_2", db=ledger.db)
quote1_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_allow_1", db=ledger.db
)
quote2_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_allow_2", db=ledger.db
)
assert quote1_db.state == MeltQuoteState.pending
assert quote2_db.state == MeltQuoteState.pending
@@ -497,7 +674,7 @@ async def test_set_melt_quote_pending_allows_different_checking_id(ledger: Ledge
async def test_set_melt_quote_pending_after_unset(ledger: Ledger):
"""Test that a quote can be set as pending again after being unset."""
checking_id = "test_checking_id_unset_test"
quote1 = MeltQuote(
quote="quote_id_unset_first",
method="bolt11",
@@ -518,28 +695,38 @@ async def test_set_melt_quote_pending_after_unset(ledger: Ledger):
fee_reserve=2,
state=MeltQuoteState.unpaid,
)
await ledger.crud.store_melt_quote(quote=quote1, db=ledger.db)
await ledger.crud.store_melt_quote(quote=quote2, db=ledger.db)
# Set the first quote as pending
quote1_pending = await ledger.db_write._set_melt_quote_pending(quote=quote1)
assert quote1_pending.state == MeltQuoteState.pending
# Unset the first quote (mark as paid)
await ledger.db_write._unset_melt_quote_pending(quote=quote1_pending, state=MeltQuoteState.paid)
await ledger.db_write._unset_melt_quote_pending(
quote=quote1_pending, state=MeltQuoteState.paid
)
# Verify the first quote is no longer pending
quote1_db = await ledger.crud.get_melt_quote(quote_id="quote_id_unset_first", db=ledger.db)
quote1_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_unset_first", db=ledger.db
)
assert quote1_db.state == MeltQuoteState.paid
# Now the second quote should still
assert_err(ledger.db_write._set_melt_quote_pending(quote=quote2), "Melt quote already paid or pending.")
await assert_err(
ledger.db_write._set_melt_quote_pending(quote=quote2),
"Melt quote already paid or pending.",
)
# Verify the second quote is unpaid
quote2_db = await ledger.crud.get_melt_quote(quote_id="quote_id_unset_second", db=ledger.db)
quote2_db = await ledger.crud.get_melt_quote(
quote_id="quote_id_unset_second", db=ledger.db
)
assert quote2_db.state == MeltQuoteState.unpaid
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_mint_pay_with_duplicate_checking_id(wallet):
@@ -551,18 +738,26 @@ async def test_mint_pay_with_duplicate_checking_id(wallet):
proofs1 = await wallet.mint(amount=1024, quote_id=mint_quote1.quote)
proofs2 = await wallet.mint(amount=1024, quote_id=mint_quote2.quote)
invoice = get_real_invoice(64)['payment_request']
invoice = get_real_invoice(64)["payment_request"]
# Get two melt quotes for the same invoice
melt_quote1 = await wallet.melt_quote(invoice)
melt_quote2 = await wallet.melt_quote(invoice)
response1 = await wallet.melt(
proofs=proofs1, invoice=invoice, fee_reserve_sat=melt_quote1.fee_reserve, quote_id=melt_quote1.quote
)
assert response1.state == 'PAID'
proofs=proofs1,
invoice=invoice,
fee_reserve_sat=melt_quote1.fee_reserve,
quote_id=melt_quote1.quote,
)
assert response1.state == "PAID"
assert_err(wallet.melt(
proofs=proofs2, invoice=invoice, fee_reserve_sat=melt_quote2.fee_reserve, quote_id=melt_quote2.quote
), "Melt quote already paid or pending.")
assert_err(
wallet.melt(
proofs=proofs2,
invoice=invoice,
fee_reserve_sat=melt_quote2.fee_reserve,
quote_id=melt_quote2.quote,
),
"Melt quote already paid or pending.",
)

View File

@@ -2,6 +2,7 @@ import pytest
import pytest_asyncio
from cashu.core.base import MeltQuoteState, MintQuoteState
from cashu.core.errors import OutputsAlreadySignedError
from cashu.core.helpers import sum_proofs
from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest
from cashu.core.nuts import nut20
@@ -145,7 +146,7 @@ async def test_mint_internal(wallet1: Wallet, ledger: Ledger):
await assert_err(
ledger.mint(outputs=outputs, quote_id=mint_quote.quote),
"outputs have already been signed before.",
OutputsAlreadySignedError.detail,
)
mint_quote_after_payment = await ledger.get_mint_quote(mint_quote.quote)
@@ -294,7 +295,7 @@ async def test_split_twice_with_same_outputs(wallet1: Wallet, ledger: Ledger):
# try to spend other proofs with the same outputs again
await assert_err(
ledger.swap(proofs=inputs2, outputs=outputs),
"outputs have already been signed before.",
OutputsAlreadySignedError.detail,
)
# try to spend inputs2 again with new outputs
@@ -328,7 +329,7 @@ async def test_mint_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger):
signature = nut20.sign_mint_quote(mint_quote_2.quote, outputs, mint_quote_2.privkey)
await assert_err(
ledger.mint(outputs=outputs, quote_id=mint_quote_2.quote, signature=signature),
"outputs have already been signed before.",
OutputsAlreadySignedError.detail,
)
@@ -358,7 +359,7 @@ async def test_melt_with_same_outputs_twice(wallet1: Wallet, ledger: Ledger):
)
await assert_err(
ledger.melt(proofs=wallet1.proofs, quote=melt_quote.quote, outputs=outputs),
"outputs have already been signed before.",
OutputsAlreadySignedError.detail,
)