mirror of
https://github.com/aljazceru/nutshell.git
synced 2026-02-01 15:04:18 +01:00
Merge branch 'cashubtc:main' into main
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
setup.py
2
setup.py
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user