Skip to content

Commit

Permalink
Merge branch 'main' into auth-server
Browse files Browse the repository at this point in the history
  • Loading branch information
callebtc committed Jan 22, 2025
2 parents 0abd3c5 + ea96fab commit 55a5ab3
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 36 deletions.
8 changes: 7 additions & 1 deletion cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ class MeltQuote(LedgerEvent):
fee_paid: int = 0
payment_preimage: Optional[str] = None
expiry: Optional[int] = None
outputs: Optional[List[BlindedMessage]] = None
change: Optional[List[BlindedSignature]] = None
mint: Optional[str] = None

Expand All @@ -310,9 +311,13 @@ def from_row(cls, row: Row):

# parse change from row as json
change = None
if row["change"]:
if "change" in row.keys() and row["change"]:
change = json.loads(row["change"])

outputs = None
if "outputs" in row.keys() and row["outputs"]:
outputs = json.loads(row["outputs"])

return cls(
quote=row["quote"],
method=row["method"],
Expand All @@ -325,6 +330,7 @@ def from_row(cls, row: Row):
created_time=created_time,
paid_time=paid_time,
fee_paid=row["fee_paid"],
outputs=outputs,
change=change,
expiry=expiry,
payment_preimage=payment_preimage,
Expand Down
18 changes: 18 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ class NotAllowedError(CashuError):
def __init__(self, detail: Optional[str] = None, code: Optional[int] = None):
super().__init__(detail or self.detail, code=code or self.code)

class OutputsAlreadySignedError(CashuError):
detail = "outputs have already been signed before."
code = 10002

def __init__(self, detail: Optional[str] = None, code: Optional[int] = None):
super().__init__(detail or self.detail, code=code or self.code)

class InvalidProofsError(CashuError):
detail = "proofs could not be verified"
code = 10003

def __init__(self, detail: Optional[str] = None, code: Optional[int] = None):
super().__init__(detail or self.detail, code=code or self.code)

class TransactionError(CashuError):
detail = "transaction error"
Expand Down Expand Up @@ -63,6 +76,11 @@ class TransactionUnitError(TransactionError):
def __init__(self, detail):
super().__init__(detail, code=self.code)

class TransactionAmountExceedsLimitError(TransactionError):
code = 11006

def __init__(self, detail):
super().__init__(detail, code=self.code)

class KeysetError(CashuError):
detail = "keyset error"
Expand Down
5 changes: 4 additions & 1 deletion cashu/mint/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@


class RedisCache:
initialized = False
expiry = settings.mint_redis_cache_ttl

def __init__(self):
Expand All @@ -27,6 +28,7 @@ async def test_connection(self):
try:
await self.redis.ping()
logger.success("Connected to Redis caching server.")
self.initialized = True
except ConnectionError as e:
logger.error("Redis connection error.")
raise e
Expand Down Expand Up @@ -64,4 +66,5 @@ async def wrapper(request: Request, payload: BaseModel):
return passthrough if not settings.mint_redis_cache_enabled else decorator

async def disconnect(self):
await self.redis.close()
if self.initialized:
await self.redis.close()
12 changes: 9 additions & 3 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,8 @@ async def store_melt_quote(
await (conn or db).execute(
f"""
INSERT INTO {db.table_with_schema('melt_quotes')}
(quote, method, request, checking_id, unit, amount, fee_reserve, state, paid, created_time, paid_time, fee_paid, proof, change, expiry)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :paid, :created_time, :paid_time, :fee_paid, :proof, :change, :expiry)
(quote, method, request, checking_id, unit, amount, fee_reserve, state, paid, created_time, paid_time, fee_paid, proof, outputs, change, expiry)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :paid, :created_time, :paid_time, :fee_paid, :proof, :outputs, :change, :expiry)
""",
{
"quote": quote.quote,
Expand All @@ -543,6 +543,7 @@ async def store_melt_quote(
),
"fee_paid": quote.fee_paid,
"proof": quote.payment_preimage,
"outputs": json.dumps(quote.outputs) if quote.outputs else None,
"change": json.dumps(quote.change) if quote.change else None,
"expiry": db.to_timestamp(
db.timestamp_from_seconds(quote.expiry) or ""
Expand Down Expand Up @@ -607,7 +608,7 @@ async def update_melt_quote(
) -> None:
await (conn or db).execute(
f"""
UPDATE {db.table_with_schema('melt_quotes')} SET state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, change = :change, checking_id = :checking_id WHERE quote = :quote
UPDATE {db.table_with_schema('melt_quotes')} SET state = :state, fee_paid = :fee_paid, paid_time = :paid_time, proof = :proof, outputs = :outputs, change = :change, checking_id = :checking_id WHERE quote = :quote
""",
{
"state": quote.state.value,
Expand All @@ -616,6 +617,11 @@ async def update_melt_quote(
db.timestamp_from_seconds(quote.paid_time) or ""
),
"proof": quote.payment_preimage,
"outputs": (
json.dumps([s.dict() for s in quote.outputs])
if quote.outputs
else None
),
"change": (
json.dumps([s.dict() for s in quote.change])
if quote.change
Expand Down
14 changes: 11 additions & 3 deletions cashu/mint/db/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from loguru import logger

from ...core.base import (
BlindedMessage,
MeltQuote,
MeltQuoteState,
MintQuote,
Expand Down Expand Up @@ -162,7 +163,7 @@ async def _unset_mint_quote_pending(
raise TransactionError(
f"Mint quote not pending: {quote.state.value}. Cannot set as {state.value}."
)
# set the quote as pending
# set the quote to previous state
quote.state = state
logger.trace(f"crud: setting quote {quote_id} as {state.value}")
await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn)
Expand All @@ -172,7 +173,9 @@ async def _unset_mint_quote_pending(
await self.events.submit(quote)
return quote

async def _set_melt_quote_pending(self, quote: MeltQuote) -> MeltQuote:
async def _set_melt_quote_pending(
self, quote: MeltQuote, outputs: Optional[List[BlindedMessage]] = None
) -> MeltQuote:
"""Sets the melt quote as pending.
Args:
Expand All @@ -193,6 +196,8 @@ async def _set_melt_quote_pending(self, quote: MeltQuote) -> MeltQuote:
raise TransactionError("Melt quote already pending.")
# set the quote as pending
quote_copy.state = MeltQuoteState.pending
if outputs:
quote_copy.outputs = outputs
await self.crud.update_melt_quote(quote=quote_copy, db=self.db, conn=conn)

await self.events.submit(quote_copy)
Expand All @@ -217,8 +222,11 @@ async def _unset_melt_quote_pending(
raise TransactionError("Melt quote not found.")
if quote_db.state != MeltQuoteState.pending:
raise TransactionError("Melt quote not pending.")
# set the quote as pending
# set the quote to previous state
quote_copy.state = state

# unset outputs
quote_copy.outputs = None
await self.crud.update_melt_quote(quote=quote_copy, db=self.db, conn=conn)

await self.events.submit(quote_copy)
Expand Down
49 changes: 33 additions & 16 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
NotAllowedError,
QuoteNotPaidError,
QuoteSignatureInvalidError,
TransactionAmountExceedsLimitError,
TransactionError,
)
from ..core.helpers import sum_proofs
Expand Down Expand Up @@ -162,19 +163,19 @@ async def shutdown_ledger(self) -> None:
for task in self.invoice_listener_tasks:
task.cancel()

async def _check_pending_proofs_and_melt_quotes(self) -> None:
"""Startup routine that checks all pending proofs for their melt state and either invalidates
them for a successful melt or deletes them if the melt failed.
async def _check_pending_proofs_and_melt_quotes(self):
"""Startup routine that checks all pending melt quotes and either invalidates
their pending proofs for a successful melt or deletes them if the melt failed.
"""
# get all pending melt quotes
melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs(
pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs(
db=self.db
)
if not melt_quotes:
if not pending_melt_quotes:
return
logger.info("Checking pending melt quotes")
for quote in melt_quotes:
quote = await self.get_melt_quote(quote_id=quote.quote, purge_unknown=True)
logger.info(f"Checking {len(pending_melt_quotes)} pending melt quotes")
for quote in pending_melt_quotes:
quote = await self.get_melt_quote(quote_id=quote.quote)
logger.info(f"Melt quote {quote.quote} state: {quote.state}")

# ------- KEYS -------
Expand Down Expand Up @@ -430,7 +431,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote:
if not quote_request.amount > 0:
raise TransactionError("amount must be positive")
if settings.mint_max_peg_in and quote_request.amount > settings.mint_max_peg_in:
raise NotAllowedError(
raise TransactionAmountExceedsLimitError(
f"Maximum mint amount is {settings.mint_max_peg_in} sat."
)
if settings.mint_peg_out_only:
Expand Down Expand Up @@ -750,17 +751,17 @@ async def melt_quote(
expiry=quote.expiry,
)

async def get_melt_quote(self, quote_id: str, purge_unknown=False) -> MeltQuote:
async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuote:
"""Returns a melt quote.
If the melt quote is pending, checks status of the payment with the backend.
- If settled, sets the quote as paid and invalidates pending proofs (commit).
- If failed, sets the quote as unpaid and unsets pending proofs (rollback).
- If purge_unknown is set, do the same for unknown states as for failed states.
- If rollback_unknown is set, do the same for unknown states as for failed states.
Args:
quote_id (str): ID of the melt quote.
purge_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False.
rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False.
Raises:
Exception: Quote not found.
Expand Down Expand Up @@ -799,14 +800,28 @@ async def get_melt_quote(self, quote_id: str, purge_unknown=False) -> MeltQuote:
if status.preimage:
melt_quote.payment_preimage = status.preimage
melt_quote.paid_time = int(time.time())
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
pending_proofs = await self.crud.get_pending_proofs_for_quote(
quote_id=quote_id, db=self.db
)
await self._invalidate_proofs(proofs=pending_proofs, quote_id=quote_id)
await self.db_write._unset_proofs_pending(pending_proofs)
if status.failed or (purge_unknown and status.unknown):
# change to compensate wallet for overpaid fees
if melt_quote.outputs:
total_provided = sum_proofs(pending_proofs)
input_fees = self.get_fees_for_proofs(pending_proofs)
fee_reserve_provided = (
total_provided - melt_quote.amount - input_fees
)
return_promises = await self._generate_change_promises(
fee_provided=fee_reserve_provided,
fee_paid=melt_quote.fee_paid,
outputs=melt_quote.outputs,
keyset=self.keysets[melt_quote.outputs[0].id],
)
melt_quote.change = return_promises
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
await self.events.submit(melt_quote)
if status.failed or (rollback_unknown and status.unknown):
logger.debug(f"Setting quote {quote_id} as unpaid")
melt_quote.state = MeltQuoteState.unpaid
await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
Expand Down Expand Up @@ -936,6 +951,8 @@ async def melt(
raise TransactionError(
f"output unit {outputs_unit.name} does not match quote unit {melt_quote.unit}"
)
# we don't need to set it here, _set_melt_quote_pending will set it in the db
melt_quote.outputs = outputs

# verify that the amount of the input proofs is equal to the amount of the quote
total_provided = sum_proofs(proofs)
Expand Down Expand Up @@ -966,7 +983,7 @@ async def melt(
proofs, quote_id=melt_quote.quote
)
previous_state = melt_quote.state
melt_quote = await self.db_write._set_melt_quote_pending(melt_quote)
melt_quote = await self.db_write._set_melt_quote_pending(melt_quote, outputs)

# if the melt corresponds to an internal mint, mark both as paid
melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
Expand Down
12 changes: 11 additions & 1 deletion cashu/mint/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,17 @@ async def m023_add_key_to_mint_quote_table(db: Database):
)


async def m024_add_amounts_to_keysets(db: Database):
async def m024_add_melt_quote_outputs(db: Database):
async with db.connect() as conn:
await conn.execute(
f"""
ALTER TABLE {db.table_with_schema('melt_quotes')}
ADD COLUMN outputs TEXT DEFAULT NULL
"""
)


async def m025_add_amounts_to_keysets(db: Database):
async with db.connect() as conn:
await conn.execute(
f"ALTER TABLE {db.table_with_schema('keysets')} ADD COLUMN amounts TEXT"
Expand Down
6 changes: 4 additions & 2 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
from ..core.crypto.secp import PublicKey
from ..core.db import Connection
from ..core.errors import (
InvalidProofsError,
NoSecretInProofsError,
NotAllowedError,
OutputsAlreadySignedError,
SecretTooLongError,
TransactionError,
TransactionUnitError,
Expand Down Expand Up @@ -68,7 +70,7 @@ async def verify_inputs_and_outputs(
raise TransactionError("duplicate proofs.")
# Verify ecash signatures
if not all([self._verify_proof_bdhke(p) for p in proofs]):
raise TransactionError("could not verify proofs.")
raise InvalidProofsError()
# Verify input spending conditions
if not all([self._verify_input_spending_conditions(p) for p in proofs]):
raise TransactionError("validation of input spending conditions failed.")
Expand Down Expand Up @@ -130,7 +132,7 @@ async def _verify_outputs(
# verify that outputs have not been signed previously
signed_before = await self._check_outputs_issued_before(outputs, conn)
if any(signed_before):
raise TransactionError("outputs have already been signed before.")
raise OutputsAlreadySignedError()
logger.trace(f"Verified {len(outputs)} outputs.")

async def _check_outputs_issued_before(
Expand Down
17 changes: 10 additions & 7 deletions tests/test_mint_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ async def test_startup_fakewallet_pending_quote_pending(ledger: Ledger):
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only for fake wallet")
async def test_startup_fakewallet_pending_quote_unknown(ledger: Ledger):
# unknown state simulates a failure th check the lightning backend
pending_proof, quote = await create_pending_melts(ledger)
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].pending
Expand All @@ -250,11 +251,12 @@ async def test_startup_fakewallet_pending_quote_unknown(ledger: Ledger):
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
assert melt_quotes
assert melt_quotes[0].state == MeltQuoteState.pending

# expect that proofs are still pending
states = await ledger.db_read.get_proofs_states([pending_proof.Y])
assert states[0].unspent
assert states[0].pending


@pytest.mark.asyncio
Expand Down Expand Up @@ -450,18 +452,19 @@ async def test_startup_regtest_pending_quote_unknown(wallet: Wallet, ledger: Led

await asyncio.sleep(SLEEP_TIME)

# run startup routinge
# run startup routine
await ledger.startup_ledger()

# expect that no melt quote is pending
# expect that melt quote is still pending
melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
db=ledger.db
)
assert not melt_quotes
assert melt_quotes
assert melt_quotes[0].state == MeltQuoteState.pending

# expect that proofs are unspent
# expect that proofs are pending
states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
assert all([s.unspent for s in states])
assert all([s.pending for s in states])

# clean up
cancel_invoice(preimage_hash=preimage_hash)
4 changes: 2 additions & 2 deletions tests/test_mint_melt.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ async def test_fakewallet_pending_quote_get_melt_quote_unknown(ledger: Ledger):
assert states[0].pending
settings.fakewallet_payment_state = PaymentResult.UNKNOWN.name

# get_melt_quote(..., purge_unknown=True) should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote, purge_unknown=True)
# get_melt_quote(..., rollback_unknown=True) should check the payment status and update the db
quote2 = await ledger.get_melt_quote(quote_id=quote.quote, rollback_unknown=True)
assert quote2.state == MeltQuoteState.unpaid

# expect that pending tokens are still in db
Expand Down

0 comments on commit 55a5ab3

Please sign in to comment.