Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue NUT-08 overpaid Lightning fees for melt quote checks on startup #688

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,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 @@ -307,9 +308,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 @@ -322,6 +327,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
14 changes: 10 additions & 4 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ async def store_mint_quote(
"paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or ""
),
"pubkey": quote.pubkey or ""
"pubkey": quote.pubkey or "",
},
)

Expand Down 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
11 changes: 10 additions & 1 deletion 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 @@ -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,9 @@ 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 @@ -219,6 +225,9 @@ async def _unset_melt_quote_pending(
raise TransactionError("Melt quote not pending.")
# set the quote as pending
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
44 changes: 30 additions & 14 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,18 @@ async def shutdown_ledger(self):
task.cancel()

async def _check_pending_proofs_and_melt_quotes(self):
"""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.
"""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 @@ -723,17 +723,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 @@ -772,14 +772,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 @@ -909,6 +923,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 @@ -939,7 +955,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
13 changes: 12 additions & 1 deletion cashu/mint/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,11 +839,22 @@ async def m022_quote_set_states_to_values(db: Database):
f"UPDATE {db.table_with_schema('mint_quotes')} SET state = '{mint_quote_states.value}' WHERE state = '{mint_quote_states.name}'"
)


async def m023_add_key_to_mint_quote_table(db: Database):
async with db.connect() as conn:
await conn.execute(
f"""
ALTER TABLE {db.table_with_schema('mint_quotes')}
ADD COLUMN pubkey TEXT DEFAULT NULL
"""
)
)


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
"""
)
4 changes: 2 additions & 2 deletions cashu/wallet/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ def coinselect(
remainder = amount_to_send
selected_proofs = [smaller_proofs[0]]
fee_ppk = self.get_fees_for_proofs_ppk(selected_proofs) if include_fees else 0
logger.debug(f"adding proof: {smaller_proofs[0].amount} – fee: {fee_ppk} ppk")
logger.trace(f"adding proof: {smaller_proofs[0].amount} – fee: {fee_ppk} ppk")
remainder -= smaller_proofs[0].amount - fee_ppk / 1000
logger.debug(f"remainder: {remainder}")
logger.trace(f"remainder: {remainder}")
if remainder > 0:
logger.trace(
f"> selecting more proofs from {amount_summary(smaller_proofs[1:], self.unit)} sum: {sum_proofs(smaller_proofs[1:])} to reach {remainder}"
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
Loading