diff --git a/cashu/core/base.py b/cashu/core/base.py index 1d047098..9cede73e 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -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 @@ -310,6 +311,10 @@ def from_row(cls, row: Row): if row["change"]: change = json.loads(row["change"]) + outputs = None + if row["outputs"]: + outputs = json.loads(row["outputs"]) + return cls( quote=row["quote"], method=row["method"], @@ -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, diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 89feb3c6..d7b17bda 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -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 "", }, ) @@ -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, @@ -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 "" @@ -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, @@ -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 diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index 239b529a..7d589618 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -3,6 +3,7 @@ from loguru import logger from ...core.base import ( + BlindedMessage, MeltQuote, MeltQuoteState, MintQuote, @@ -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: @@ -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) @@ -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) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 60b6d234..a070f8df 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -144,17 +144,17 @@ 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: + 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}") @@ -772,13 +772,27 @@ async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuo 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) + # 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 @@ -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) @@ -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) diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 39c29487..61c7b129 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -839,6 +839,7 @@ 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( @@ -846,4 +847,14 @@ async def m023_add_key_to_mint_quote_table(db: Database): ALTER TABLE {db.table_with_schema('mint_quotes')} ADD COLUMN pubkey TEXT DEFAULT NULL """ - ) \ No newline at end of file + ) + + +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 + """ + ) diff --git a/cashu/wallet/transactions.py b/cashu/wallet/transactions.py index 0fb83d68..bb7464bf 100644 --- a/cashu/wallet/transactions.py +++ b/cashu/wallet/transactions.py @@ -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}"