From 01a3c556bc3f92cb149842cee30123e25a4a6904 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 17 Jul 2026 22:43:23 +0100 Subject: [PATCH 1/5] feat: verify and sign the length-framed NUT-11 SIG_ALL message The mint accepts the v1 ("Cashu_SigAllSig_v1") and current message formats, counting unique public keys with valid signatures and ignoring signatures that do not verify. The wallet signs v1, current and legacy so its transactions verify on mints at any upgrade stage. Canonical vectors from the NUT-11 spec tests are pinned in the test suite. --- cashu/core/nuts/nut11.py | 42 ++++++++- cashu/mint/conditions.py | 62 ++++++++++---- cashu/mint/ledger.py | 4 +- cashu/wallet/p2pk.py | 49 ++++++----- tests/mint/test_mint_conditions.py | 32 +++++-- tests/nuts/test_nut11_test_vectors.py | 104 ++++++++++++++++++----- tests/wallet/test_wallet_p2pk_methods.py | 20 +++-- 7 files changed, 236 insertions(+), 77 deletions(-) diff --git a/cashu/core/nuts/nut11.py b/cashu/core/nuts/nut11.py index 45567e10..5d6367a3 100644 --- a/cashu/core/nuts/nut11.py +++ b/cashu/core/nuts/nut11.py @@ -1,6 +1,35 @@ -from typing import List +from typing import List, Optional from ..base import BlindedMessage, Proof +from .nut20 import int_to_minimal_bytes + +SIGALL_SIG_DOMAIN_TAG = b"Cashu_SigAllSig_v1" + + +def _len_prefixed(data: bytes) -> bytes: + return len(data).to_bytes(4, "big") + data + + +def sigall_message_to_sign_v1( + proofs: List[Proof], + outputs: List[BlindedMessage], + quote_id: Optional[str] = None, +) -> bytes: + """ + Creates the NUT-11 v1 SIG_ALL message: domain-separated, length-framed bytes. + + Commits to the quote id (empty for swaps), then each proof's secret and C, + then each output's amount (minimal big-endian bytes) and B_. + """ + msg = bytearray(SIGALL_SIG_DOMAIN_TAG) + msg += _len_prefixed((quote_id or "").encode("utf-8")) + for p in proofs: + msg += _len_prefixed(p.secret.encode("utf-8")) + msg += _len_prefixed(bytes.fromhex(p.C)) + for o in outputs: + msg += _len_prefixed(int_to_minimal_bytes(o.amount)) + msg += _len_prefixed(bytes.fromhex(o.B_)) + return bytes(msg) def sigall_message_to_sign(proofs: List[Proof], outputs: List[BlindedMessage]) -> str: @@ -15,3 +44,14 @@ def sigall_message_to_sign(proofs: List[Proof], outputs: List[BlindedMessage]) - message += "".join([str(o.amount) + o.B_ for o in outputs]) return message + + +def sigall_message_to_sign_legacy( + proofs: List[Proof], outputs: List[BlindedMessage] +) -> str: + """ + SIG_ALL message as verified by releases <= 0.20.2: secrets then B_ fields. + + Kept so upgraded mints keep accepting witnesses from older wallets. + """ + return "".join([p.secret for p in proofs]) + "".join([o.B_ for o in outputs]) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index 85112810..f632a0af 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -46,7 +46,7 @@ def _verify_p2pk_sig_inputs( """ p2pk_secret = secret - message_to_sign = message_to_sign or proof.secret + messages_to_sign = [(message_to_sign or proof.secret).encode("utf-8")] # if a sigflag other than SIG_INPUTS is present, we return True if ( @@ -76,7 +76,7 @@ def _verify_p2pk_sig_inputs( if main_pubkeys: try: if self._verify_p2pk_signatures( - message_to_sign, main_pubkeys, proof.p2pksigs.copy(), main_n_sigs + messages_to_sign, main_pubkeys, proof.p2pksigs.copy(), main_n_sigs ): logger.trace("Spending condition satisfied via main pubkeys.") return True @@ -101,7 +101,7 @@ def _verify_p2pk_sig_inputs( if refund_pubkeys: try: if self._verify_p2pk_signatures( - message_to_sign, + messages_to_sign, refund_pubkeys, proof.p2pksigs.copy(), refund_n_sigs, @@ -121,9 +121,30 @@ def _verify_p2pk_sig_inputs( # if no pubkeys are present, anyone can spend return True + @staticmethod + def _verify_signature_any_message( + messages_to_sign: List[bytes], pubkey: str, signature: str + ) -> bool: + """True if the signature verifies under any accepted message format. + + Malformed signatures are ignored (treated as invalid) per NUT-11 + signature validation. + """ + for message in messages_to_sign: + try: + if verify_schnorr_signature( + message=message, + pubkey=PublicKey(bytes.fromhex(pubkey)), + signature=bytes.fromhex(signature), + ): + return True + except Exception: + continue + return False + def _verify_p2pk_signatures( self, - message_to_sign: str, + messages_to_sign: List[bytes], pubkeys: List[str], signatures: List[str], n_sigs_required: int, @@ -165,11 +186,8 @@ def _verify_p2pk_signatures( for pubkey in unique_pubkeys: for i, input_sig in enumerate(signatures): logger.trace(f"verifying signature {input_sig} by pubkey {pubkey}.") - logger.trace(f"Message: {message_to_sign}") - if verify_schnorr_signature( - message=message_to_sign.encode("utf-8"), - pubkey=PublicKey(bytes.fromhex(pubkey)), - signature=bytes.fromhex(input_sig), + if self._verify_signature_any_message( + messages_to_sign, pubkey, input_sig ): n_pubkeys_with_valid_sigs += 1 logger.trace( @@ -284,7 +302,7 @@ def _verify_sigall_spending_conditions( self, proofs: List[Proof], outputs: List[BlindedMessage], - message_to_sign: Optional[str] = None, + quote_id: Optional[str] = None, ) -> bool: """ If sigflag==SIG_ALL in any proof.secret, perform a signature check on all @@ -335,10 +353,18 @@ def _verify_sigall_spending_conditions( main_pubkeys = list(dict.fromkeys([p.lower() for p in main_pubkeys])) main_n_sigs = secret_lock.n_sigs or 1 - message_to_sign = message_to_sign or nut11.sigall_message_to_sign( - proofs, outputs - ) - + # All SIG_ALL message formats this mint accepts. Signatures that do not + # verify under any of them are ignored (NUT-11 signature validation); + # only unique pubkeys with valid signatures count towards the threshold. + # The legacy format is signed by wallets for older mints but is never + # accepted here: it does not commit to C values or output amounts. + quote_suffix = quote_id or "" + messages_to_sign = [ + nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id), + (nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix).encode( + "utf-8" + ), + ] first_proof = proofs[0] if not first_proof.witness: @@ -357,7 +383,7 @@ def _verify_sigall_spending_conditions( if main_pubkeys: try: if self._verify_p2pk_signatures( - message_to_sign, main_pubkeys, signatures.copy(), main_n_sigs + messages_to_sign, main_pubkeys, signatures.copy(), main_n_sigs ): logger.trace("Spending condition satisfied via main pubkeys.") return True @@ -382,7 +408,7 @@ def _verify_sigall_spending_conditions( if refund_pubkeys: try: if self._verify_p2pk_signatures( - message_to_sign, + messages_to_sign, refund_pubkeys, signatures.copy(), refund_n_sigs, @@ -406,7 +432,7 @@ def _verify_input_output_spending_conditions( self, proofs: List[Proof], outputs: List[BlindedMessage], - message_to_sign: Optional[str] = None, + quote_id: Optional[str] = None, ) -> bool: """ Verify spending conditions: @@ -423,4 +449,4 @@ def _verify_input_output_spending_conditions( # verify that all secrets are of the same kind, raise an error if not _ = self._verify_all_secrets_equal_and_return(proofs) - return self._verify_sigall_spending_conditions(proofs, outputs, message_to_sign) + return self._verify_sigall_spending_conditions(proofs, outputs, quote_id) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c546a86e..a9dc8af2 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -47,7 +47,6 @@ PostMintQuoteCheckRequest, PostMintQuoteRequest, ) -from ..core.nuts import nut11 from ..core.settings import settings from ..core.split import amount_split from ..lightning.base import ( @@ -1103,8 +1102,7 @@ async def _prepare_melt( ) # verify SIG_ALL signatures - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs or []) + quote - self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign) + self._verify_sigall_spending_conditions(proofs, outputs or [], quote_id=quote) # verify that the amount of the input proofs is equal to the amount of the quote total_provided = sum_proofs(proofs) diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index 76722930..e329a2ac 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import List, Optional +from typing import List, Optional, Union from loguru import logger @@ -109,12 +109,15 @@ def signatures_proofs_sig_inputs(self, proofs: List[Proof]) -> List[str]: logger.debug(f"Signatures: {signatures}") return signatures - def schnorr_sign_message(self, message: str, signing_key: Optional[PrivateKey] = None) -> str: + def schnorr_sign_message( + self, message: Union[str, bytes], signing_key: Optional[PrivateKey] = None + ) -> str: """Sign a message with the given key or the wallet's private key.""" key = signing_key or self.private_key assert key.public_key + message_bytes = message.encode("utf-8") if isinstance(message, str) else message return schnorr_sign( - message=message.encode("utf-8"), + message=message_bytes, private_key=key, ).hex() @@ -146,7 +149,7 @@ def add_witness_swap_sig_all( self, proofs: List[Proof], outputs: List[BlindedMessage], - message_to_sign: Optional[str] = None, + quote_id: Optional[str] = None, ) -> List[Proof]: """Determine whether the first input's sig flag is SIG_ALL ()""" if not self._inputs_require_sigall(proofs): @@ -159,24 +162,27 @@ def add_witness_swap_sig_all( secrets = set([Secret.deserialize(p.secret) for p in proofs]) if not len(secrets) == 1: raise Exception("Secrets not identical") - message_to_sign = message_to_sign or nut11.sigall_message_to_sign( - proofs, outputs - ) + # Sign every known SIG_ALL message format so the transaction verifies + # on mints that have not (or have already) upgraded. Mints ignore + # signatures that do not verify and count unique pubkeys. + quote_suffix = quote_id or "" + messages_to_sign: List[Union[str, bytes]] = [ + nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id), + nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix, + nut11.sigall_message_to_sign_legacy(proofs, outputs) + quote_suffix, + ] # For P2BK proofs, use the derived blinded signing key p2bk_keys = self._derive_p2bk_signing_keys(proofs[0]) - signing_key = p2bk_keys[0] if p2bk_keys else None - signature = self.schnorr_sign_message(message_to_sign, signing_key) + signing_keys = p2bk_keys if p2bk_keys else [None] # add witness to only the first proof - signed_proofs = self.add_signatures_to_proofs([proofs[0]], [signature]) - proofs[0].witness = signed_proofs[0].witness - logger.debug( - f"SIGALL Adding witness to proof: {proofs[0].secret} with signature: {signature}" - ) - # Sign message_to_sign with remaining keys for SIG_ALL multi-key slots - if proofs[0].p2pk_e and len(p2bk_keys) > 1: - for extra_key in p2bk_keys[1:]: - extra_sig = self.schnorr_sign_message(message_to_sign, extra_key) - self.add_signatures_to_proofs([proofs[0]], [extra_sig]) + for key in signing_keys: + for message_to_sign in messages_to_sign: + signature = self.schnorr_sign_message(message_to_sign, key) + signed_proofs = self.add_signatures_to_proofs( + [proofs[0]], [signature] + ) + proofs[0].witness = signed_proofs[0].witness + logger.debug(f"SIGALL Added witness to proof: {proofs[0].secret}") except Exception: logger.error("not all secrets are the same, skipping SIG_ALL signature") return proofs @@ -210,9 +216,8 @@ def sign_proofs_inplace_melt( ) -> List[Proof]: # sign proofs if they are P2PK SIG_INPUTS proofs = self.add_witnesses_sig_inputs(proofs) - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs) + quote_id - # sign first proof if swap is SIG_ALL - proofs = self.add_witness_swap_sig_all(proofs, outputs, message_to_sign) + # sign first proof if melt is SIG_ALL + proofs = self.add_witness_swap_sig_all(proofs, outputs, quote_id=quote_id) # p2pk_e stripped AFTER signing: add_witnesses_sig_inputs derives the # blinded key via _derive_p2bk_signing_keys before we clear the field. diff --git a/tests/mint/test_mint_conditions.py b/tests/mint/test_mint_conditions.py index 2cdc90cc..93858199 100644 --- a/tests/mint/test_mint_conditions.py +++ b/tests/mint/test_mint_conditions.py @@ -55,7 +55,7 @@ def test_verify_p2pk_signatures_valid_threshold(): message = "msg-1" pub1, sig1 = _pubkey_and_sig(message) pub2, sig2 = _pubkey_and_sig(message) - assert cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1, sig2], 2) + assert cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1, sig2], 2) def test_verify_p2pk_signatures_accepts_duplicate_pubkeys(): @@ -63,7 +63,7 @@ def test_verify_p2pk_signatures_accepts_duplicate_pubkeys(): message = "msg-dup-pubkeys" pub, sig = _pubkey_and_sig(message) # Deduplication means 1 valid signature is sufficient even if pub is passed twice - assert cond._verify_p2pk_signatures(message, [pub, pub], [sig], 1) + assert cond._verify_p2pk_signatures([message.encode("utf-8")], [pub, pub], [sig], 1) def test_verify_p2pk_signatures_reject_duplicate_signatures(): @@ -71,14 +71,14 @@ def test_verify_p2pk_signatures_reject_duplicate_signatures(): message = "msg-dup-sigs" pub, sig = _pubkey_and_sig(message) with pytest.raises(TransactionError, match="signatures must be unique"): - cond._verify_p2pk_signatures(message, [pub], [sig, sig], 1) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub], [sig, sig], 1) def test_verify_p2pk_signatures_reject_missing_signatures(): cond = LedgerSpendingConditions() pub, _ = _pubkey_and_sig("msg-empty") with pytest.raises(TransactionError, match="no signatures in proof"): - cond._verify_p2pk_signatures("msg-empty", [pub], [], 1) + cond._verify_p2pk_signatures([b"msg-empty"], [pub], [], 1) def test_verify_p2pk_signatures_reject_threshold_not_met(): @@ -89,7 +89,7 @@ def test_verify_p2pk_signatures_reject_threshold_not_met(): with pytest.raises( TransactionError, match=r"not enough pubkeys \(2\) or signatures \(1\)" ): - cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1], 2) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1], 2) def test_verify_p2pk_sig_inputs_skips_for_non_input_sigflag(): @@ -313,7 +313,7 @@ def test_verify_p2pk_signatures_rejects_same_x_coord_different_prefix(): with pytest.raises( TransactionError, match="pubkeys must have unique x-coordinates" ): - cond._verify_p2pk_signatures(message, [pub1, pub2], [sig1, sig2], 2) + cond._verify_p2pk_signatures([message.encode("utf-8")], [pub1, pub2], [sig1, sig2], 2) def test_verify_p2pk_sig_inputs_handles_duplicate_pubkeys_gracefully(): @@ -329,3 +329,23 @@ def test_verify_p2pk_sig_inputs_handles_duplicate_pubkeys_gracefully(): proof = _proof(secret_str, signatures=[sig]) assert cond._verify_input_spending_conditions(proof) + + +def test_verify_sigall_spending_conditions_ignores_legacy_only_witness(): + # The legacy message format does not commit to C values or output amounts, + # so a witness carrying only a legacy signature must not satisfy the mint. + cond = LedgerSpendingConditions() + outputs = [BlindedMessage(id="ks", amount=1, B_="abcd")] + + signer = PrivateKey() + signer_pub = signer.public_key.format().hex() + fixed_secret = _secret( + kind=SecretKind.P2PK, data=signer_pub, sigflag=SigFlags.SIG_ALL + ) + proofs = [_proof(fixed_secret), _proof(fixed_secret)] + legacy_msg = nut11.sigall_message_to_sign_legacy(proofs, outputs) + signature = schnorr_sign(legacy_msg.encode("utf-8"), signer).hex() + proofs[0].witness = P2PKWitness(signatures=[signature]).model_dump_json() + + with pytest.raises(TransactionError, match="signature threshold not met"): + cond._verify_sigall_spending_conditions(proofs, outputs) diff --git a/tests/nuts/test_nut11_test_vectors.py b/tests/nuts/test_nut11_test_vectors.py index f7926a0c..fbdda698 100644 --- a/tests/nuts/test_nut11_test_vectors.py +++ b/tests/nuts/test_nut11_test_vectors.py @@ -142,9 +142,8 @@ def test_sig_all_swap_valid_single_signature(): ] proofs = [_proof_from_dict(input_dict)] outputs = _outputs_from_list(output_dicts) - msg = nut11.sigall_message_to_sign(proofs, outputs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outputs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outputs) is True def test_sig_all_swap_invalid_pubkeys_and_refund_mixed(): @@ -169,10 +168,9 @@ def test_sig_all_swap_invalid_pubkeys_and_refund_mixed(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() with pytest.raises(Exception): - cond._verify_sigall_spending_conditions(proofs, outs, msg) + cond._verify_sigall_spending_conditions(proofs, outs) def test_sig_all_swap_refund_after_locktime_valid(): @@ -192,9 +190,8 @@ def test_sig_all_swap_refund_after_locktime_valid(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outs) is True # --- SIG_ALL (HTLC) Test Vectors --- @@ -217,9 +214,8 @@ def test_sig_all_htlc_valid_pubkey(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outs) is True def test_sig_all_swap_message_example(): @@ -288,10 +284,9 @@ def test_sig_all_htlc_refund_before_locktime_invalid(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() with pytest.raises(Exception): - cond._verify_sigall_spending_conditions(proofs, outs, msg) + cond._verify_sigall_spending_conditions(proofs, outs) def test_sig_all_htlc_multisig_refund_after_locktime_valid(): @@ -311,9 +306,8 @@ def test_sig_all_htlc_multisig_refund_after_locktime_valid(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outs) is True # --- SIG_ALL (Melt) Test Vectors --- @@ -337,9 +331,8 @@ def test_sig_all_melt_valid_single_signature(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) + quote_id cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outs, quote_id=quote_id) is True def test_sig_all_melt_multisig_valid(): @@ -360,9 +353,8 @@ def test_sig_all_melt_multisig_valid(): ] proofs = [_proof_from_dict(input_dict)] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) + quote_id cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outs, quote_id=quote_id) is True def test_sig_all_swap_invalid_multiple_secrets(): @@ -400,9 +392,8 @@ def test_sig_all_swap_invalid_multiple_secrets(): ] proofs = [_proof_from_dict(i) for i in inputs] outs = _outputs_from_list(outputs) - msg = nut11.sigall_message_to_sign(proofs, outs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outs, msg) is False + assert cond._verify_sigall_spending_conditions(proofs, outs) is False def test_sig_all_swap_multisig_valid(): @@ -422,6 +413,79 @@ def test_sig_all_swap_multisig_valid(): ] proofs = [_proof_from_dict(input_dict)] outputs = _outputs_from_list(output_dicts) - msg = nut11.sigall_message_to_sign(proofs, outputs) cond = LedgerSpendingConditions() - assert cond._verify_sigall_spending_conditions(proofs, outputs, msg) is True + assert cond._verify_sigall_spending_conditions(proofs, outputs) is True + + +# --- SIG_ALL v1 (length-framed) message vectors --- +# Canonical vectors from nuts tests/11-test.md ("SIG_ALL v1 Message Vectors"), +# pinned byte-for-byte in cashu-ts and cdk too. Signing key is the well-known +# test key (privkey 0x...01). + + +def test_sig_all_v1_message_canonical_vector(): + pub = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + inputs = [ + { + "amount": 8, + "id": "009a1f293253e41e", + "secret": f'["P2PK",{{"nonce":"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f","data":"{pub}","tags":[["sigflag","SIG_ALL"]]}}]', + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "secret": f'["P2PK",{{"nonce":"16d937a29ae4e5d4a6e9f9959c4d4b9a8d6f2f7b2f0a1b3c4d5e6f708192a3b4","data":"{pub}","tags":[["sigflag","SIG_ALL"]]}}]', + "C": "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + }, + ] + outputs = [ + { + "amount": 8, + "id": "009a1f293253e41e", + "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d", + }, + { + "amount": 2, + "id": "009a1f293253e41e", + "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6", + }, + ] + quote_id = "9d745270-1405-46de-b5c5-e2762b4f5e00" + proofs = [_proof_from_dict(i) for i in inputs] + outs = _outputs_from_list(outputs) + + from hashlib import sha256 + + from cashu.core.crypto.secp import PublicKey + from cashu.core.p2pk import verify_schnorr_signature + + swap_msg = nut11.sigall_message_to_sign_v1(proofs, outs) + assert len(swap_msg) == 572 + assert swap_msg.startswith(b"Cashu_SigAllSig_v1" + b"\x00" * 4) + assert ( + sha256(swap_msg).hexdigest() + == "3fd05c896ff0d5a058f9180e577dced83539ea885f9bf6adf71f7ed084590dc2" + ) + # Pinned signature by the test key over the swap message + assert verify_schnorr_signature( + message=swap_msg, + pubkey=PublicKey(bytes.fromhex(pub)), + signature=bytes.fromhex( + "b2b821f819f12ab61d261971187d19772aaad11422f9d5f3ffda6f97de03349e0e44976b5d44e3c850a99b621045167915caf3ef5b102abe69439e36b68f89d5" + ), + ) + + melt_msg = nut11.sigall_message_to_sign_v1(proofs, outs, quote_id) + assert len(melt_msg) == 608 + assert ( + sha256(melt_msg).hexdigest() + == "0ebae3a8dbe1107a7b6392a53b6fb3dfc18b38c4ab02b4b9a460ad8829e20ce1" + ) + assert verify_schnorr_signature( + message=melt_msg, + pubkey=PublicKey(bytes.fromhex(pub)), + signature=bytes.fromhex( + "1493200b3f52cd67bdda888f67d80cf8862b4e7bd48a800828f5482218305c367f17dc3409cf89e6c795741605ccce2501f5e2b0f0e3ec54edcc7001a5863e3d" + ), + ) diff --git a/tests/wallet/test_wallet_p2pk_methods.py b/tests/wallet/test_wallet_p2pk_methods.py index 3cd7e656..48ab2dce 100644 --- a/tests/wallet/test_wallet_p2pk_methods.py +++ b/tests/wallet/test_wallet_p2pk_methods.py @@ -200,12 +200,18 @@ async def test_add_witness_swap_sig_all(wallet1: Wallet): # Verify the first proof has a witness assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - assert len(witness.signatures) == 1 + # One signature per supported SIG_ALL message format (v1, current, legacy) + assert len(witness.signatures) == 3 - # Verify the signature includes both inputs and outputs - message_to_sign = nut11.sigall_message_to_sign(proofs, outputs) - signature = wallet1.schnorr_sign_message(message_to_sign) - assert witness.signatures[0] == signature + # Verify the signatures cover all formats over both inputs and outputs + expected = [ + wallet1.schnorr_sign_message(nut11.sigall_message_to_sign_v1(proofs, outputs)), + wallet1.schnorr_sign_message(nut11.sigall_message_to_sign(proofs, outputs)), + wallet1.schnorr_sign_message( + nut11.sigall_message_to_sign_legacy(proofs, outputs) + ), + ] + assert witness.signatures == expected @pytest.mark.asyncio @@ -229,10 +235,10 @@ async def test_sign_proofs_inplace_swap(wallet1: Wallet): # Sign proofs signed_proofs = wallet1.sign_proofs_inplace_swap(proofs, outputs) - # Verify the first proof has a witness with a signature + # Verify the first proof has a witness with a signature per format assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - assert len(witness.signatures) == 1 + assert len(witness.signatures) == 3 @pytest.mark.asyncio From 011159499d58d7119eec0c7f5cc00f2ae726747c Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Fri, 17 Jul 2026 23:29:11 +0100 Subject: [PATCH 2/5] fix: type the SIG_ALL signing key list for mypy --- cashu/wallet/p2pk.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index e329a2ac..b92b27db 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -171,9 +171,12 @@ def add_witness_swap_sig_all( nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix, nut11.sigall_message_to_sign_legacy(proofs, outputs) + quote_suffix, ] - # For P2BK proofs, use the derived blinded signing key + # For P2BK proofs, use the derived blinded signing keys; None falls + # back to the wallet's private key in schnorr_sign_message p2bk_keys = self._derive_p2bk_signing_keys(proofs[0]) - signing_keys = p2bk_keys if p2bk_keys else [None] + signing_keys: List[Optional[PrivateKey]] = ( + [*p2bk_keys] if p2bk_keys else [None] + ) # add witness to only the first proof for key in signing_keys: for message_to_sign in messages_to_sign: From f3e3551fcec31891379aea5607a9c9c3d8e0b8ae Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 11 Aug 2026 22:08:43 +0100 Subject: [PATCH 3/5] refactor: sign only the accepted SIG_ALL message formats Wallets sign v1 and current; the pre-0.21 message (secrets then B_ fields) is no longer produced, as it does not commit to C values or output amounts (cashubtc/nuts#404). Mint verification is unchanged. --- cashu/core/nuts/nut11.py | 11 ----------- cashu/mint/conditions.py | 4 ++-- cashu/wallet/p2pk.py | 7 +++---- tests/mint/test_mint_conditions.py | 3 ++- tests/wallet/test_wallet_p2pk_methods.py | 9 +++------ 5 files changed, 10 insertions(+), 24 deletions(-) diff --git a/cashu/core/nuts/nut11.py b/cashu/core/nuts/nut11.py index 5d6367a3..73ba60a1 100644 --- a/cashu/core/nuts/nut11.py +++ b/cashu/core/nuts/nut11.py @@ -44,14 +44,3 @@ def sigall_message_to_sign(proofs: List[Proof], outputs: List[BlindedMessage]) - message += "".join([str(o.amount) + o.B_ for o in outputs]) return message - - -def sigall_message_to_sign_legacy( - proofs: List[Proof], outputs: List[BlindedMessage] -) -> str: - """ - SIG_ALL message as verified by releases <= 0.20.2: secrets then B_ fields. - - Kept so upgraded mints keep accepting witnesses from older wallets. - """ - return "".join([p.secret for p in proofs]) + "".join([o.B_ for o in outputs]) diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index f632a0af..2fc2f688 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -356,8 +356,8 @@ def _verify_sigall_spending_conditions( # All SIG_ALL message formats this mint accepts. Signatures that do not # verify under any of them are ignored (NUT-11 signature validation); # only unique pubkeys with valid signatures count towards the threshold. - # The legacy format is signed by wallets for older mints but is never - # accepted here: it does not commit to C values or output amounts. + # The pre-0.21 format (secrets then B_ fields) is not accepted: it does + # not commit to C values or output amounts. quote_suffix = quote_id or "" messages_to_sign = [ nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id), diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index b92b27db..768938fa 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -162,14 +162,13 @@ def add_witness_swap_sig_all( secrets = set([Secret.deserialize(p.secret) for p in proofs]) if not len(secrets) == 1: raise Exception("Secrets not identical") - # Sign every known SIG_ALL message format so the transaction verifies - # on mints that have not (or have already) upgraded. Mints ignore - # signatures that do not verify and count unique pubkeys. + # Sign every accepted SIG_ALL message format so the transaction + # verifies on mints that have not (or have already) upgraded. Mints + # ignore signatures that do not verify and count unique pubkeys. quote_suffix = quote_id or "" messages_to_sign: List[Union[str, bytes]] = [ nut11.sigall_message_to_sign_v1(proofs, outputs, quote_id), nut11.sigall_message_to_sign(proofs, outputs) + quote_suffix, - nut11.sigall_message_to_sign_legacy(proofs, outputs) + quote_suffix, ] # For P2BK proofs, use the derived blinded signing keys; None falls # back to the wallet's private key in schnorr_sign_message diff --git a/tests/mint/test_mint_conditions.py b/tests/mint/test_mint_conditions.py index 93858199..e52b85c5 100644 --- a/tests/mint/test_mint_conditions.py +++ b/tests/mint/test_mint_conditions.py @@ -343,7 +343,8 @@ def test_verify_sigall_spending_conditions_ignores_legacy_only_witness(): kind=SecretKind.P2PK, data=signer_pub, sigflag=SigFlags.SIG_ALL ) proofs = [_proof(fixed_secret), _proof(fixed_secret)] - legacy_msg = nut11.sigall_message_to_sign_legacy(proofs, outputs) + # Pre-0.21 message format: secrets then B_ fields + legacy_msg = "".join(p.secret for p in proofs) + "".join(o.B_ for o in outputs) signature = schnorr_sign(legacy_msg.encode("utf-8"), signer).hex() proofs[0].witness = P2PKWitness(signatures=[signature]).model_dump_json() diff --git a/tests/wallet/test_wallet_p2pk_methods.py b/tests/wallet/test_wallet_p2pk_methods.py index 48ab2dce..31eccf4a 100644 --- a/tests/wallet/test_wallet_p2pk_methods.py +++ b/tests/wallet/test_wallet_p2pk_methods.py @@ -200,16 +200,13 @@ async def test_add_witness_swap_sig_all(wallet1: Wallet): # Verify the first proof has a witness assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - # One signature per supported SIG_ALL message format (v1, current, legacy) - assert len(witness.signatures) == 3 + # One signature per supported SIG_ALL message format (v1, current) + assert len(witness.signatures) == 2 # Verify the signatures cover all formats over both inputs and outputs expected = [ wallet1.schnorr_sign_message(nut11.sigall_message_to_sign_v1(proofs, outputs)), wallet1.schnorr_sign_message(nut11.sigall_message_to_sign(proofs, outputs)), - wallet1.schnorr_sign_message( - nut11.sigall_message_to_sign_legacy(proofs, outputs) - ), ] assert witness.signatures == expected @@ -238,7 +235,7 @@ async def test_sign_proofs_inplace_swap(wallet1: Wallet): # Verify the first proof has a witness with a signature per format assert signed_proofs[0].witness is not None witness = P2PKWitness.from_witness(signed_proofs[0].witness) - assert len(witness.signatures) == 3 + assert len(witness.signatures) == 2 @pytest.mark.asyncio From c0af22b7993ecc9074dd11c225c3d43e0d5c65ff Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 11 Aug 2026 23:06:10 +0100 Subject: [PATCH 4/5] chore: re-trigger CI From 7c9ec52f6ec921b4c6d6bbc9837abdef5ca713c2 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 11 Aug 2026 23:27:31 +0100 Subject: [PATCH 5/5] fix: repair two artifacts of the main merge Restore cashu/mint/ledger.py byte-for-byte to main: the merge resolution normalized its mixed line endings, which made the whole file show as changed. Give the transaction unit test outputs even-length hex B_ values: the v1 SIG_ALL message decodes them to raw bytes. --- cashu/mint/ledger.py | 2990 ++++++++--------- ...st_spending_conditions_unit_transaction.py | 3 +- 2 files changed, 1497 insertions(+), 1496 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index f322becd..a04edf88 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,1244 +1,1244 @@ -import asyncio -import time -from typing import Dict, List, Mapping, Optional, Tuple - -import bolt11 -from loguru import logger - -from ..core.base import ( - DLEQ, - Amount, - BlindedMessage, - BlindedSignature, - MeltQuote, - MeltQuoteState, - Method, - MintKeyset, - MintQuote, - MintQuoteState, - Proof, - Unit, -) -from ..core.crypto import b_dhke -from ..core.crypto.aes import AESCipher -from ..core.crypto.keys import ( - derive_pubkey, - generate_uuid_v7, -) -from ..core.crypto.secp import PrivateKey, PublicKey -from ..core.db import Connection, Database -from ..core.errors import ( - BatchDuplicateQuotesError, - CashuError, - LightningError, - LightningPaymentFailedError, - NotAllowedError, - QuoteAlreadyIssuedError, - QuoteNotPaidError, - QuoteSignatureInvalidError, - TransactionAmountExceedsLimitError, - TransactionError, -) -from ..core.helpers import sum_proofs -from ..core.models import ( - PostMeltQuoteRequest, - PostMeltQuoteResponse, - PostMintBatchRequest, - PostMintQuoteCheckRequest, - PostMintQuoteRequest, -) -from ..core.settings import settings -from ..core.split import amount_split -from ..lightning.base import ( - InvoiceResponse, - LightningBackend, - PaymentQuoteResponse, - PaymentResponse, - PaymentResult, - PaymentStatus, -) -from ..mint.crud import LedgerCrudSqlite -from .conditions import LedgerSpendingConditions -from .db.read import DbReadHelper -from .db.write import DbWriteHelper -from .events.events import LedgerEventManager -from .features import LedgerFeatures -from .keysets import LedgerKeysets -from .tasks import LedgerTasks -from .verification import LedgerVerification -from .watchdog import LedgerWatchdog - - -class Ledger( - LedgerVerification, - LedgerSpendingConditions, - LedgerTasks, - LedgerFeatures, - LedgerWatchdog, - LedgerKeysets, -): - backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - keysets: Dict[str, MintKeyset] = {} - events = LedgerEventManager() - db: Database - db_read: DbReadHelper - db_write: DbWriteHelper - invoice_listener_tasks: List[asyncio.Task] = [] - watchdog_tasks: List[asyncio.Task] = [] - disable_melt: bool = False - pubkey: PublicKey - - def __init__( - self, - *, - db: Database, - seed: str, - derivation_path="", - amounts: Optional[List[int]] = None, - backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, - seed_decryption_key: Optional[str] = None, - crud=LedgerCrudSqlite(), - ) -> None: - self.keysets: Dict[str, MintKeyset] = {} - self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - self.events = LedgerEventManager() - self.db_read: DbReadHelper - self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks - self.invoice_listener_tasks: List[asyncio.Task] = [] - self.watchdog_tasks: List[asyncio.Task] = [] - self.regular_tasks: List[asyncio.Task] = [] - - if not seed: - raise Exception("seed not set") - - # decrypt seed if seed_decryption_key is set - try: - self.seed = ( - AESCipher(seed_decryption_key).decrypt(seed) - if seed_decryption_key - else seed - ) - except Exception as e: - raise Exception( - f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" - ) - self.derivation_path = derivation_path - - self.db = db - self.crud = crud - - if backends: - self.backends = backends - - if amounts: - self.amounts = amounts - else: - self.amounts = [2**n for n in range(settings.max_order)] - - self.pubkey = derive_pubkey(self.seed) - self.db_read = DbReadHelper(self.db, self.crud) - self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) - - LedgerWatchdog.__init__(self) - - # ------- STARTUP ------- - - async def startup_ledger(self) -> None: - await self._startup_keysets() - await self._check_backends() - self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) - self.invoice_listener_tasks = await self.dispatch_listeners() - if settings.mint_watchdog_enabled: - self.watchdog_tasks = await self.dispatch_watchdogs() - - async def _startup_keysets(self) -> None: - await self.init_keysets() - for derivation_path in settings.mint_derivation_path_list: - derivation_path = self.maybe_update_derivation_path(derivation_path) - await self.activate_keyset(derivation_path=derivation_path) - - async def _run_regular_tasks(self) -> None: - """ - Runs periodic ledger maintenance tasks forever. - This function intentionally loops forever and is designed to be scheduled as a Task. - """ - logger.info("Starting ledger regular tasks loop") - while True: - try: - await self._check_pending_proofs_and_melt_quotes() - await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) - except Exception as e: - logger.error(f"Ledger regular task failed: {e}") - await asyncio.sleep(60) - - async def _check_backends(self) -> None: - for method in self.backends: - for unit in self.backends[method]: - logger.info( - f"Using {self.backends[method][unit].__class__.__name__} backend for" - f" method: '{method.name}' and unit: '{unit.name}'" - ) - status = await self.backends[method][unit].status() - if status.error_message: - logger.error( - "The backend for" - f" {self.backends[method][unit].__class__.__name__} isn't" - f" working properly: '{status.error_message}'" - ) - exit(1) - logger.info(f"Backend balance: {status.balance}") - - logger.info(f"Data dir: {settings.cashu_dir}") - - async def shutdown_ledger(self) -> None: - logger.debug("Shutting down invoice listeners") - for task in self.invoice_listener_tasks: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - for task in self.watchdog_tasks: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - logger.debug("Shutting down regular tasks") - for task in self.regular_tasks: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - logger.debug("Disconnecting from database") - await self.db.engine.dispose() - - 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 - pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( - db=self.db - ) - if not pending_melt_quotes: - return - 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}") - - # ------- ECASH ------- - - async def _generate_change_promises( - self, - fee_provided: int, - fee_paid: int, - outputs: Optional[List[BlindedMessage]], - melt_id: Optional[str] = None, - keyset: Optional[MintKeyset] = None, - ) -> List[BlindedSignature]: - """Generates a set of new promises (blinded signatures) from a set of blank outputs - (outputs with no or ignored amount) by looking at the difference between the Lightning - fee reserve provided by the wallet and the actual Lightning fee paid by the mint. - - If there is a positive difference, produces maximum `n_return_outputs` new outputs - with values close or equal to the fee difference. If the given number of `outputs` matches - the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. - Otherwise, a smaller amount will be returned. - - Args: - input_amount (int): Amount of the proofs provided by the client. - output_amount (int): Amount of the melt request to be paid. - output_fee_paid (int): Actually paid melt network fees. - outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. - - Raises: - Exception: Output validation failed. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - # we make sure that the fee is positive - overpaid_fee = fee_provided - fee_paid - - if overpaid_fee <= 0 or outputs is None: - if overpaid_fee < 0: - logger.debug( - f"No change to return: backend fee {fee_paid} exceeds wallet's " - f"fee reserve {fee_provided} by {-overpaid_fee}." - ) - if melt_id and outputs is not None: - await self.crud.delete_blinded_messages_melt_id( - melt_id=melt_id, db=self.db - ) - return [] - - logger.debug( - f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " - f"Returning difference: {overpaid_fee}." - ) - - return_amounts = amount_split(overpaid_fee) - - # We return at most as many outputs as were provided or as many as are - # required to pay back the overpaid fee. - n_return_outputs = min(len(outputs), len(return_amounts)) - - # we only need as many outputs as we have change to return - outputs = outputs[:n_return_outputs] - - # we sort the return_amounts in descending order so we only - # take the largest values in the next step - return_amounts_sorted = sorted(return_amounts, reverse=True) - # we need to imprint these amounts into the blanket outputs - for i in range(len(outputs)): - outputs[i].amount = return_amounts_sorted[i] # type: ignore - if not self._verify_no_duplicate_outputs(outputs): - raise TransactionError("duplicate promises.") - return_promises = await self._sign_blinded_messages(outputs) - # delete remaining unsigned blank outputs from db - if melt_id: - await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) - return return_promises - - # ------- TRANSACTIONS ------- - - async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: - """Creates a mint quote and stores it in the database. - - Args: - quote_request (PostMintQuoteRequest): Mint quote request. - - Raises: - Exception: Quote creation failed. - - Returns: - MintQuote: Mint quote object. - """ - logger.trace("called request_mint") - if not quote_request.amount > 0: - raise TransactionError("amount must be positive") - if ( - settings.mint_max_mint_bolt11_sat - and quote_request.amount > settings.mint_max_mint_bolt11_sat - ): - raise TransactionAmountExceedsLimitError( - f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." - ) - if settings.mint_bolt11_disable_mint: - raise NotAllowedError("Minting with bolt11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - quote_request.unit, Method.bolt11.name - ) - - if ( - quote_request.description - and not self.backends[method][unit].supports_description - ): - raise NotAllowedError("Backend does not support descriptions.") - - # Check maximum balance. - # TODO: Allow setting MINT_MAX_BALANCE per unit - if settings.mint_max_balance: - balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) - if balance + quote_request.amount > settings.mint_max_balance: - raise NotAllowedError("Mint has reached maximum balance.") - - logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") - invoice_response: InvoiceResponse = await self.backends[method][ - unit - ].create_invoice( - amount=Amount(unit=unit, amount=quote_request.amount), - memo=quote_request.description, - ) - logger.trace( - f"got invoice {invoice_response.payment_request} with checking id" - f" {invoice_response.checking_id}" - ) - - if not (invoice_response.payment_request and invoice_response.checking_id): - raise LightningError("could not fetch bolt11 payment request from backend") - - # get invoice expiry time - invoice_obj = bolt11.decode(invoice_response.payment_request) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = invoice_response.payment_request.lower() - - now = int(time.time()) - expiry = None - if settings.mint_quote_ttl is not None: - expiry = now + settings.mint_quote_ttl - elif invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MintQuote( - quote=generate_uuid_v7(), - method=method.name, - request=request, - checking_id=invoice_response.checking_id, - unit=quote_request.unit, - amount=quote_request.amount, - state=MintQuoteState.unpaid, - created_time=now, - expiry=expiry, - pubkey=quote_request.pubkey, - ) - await self.crud.store_mint_quote(quote=quote, db=self.db) - await self.events.submit(quote) - - return quote - - async def get_mint_quote(self, quote_id: str) -> MintQuote: - """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. - - Args: - quote_id (str): ID of the mint quote. - - Raises: - Exception: Quote not found. - - Returns: - MintQuote: Mint quote object. - """ - quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) - if not quote: - raise Exception("quote not found") - - unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) - - if quote.unpaid: - if not quote.checking_id: - raise CashuError("quote has no checking id") - - now = int(time.time()) - updated = await self.crud.try_update_mint_quote_last_checked( - quote_id=quote_id, - last_checked=now, - rate_limit=settings.mint_quote_backend_check_rate_limit, - db=self.db, - ) - if not updated: - logger.trace( - f"Lightning: checking invoice {quote.checking_id} skipped due to rate limit" - ) - return quote - quote.last_checked = now - - logger.trace(f"Lightning: checking invoice {quote.checking_id}") - status: PaymentStatus = await self.backends[method][ - unit - ].get_invoice_status(quote.checking_id) - if status.settled: - # change state to paid in one transaction, it could have been marked paid - # by the invoice listener in the mean time - async with self.db.get_connection( - lock_table="mint_quotes", - lock_select_statement="quote = :quote", - lock_parameters={"quote": quote_id}, - ) as conn: - quote = await self.crud.get_mint_quote( - quote_id=quote_id, db=self.db, conn=conn - ) - if not quote: - raise Exception("quote not found") - if quote.unpaid: - logger.trace(f"Setting quote {quote_id} as paid") - quote.state = MintQuoteState.paid - quote.paid_time = now - quote.last_checked = now - quote.updated_at = now - await self.crud.update_mint_quote( - quote=quote, db=self.db, conn=conn - ) - await self.events.submit(quote) - - return quote - - async def mint_quote_check( - self, payload: PostMintQuoteCheckRequest - ) -> List[MintQuote]: - """Batch check mint quotes. - - Args: - payload (PostMintQuoteCheckRequest): Request payload containing quote IDs. - - Returns: - List[MintQuote]: List of mint quotes matching the request. - """ - quotes: List[MintQuote] = [] - for quote_id in payload.quotes: - quote = await self.get_mint_quote(quote_id) - if not quote: - raise TransactionError(f"quote {quote_id} not found") - quotes.append(quote) - return quotes - - async def mint( - self, - *, - outputs: List[BlindedMessage], - quote_id: str, - signature: Optional[str] = None, - ) -> List[BlindedSignature]: - """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. - - Args: - outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. - quote_id (str): Mint quote id. - witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. - - Raises: - Exception: Validation of outputs failed. - Exception: Quote not paid. - Exception: Quote already issued. - Exception: Quote expired. - Exception: Amount to mint does not match quote amount. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - await self._verify_outputs(outputs) - sum_amount_outputs = sum([b.amount for b in outputs]) - # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset - output_unit = self.keysets[outputs[0].id].unit - - quote = await self.get_mint_quote(quote_id) - if quote.pending: - raise TransactionError("Mint quote already pending.") - if quote.issued: - raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - previous_state = quote.state - await self.db_write._set_mint_quote_pending(quote_id=quote_id) - try: - if not quote.unit == output_unit.name: - raise TransactionError("quote unit does not match output unit") - if not quote.amount == sum_amount_outputs: - raise TransactionError("amount to mint does not match quote amount") - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - if not self._verify_mint_quote_witness(quote, outputs, signature): - raise QuoteSignatureInvalidError() - await self._store_blinded_messages(outputs, mint_id=quote_id) - promises = await self._sign_blinded_messages(outputs) - except Exception as e: - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=previous_state - ) - raise e - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=MintQuoteState.issued - ) - - return promises - - async def mint_batch( - self, - payload: PostMintBatchRequest, - ) -> List[BlindedSignature]: - """Batch mint tokens. - - Args: - payload (PostMintBatchRequest): Request payload containing quote IDs, outputs, and signatures. - - Raises: - Exception: Validation of outputs failed. - Exception: Quote not paid. - Exception: Quote already issued. - Exception: Amount to mint does not match quote amount. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - if not payload.quotes: - raise TransactionError("batch must not be empty") - - if len(set(payload.quotes)) != len(payload.quotes): - raise BatchDuplicateQuotesError() - - if payload.signatures and len(payload.signatures) != len(payload.quotes): - raise TransactionError("signatures length must match quotes length") - - await self._verify_outputs(payload.outputs) - # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset - output_unit = self.keysets[payload.outputs[0].id].unit - sum_amount_outputs = sum([b.amount for b in payload.outputs]) - - quotes: List[MintQuote] = [] - for quote_id in payload.quotes: - quote = await self.get_mint_quote(quote_id) - if not quote: - raise TransactionError(f"quote {quote_id} not found") - quotes.append(quote) - - # Check payment method consistency - methods = set([q.method for q in quotes]) - if len(methods) > 1: - raise TransactionError("all quotes must have the same method") - if Method.bolt11.name not in methods: - raise TransactionError("all quotes must be of bolt11 method") - - # Check currency unit consistency - units = set([q.unit for q in quotes]) - if len(units) > 1: - raise TransactionError("all quotes must have the same unit") - if units.pop() != output_unit.name: - raise TransactionError("quote unit does not match output unit") - - for quote in quotes: - if quote.pending: - raise TransactionError("mint quote already pending") - if quote.issued: - raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - # Check amount balance - if payload.quote_amounts: - if len(payload.quote_amounts) != len(quotes): - raise TransactionError("quote_amounts length must match quotes length") - for i, quote in enumerate(quotes): - if ( - quote.method == Method.bolt11.name - and payload.quote_amounts[i] != quote.amount - ): - raise TransactionError( - f"quote amount {payload.quote_amounts[i]} does not match quote {quote.quote} amount {quote.amount}" - ) - if payload.quote_amounts[i] > quote.amount: - raise TransactionError( - f"quote amount {payload.quote_amounts[i]} exceeds quote {quote.quote} amount {quote.amount}" - ) - - quote_amounts = payload.quote_amounts or [q.amount for q in quotes] - if Method.bolt11.name in methods: - if sum(quote_amounts) != sum_amount_outputs: - raise TransactionError( - "amount to mint does not match quote amounts sum" - ) - else: - if sum_amount_outputs > sum(quote_amounts): - raise TransactionError("amount to mint exceeds quote amounts sum") - - # Signature validation (NUT-20) - for i, quote in enumerate(quotes): - sig = payload.signatures[i] if payload.signatures else None - - if not quote.pubkey and sig: - raise QuoteSignatureInvalidError() - - # The spec says msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) - # This logic is inside self._verify_mint_quote_witness, let's reuse it. - if not self._verify_mint_quote_witness(quote, payload.outputs, sig): - raise QuoteSignatureInvalidError() - - # Set all quotes to pending - quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) - - try: - for quote in quotes: - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - - # Store all blinded messages - await self._store_blinded_messages( - payload.outputs, mint_id=payload.quotes[0] - ) - promises = await self._sign_blinded_messages(payload.outputs) - - except Exception as e: - # Revert pending status - await self.db_write._unset_mint_quotes_pending( - quote_ids=payload.quotes, state=MintQuoteState.paid - ) - raise e - - # Set all quotes to issued - await self.db_write._unset_mint_quotes_pending( - quote_ids=payload.quotes, state=MintQuoteState.issued - ) - - return promises - - def create_internal_melt_quote( - self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest - ) -> PaymentQuoteResponse: - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - if not request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.unit == melt_quote.unit: - raise TransactionError("units do not match") - if not mint_quote.method == method.name: - raise TransactionError("methods do not match") - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - if not mint_quote.unpaid: - raise TransactionError("mint quote is not unpaid") - - if not mint_quote.checking_id: - raise TransactionError("mint quote has no checking id") - if melt_quote.is_mpp: - raise TransactionError("internal payments do not support mpp") - - internal_fee = Amount(unit, 0) # no internal fees - amount = Amount(unit, mint_quote.amount) - - payment_quote = PaymentQuoteResponse( - checking_id=mint_quote.checking_id, - amount=amount, - fee=internal_fee, - ) - logger.info( - f"Issuing internal melt quote: {request} ->" - f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" - ) - - return payment_quote - - def validate_payment_quote( - self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse - ): - # payment quote validation - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - if not payment_quote.checking_id: - raise Exception("quote has no checking id") - # verify that payment quote amount is as expected - if ( - melt_quote.is_mpp - and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount - ): - logger.error( - f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" - ) - raise TransactionError("quote amount not as requested") - # make sure the backend returned the amount with a correct unit - if not payment_quote.amount.unit == unit: - raise TransactionError("payment quote amount units do not match") - # fee from the backend must be in the same unit as the amount - if not payment_quote.fee.unit == unit: - raise TransactionError("payment quote fee units do not match") - - async def melt_quote( - self, melt_quote: PostMeltQuoteRequest - ) -> PostMeltQuoteResponse: - """Creates a melt quote and stores it in the database. - - Args: - melt_quote (PostMeltQuoteRequest): Melt quote request. - - Raises: - Exception: Quote invalid. - Exception: Quote already paid. - Exception: Quote already issued. - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - if settings.mint_bolt11_disable_melt: - raise NotAllowedError("Melting with bol11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - # check if there is a mint quote with the same payment request - # so that we would be able to handle the transaction internally - # and therefore respond with internal transaction fees (0 for now) - mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) - if mint_quote and mint_quote.unit == melt_quote.unit: - # check if the melt quote is partial and error if it is. - # it's just not possible to handle this case - if melt_quote.is_mpp: - raise TransactionError("internal mpp not allowed.") - payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) - else: - # not internal - # verify that the backend supports mpp if the quote request has an amount - if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: - raise TransactionError("backend does not support mpp.") - # get payment quote by backend - payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote=melt_quote - ) - - self.validate_payment_quote(melt_quote, payment_quote) - - # verify that the amount of the proofs is not larger than the maximum allowed - if ( - settings.mint_max_melt_bolt11_sat - and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat - ): - raise NotAllowedError( - f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." - ) - - # We assume that the request is a bolt11 invoice, this works since we - # support only the bol11 method for now. - invoice_obj = bolt11.decode(melt_quote.request) - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - # we set the expiry of this quote to the expiry of the bolt11 invoice - now = int(time.time()) - expiry = None - if settings.melt_quote_ttl is not None: - expiry = now + settings.melt_quote_ttl - elif invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MeltQuote( - quote=generate_uuid_v7(), - method=method.name, - request=request, - checking_id=payment_quote.checking_id, - unit=unit.name, - amount=payment_quote.amount.to(unit).amount, - state=MeltQuoteState.unpaid, - fee_reserve=payment_quote.fee.to(unit).amount, - created_time=now, - expiry=expiry, - ) - await self.db_write._store_melt_quote(quote) - await self.events.submit(quote) - - return PostMeltQuoteResponse( - quote=quote.quote, - amount=quote.amount, - unit=quote.unit, - method=quote.method, - request=quote.request, - fee_reserve=quote.fee_reserve, - state=quote.state.value, - expiry=quote.expiry, - ) - - 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 rollback_unknown is set, do the same for unknown states as for failed states. - - Args: - quote_id (str): ID of the melt quote. - rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. - - Raises: - Exception: Quote not found. - - Returns: - MeltQuote: Melt quote object. - """ - melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) - if not melt_quote: - raise Exception("quote not found") - - if melt_quote.change: - change_outputs = await self.crud.get_blinded_messages_melt_id( - melt_id=quote_id, db=self.db, signed=True - ) - if len(change_outputs) != len(melt_quote.change): - raise TransactionError("could not reconstruct melt change promises") - for output, promise in zip(change_outputs, melt_quote.change): - promise.dleq = self._generate_dleq(output, promise) - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - - # we only check the state with the backend if there is no associated internal - # mint quote for this melt quote - mint_quote = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - - is_internal = mint_quote is not None and mint_quote.unit == melt_quote.unit - - if melt_quote.pending and not is_internal: - logger.debug( - "Lightning: checking outgoing Lightning payment" - f" {melt_quote.checking_id}" - ) - status: PaymentStatus = await self.backends[method][ - unit - ].get_payment_status(melt_quote.checking_id) - logger.debug(f"State: {status.result}") - if status.settled: - logger.debug(f"Setting quote {quote_id} as paid") - melt_quote.state = MeltQuoteState.paid - if status.fee: - melt_quote.fee_paid = status.fee.to(unit, round="up").amount - if status.preimage: - melt_quote.payment_preimage = status.preimage - melt_quote.paid_time = int(time.time()) - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - - # change to compensate wallet for overpaid fees - melt_outputs = await self.crud.get_blinded_messages_melt_id( - melt_id=quote_id, db=self.db - ) - if melt_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_outputs, - melt_id=quote_id, - keyset=self.keysets[melt_outputs[0].id], - ) - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in pending_proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = ( - await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - ) - - if status.failed or (rollback_unknown and status.unknown): - logger.debug(f"Setting quote {quote_id} as unpaid") - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - - return melt_quote - - async def melt_mint_settle_internally( - self, melt_quote: MeltQuote, proofs: List[Proof] - ) -> MeltQuote: - """Settles a melt quote internally if there is a mint quote with the same payment request. - - `proofs` are passed to determine the ecash input transaction fees for this melt quote. - - Args: - melt_quote (MeltQuote): Melt quote to settle. - proofs (List[Proof]): Proofs provided for paying the Lightning invoice. - - Raises: - Exception: Melt quote already paid. - Exception: Melt quote already issued. - - Returns: - MeltQuote: Settled melt quote. - """ - # first we check if there is a mint quote with the same payment request - # so that we can handle the transaction internally without the backend - mint_quote = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - if not mint_quote: - return melt_quote - - # settle externally if units are different - if mint_quote.unit != melt_quote.unit: - return melt_quote - - # we settle the transaction internally - if melt_quote.state == MeltQuoteState.paid: - raise TransactionError("melt quote already paid") - - # verify amounts from bolt11 invoice - bolt11_request = melt_quote.request - invoice_obj = bolt11.decode(bolt11_request) - - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - if not mint_quote.amount == melt_quote.amount: - raise TransactionError("amounts do not match") - if not bolt11_request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.method == melt_quote.method: - raise TransactionError("methods do not match") - - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - - if mint_quote.state != MintQuoteState.unpaid: - raise TransactionError("mint quote is not unpaid") - - logger.info( - f"Settling bolt11 payment internally: {melt_quote.quote} ->" - f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" - ) - - melt_quote.fee_paid = 0 # no internal fees - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - - mint_quote.state = MintQuoteState.paid - mint_quote.paid_time = melt_quote.paid_time - mint_quote.updated_at = melt_quote.paid_time - - async with self.db.get_connection() as conn: - await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) - await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) - - await self.events.submit(melt_quote) - await self.events.submit(mint_quote) - - return melt_quote - - async def async_melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> PostMeltQuoteResponse: - """Invalidates proofs and pays a Lightning invoice asynchronously. - - Locks the melt quote and proofs as PENDING before returning, then runs - the Lightning payment in the background. - - Args: - proofs (List[Proof]): Proofs provided for paying the Lightning invoice - quote (str): ID of the melt quote. - outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. - - Returns: - PostMeltQuoteResponse: Melt quote response after PENDING is committed. - """ - melt_quote = await self._prepare_melt( - proofs=proofs, quote=quote, outputs=outputs - ) - - async def melt_task(): - try: - await self._execute_melt_payment(melt_quote, proofs, outputs) - except Exception as e: - logger.error(f"Error in background melt task: {e}") - - asyncio.create_task(melt_task()) - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - async def melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> PostMeltQuoteResponse: - """Invalidates proofs and pays a Lightning invoice. - - Args: - proofs (List[Proof]): Proofs provided for paying the Lightning invoice - quote (str): ID of the melt quote. - outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. - - Raises: - e: Lightning payment unsuccessful - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - melt_quote = await self._prepare_melt( - proofs=proofs, quote=quote, outputs=outputs - ) - return await self._execute_melt_payment(melt_quote, proofs, outputs) - - async def _prepare_melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> MeltQuote: - """Validates a melt request and durably sets the quote and proofs to pending.""" - # make sure we're allowed to melt - if self.disable_melt and settings.mint_disable_melt_on_error: - raise NotAllowedError("Melt is disabled. Please contact the operator.") - - # get melt quote and check if it was already paid - melt_quote = await self.get_melt_quote(quote_id=quote) - if not melt_quote.unpaid: - raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") - - unit, _ = self._verify_and_get_unit_method(melt_quote.unit, melt_quote.method) - - # make sure that the proofs are in the same unit as the quote - self._verify_proofs_unit(proofs, expected_unit=unit) - - await self._verify_transaction( - proofs=proofs, - outputs=outputs, - quote=quote, - skip_output_amount_check=True, - expected_output_unit=unit, - verify_input_output_balance=False, - ) - - # verify that the amount of the input proofs is equal to the amount of the quote - total_provided = sum_proofs(proofs) - input_fees = self.get_fees_for_proofs(proofs) - total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees - # we need the fees specifically for lightning to return the overpaid fees - fee_reserve_provided = total_provided - melt_quote.amount - input_fees - if total_provided < total_needed: - raise TransactionError( - f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" - ) - if fee_reserve_provided < melt_quote.fee_reserve: - raise TransactionError( - f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" - ) - - # set quote and proofs to pending to avoid race conditions - melt_quote = await self.db_write.verify_and_set_melt_quote_pending( - quote=melt_quote, proofs=proofs, keysets=self.keysets - ) - - try: - # store the change outputs - if outputs: - await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) - except Exception as e: - logger.debug(f"Melt failed before backend payment: {e}") - await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - raise e - - return melt_quote - - async def _execute_melt_payment( - self, - melt_quote: MeltQuote, - proofs: List[Proof], - outputs: Optional[List[BlindedMessage]], - ) -> PostMeltQuoteResponse: - """Pays the Lightning invoice for a pending melt quote and finalizes it.""" - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - input_fees = self.get_fees_for_proofs(proofs) - fee_reserve_provided = sum_proofs(proofs) - melt_quote.amount - input_fees - - try: - # if the melt corresponds to an internal mint, mark both as paid - melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) - except Exception as e: - logger.debug(f"Melt failed before backend payment: {e}") - await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - raise e - - # quote not paid yet (not internal), pay it with the backend - if melt_quote.state == MeltQuoteState.pending: - logger.debug(f"Lightning: pay invoice {melt_quote.request}") - try: - fee_limit_msat = ( - Amount(Unit[melt_quote.unit], melt_quote.fee_reserve) - .to(Unit.msat) - .amount - ) - payment = await self.backends[method][unit].pay_invoice( - melt_quote, fee_limit_msat - ) - logger.debug( - f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," - f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" - ) - if ( - payment.checking_id - and payment.checking_id != melt_quote.checking_id - ): - logger.warning( - f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." - ) - melt_quote.checking_id = payment.checking_id - await self.crud.update_melt_quote(quote=melt_quote, db=self.db) - except Exception as e: - logger.error(f"Exception during pay_invoice: {e}") - payment = PaymentResponse( - result=PaymentResult.UNKNOWN, - error_message=str(e), - ) - - match payment.result: - case PaymentResult.FAILED | PaymentResult.UNKNOWN: - # explicitly check payment status for failed or unknown payment states - checking_id = payment.checking_id or melt_quote.checking_id - logger.debug( - f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." - ) - try: - status = await self.backends[method][unit].get_payment_status( - checking_id - ) - except Exception as e: - # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. - logger.error( - f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - match status.result: +import asyncio +import time +from typing import Dict, List, Mapping, Optional, Tuple + +import bolt11 +from loguru import logger + +from ..core.base import ( + DLEQ, + Amount, + BlindedMessage, + BlindedSignature, + MeltQuote, + MeltQuoteState, + Method, + MintKeyset, + MintQuote, + MintQuoteState, + Proof, + Unit, +) +from ..core.crypto import b_dhke +from ..core.crypto.aes import AESCipher +from ..core.crypto.keys import ( + derive_pubkey, + generate_uuid_v7, +) +from ..core.crypto.secp import PrivateKey, PublicKey +from ..core.db import Connection, Database +from ..core.errors import ( + BatchDuplicateQuotesError, + CashuError, + LightningError, + LightningPaymentFailedError, + NotAllowedError, + QuoteAlreadyIssuedError, + QuoteNotPaidError, + QuoteSignatureInvalidError, + TransactionAmountExceedsLimitError, + TransactionError, +) +from ..core.helpers import sum_proofs +from ..core.models import ( + PostMeltQuoteRequest, + PostMeltQuoteResponse, + PostMintBatchRequest, + PostMintQuoteCheckRequest, + PostMintQuoteRequest, +) +from ..core.settings import settings +from ..core.split import amount_split +from ..lightning.base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentResult, + PaymentStatus, +) +from ..mint.crud import LedgerCrudSqlite +from .conditions import LedgerSpendingConditions +from .db.read import DbReadHelper +from .db.write import DbWriteHelper +from .events.events import LedgerEventManager +from .features import LedgerFeatures +from .keysets import LedgerKeysets +from .tasks import LedgerTasks +from .verification import LedgerVerification +from .watchdog import LedgerWatchdog + + +class Ledger( + LedgerVerification, + LedgerSpendingConditions, + LedgerTasks, + LedgerFeatures, + LedgerWatchdog, + LedgerKeysets, +): + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + keysets: Dict[str, MintKeyset] = {} + events = LedgerEventManager() + db: Database + db_read: DbReadHelper + db_write: DbWriteHelper + invoice_listener_tasks: List[asyncio.Task] = [] + watchdog_tasks: List[asyncio.Task] = [] + disable_melt: bool = False + pubkey: PublicKey + + def __init__( + self, + *, + db: Database, + seed: str, + derivation_path="", + amounts: Optional[List[int]] = None, + backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, + seed_decryption_key: Optional[str] = None, + crud=LedgerCrudSqlite(), + ) -> None: + self.keysets: Dict[str, MintKeyset] = {} + self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + self.events = LedgerEventManager() + self.db_read: DbReadHelper + self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks + self.invoice_listener_tasks: List[asyncio.Task] = [] + self.watchdog_tasks: List[asyncio.Task] = [] + self.regular_tasks: List[asyncio.Task] = [] + + if not seed: + raise Exception("seed not set") + + # decrypt seed if seed_decryption_key is set + try: + self.seed = ( + AESCipher(seed_decryption_key).decrypt(seed) + if seed_decryption_key + else seed + ) + except Exception as e: + raise Exception( + f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" + ) + self.derivation_path = derivation_path + + self.db = db + self.crud = crud + + if backends: + self.backends = backends + + if amounts: + self.amounts = amounts + else: + self.amounts = [2**n for n in range(settings.max_order)] + + self.pubkey = derive_pubkey(self.seed) + self.db_read = DbReadHelper(self.db, self.crud) + self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) + + LedgerWatchdog.__init__(self) + + # ------- STARTUP ------- + + async def startup_ledger(self) -> None: + await self._startup_keysets() + await self._check_backends() + self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) + self.invoice_listener_tasks = await self.dispatch_listeners() + if settings.mint_watchdog_enabled: + self.watchdog_tasks = await self.dispatch_watchdogs() + + async def _startup_keysets(self) -> None: + await self.init_keysets() + for derivation_path in settings.mint_derivation_path_list: + derivation_path = self.maybe_update_derivation_path(derivation_path) + await self.activate_keyset(derivation_path=derivation_path) + + async def _run_regular_tasks(self) -> None: + """ + Runs periodic ledger maintenance tasks forever. + This function intentionally loops forever and is designed to be scheduled as a Task. + """ + logger.info("Starting ledger regular tasks loop") + while True: + try: + await self._check_pending_proofs_and_melt_quotes() + await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) + except Exception as e: + logger.error(f"Ledger regular task failed: {e}") + await asyncio.sleep(60) + + async def _check_backends(self) -> None: + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.error( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'" + ) + exit(1) + logger.info(f"Backend balance: {status.balance}") + + logger.info(f"Data dir: {settings.cashu_dir}") + + async def shutdown_ledger(self) -> None: + logger.debug("Shutting down invoice listeners") + for task in self.invoice_listener_tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + for task in self.watchdog_tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + logger.debug("Shutting down regular tasks") + for task in self.regular_tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + logger.debug("Disconnecting from database") + await self.db.engine.dispose() + + 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 + pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( + db=self.db + ) + if not pending_melt_quotes: + return + 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}") + + # ------- ECASH ------- + + async def _generate_change_promises( + self, + fee_provided: int, + fee_paid: int, + outputs: Optional[List[BlindedMessage]], + melt_id: Optional[str] = None, + keyset: Optional[MintKeyset] = None, + ) -> List[BlindedSignature]: + """Generates a set of new promises (blinded signatures) from a set of blank outputs + (outputs with no or ignored amount) by looking at the difference between the Lightning + fee reserve provided by the wallet and the actual Lightning fee paid by the mint. + + If there is a positive difference, produces maximum `n_return_outputs` new outputs + with values close or equal to the fee difference. If the given number of `outputs` matches + the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. + Otherwise, a smaller amount will be returned. + + Args: + input_amount (int): Amount of the proofs provided by the client. + output_amount (int): Amount of the melt request to be paid. + output_fee_paid (int): Actually paid melt network fees. + outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. + + Raises: + Exception: Output validation failed. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + # we make sure that the fee is positive + overpaid_fee = fee_provided - fee_paid + + if overpaid_fee <= 0 or outputs is None: + if overpaid_fee < 0: + logger.debug( + f"No change to return: backend fee {fee_paid} exceeds wallet's " + f"fee reserve {fee_provided} by {-overpaid_fee}." + ) + if melt_id and outputs is not None: + await self.crud.delete_blinded_messages_melt_id( + melt_id=melt_id, db=self.db + ) + return [] + + logger.debug( + f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " + f"Returning difference: {overpaid_fee}." + ) + + return_amounts = amount_split(overpaid_fee) + + # We return at most as many outputs as were provided or as many as are + # required to pay back the overpaid fee. + n_return_outputs = min(len(outputs), len(return_amounts)) + + # we only need as many outputs as we have change to return + outputs = outputs[:n_return_outputs] + + # we sort the return_amounts in descending order so we only + # take the largest values in the next step + return_amounts_sorted = sorted(return_amounts, reverse=True) + # we need to imprint these amounts into the blanket outputs + for i in range(len(outputs)): + outputs[i].amount = return_amounts_sorted[i] # type: ignore + if not self._verify_no_duplicate_outputs(outputs): + raise TransactionError("duplicate promises.") + return_promises = await self._sign_blinded_messages(outputs) + # delete remaining unsigned blank outputs from db + if melt_id: + await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) + return return_promises + + # ------- TRANSACTIONS ------- + + async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: + """Creates a mint quote and stores it in the database. + + Args: + quote_request (PostMintQuoteRequest): Mint quote request. + + Raises: + Exception: Quote creation failed. + + Returns: + MintQuote: Mint quote object. + """ + logger.trace("called request_mint") + if not quote_request.amount > 0: + raise TransactionError("amount must be positive") + if ( + settings.mint_max_mint_bolt11_sat + and quote_request.amount > settings.mint_max_mint_bolt11_sat + ): + raise TransactionAmountExceedsLimitError( + f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." + ) + if settings.mint_bolt11_disable_mint: + raise NotAllowedError("Minting with bolt11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + quote_request.unit, Method.bolt11.name + ) + + if ( + quote_request.description + and not self.backends[method][unit].supports_description + ): + raise NotAllowedError("Backend does not support descriptions.") + + # Check maximum balance. + # TODO: Allow setting MINT_MAX_BALANCE per unit + if settings.mint_max_balance: + balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) + if balance + quote_request.amount > settings.mint_max_balance: + raise NotAllowedError("Mint has reached maximum balance.") + + logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") + invoice_response: InvoiceResponse = await self.backends[method][ + unit + ].create_invoice( + amount=Amount(unit=unit, amount=quote_request.amount), + memo=quote_request.description, + ) + logger.trace( + f"got invoice {invoice_response.payment_request} with checking id" + f" {invoice_response.checking_id}" + ) + + if not (invoice_response.payment_request and invoice_response.checking_id): + raise LightningError("could not fetch bolt11 payment request from backend") + + # get invoice expiry time + invoice_obj = bolt11.decode(invoice_response.payment_request) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = invoice_response.payment_request.lower() + + now = int(time.time()) + expiry = None + if settings.mint_quote_ttl is not None: + expiry = now + settings.mint_quote_ttl + elif invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MintQuote( + quote=generate_uuid_v7(), + method=method.name, + request=request, + checking_id=invoice_response.checking_id, + unit=quote_request.unit, + amount=quote_request.amount, + state=MintQuoteState.unpaid, + created_time=now, + expiry=expiry, + pubkey=quote_request.pubkey, + ) + await self.crud.store_mint_quote(quote=quote, db=self.db) + await self.events.submit(quote) + + return quote + + async def get_mint_quote(self, quote_id: str) -> MintQuote: + """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. + + Args: + quote_id (str): ID of the mint quote. + + Raises: + Exception: Quote not found. + + Returns: + MintQuote: Mint quote object. + """ + quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) + if not quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) + + if quote.unpaid: + if not quote.checking_id: + raise CashuError("quote has no checking id") + + now = int(time.time()) + updated = await self.crud.try_update_mint_quote_last_checked( + quote_id=quote_id, + last_checked=now, + rate_limit=settings.mint_quote_backend_check_rate_limit, + db=self.db, + ) + if not updated: + logger.trace( + f"Lightning: checking invoice {quote.checking_id} skipped due to rate limit" + ) + return quote + quote.last_checked = now + + logger.trace(f"Lightning: checking invoice {quote.checking_id}") + status: PaymentStatus = await self.backends[method][ + unit + ].get_invoice_status(quote.checking_id) + if status.settled: + # change state to paid in one transaction, it could have been marked paid + # by the invoice listener in the mean time + async with self.db.get_connection( + lock_table="mint_quotes", + lock_select_statement="quote = :quote", + lock_parameters={"quote": quote_id}, + ) as conn: + quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db, conn=conn + ) + if not quote: + raise Exception("quote not found") + if quote.unpaid: + logger.trace(f"Setting quote {quote_id} as paid") + quote.state = MintQuoteState.paid + quote.paid_time = now + quote.last_checked = now + quote.updated_at = now + await self.crud.update_mint_quote( + quote=quote, db=self.db, conn=conn + ) + await self.events.submit(quote) + + return quote + + async def mint_quote_check( + self, payload: PostMintQuoteCheckRequest + ) -> List[MintQuote]: + """Batch check mint quotes. + + Args: + payload (PostMintQuoteCheckRequest): Request payload containing quote IDs. + + Returns: + List[MintQuote]: List of mint quotes matching the request. + """ + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise TransactionError(f"quote {quote_id} not found") + quotes.append(quote) + return quotes + + async def mint( + self, + *, + outputs: List[BlindedMessage], + quote_id: str, + signature: Optional[str] = None, + ) -> List[BlindedSignature]: + """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. + + Args: + outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. + quote_id (str): Mint quote id. + witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Quote expired. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + await self._verify_outputs(outputs) + sum_amount_outputs = sum([b.amount for b in outputs]) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[outputs[0].id].unit + + quote = await self.get_mint_quote(quote_id) + if quote.pending: + raise TransactionError("Mint quote already pending.") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + previous_state = quote.state + await self.db_write._set_mint_quote_pending(quote_id=quote_id) + try: + if not quote.unit == output_unit.name: + raise TransactionError("quote unit does not match output unit") + if not quote.amount == sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amount") + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + if not self._verify_mint_quote_witness(quote, outputs, signature): + raise QuoteSignatureInvalidError() + await self._store_blinded_messages(outputs, mint_id=quote_id) + promises = await self._sign_blinded_messages(outputs) + except Exception as e: + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=previous_state + ) + raise e + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=MintQuoteState.issued + ) + + return promises + + async def mint_batch( + self, + payload: PostMintBatchRequest, + ) -> List[BlindedSignature]: + """Batch mint tokens. + + Args: + payload (PostMintBatchRequest): Request payload containing quote IDs, outputs, and signatures. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + if not payload.quotes: + raise TransactionError("batch must not be empty") + + if len(set(payload.quotes)) != len(payload.quotes): + raise BatchDuplicateQuotesError() + + if payload.signatures and len(payload.signatures) != len(payload.quotes): + raise TransactionError("signatures length must match quotes length") + + await self._verify_outputs(payload.outputs) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[payload.outputs[0].id].unit + sum_amount_outputs = sum([b.amount for b in payload.outputs]) + + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise TransactionError(f"quote {quote_id} not found") + quotes.append(quote) + + # Check payment method consistency + methods = set([q.method for q in quotes]) + if len(methods) > 1: + raise TransactionError("all quotes must have the same method") + if Method.bolt11.name not in methods: + raise TransactionError("all quotes must be of bolt11 method") + + # Check currency unit consistency + units = set([q.unit for q in quotes]) + if len(units) > 1: + raise TransactionError("all quotes must have the same unit") + if units.pop() != output_unit.name: + raise TransactionError("quote unit does not match output unit") + + for quote in quotes: + if quote.pending: + raise TransactionError("mint quote already pending") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + # Check amount balance + if payload.quote_amounts: + if len(payload.quote_amounts) != len(quotes): + raise TransactionError("quote_amounts length must match quotes length") + for i, quote in enumerate(quotes): + if ( + quote.method == Method.bolt11.name + and payload.quote_amounts[i] != quote.amount + ): + raise TransactionError( + f"quote amount {payload.quote_amounts[i]} does not match quote {quote.quote} amount {quote.amount}" + ) + if payload.quote_amounts[i] > quote.amount: + raise TransactionError( + f"quote amount {payload.quote_amounts[i]} exceeds quote {quote.quote} amount {quote.amount}" + ) + + quote_amounts = payload.quote_amounts or [q.amount for q in quotes] + if Method.bolt11.name in methods: + if sum(quote_amounts) != sum_amount_outputs: + raise TransactionError( + "amount to mint does not match quote amounts sum" + ) + else: + if sum_amount_outputs > sum(quote_amounts): + raise TransactionError("amount to mint exceeds quote amounts sum") + + # Signature validation (NUT-20) + for i, quote in enumerate(quotes): + sig = payload.signatures[i] if payload.signatures else None + + if not quote.pubkey and sig: + raise QuoteSignatureInvalidError() + + # The spec says msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) + # This logic is inside self._verify_mint_quote_witness, let's reuse it. + if not self._verify_mint_quote_witness(quote, payload.outputs, sig): + raise QuoteSignatureInvalidError() + + # Set all quotes to pending + quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) + + try: + for quote in quotes: + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + + # Store all blinded messages + await self._store_blinded_messages( + payload.outputs, mint_id=payload.quotes[0] + ) + promises = await self._sign_blinded_messages(payload.outputs) + + except Exception as e: + # Revert pending status + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.paid + ) + raise e + + # Set all quotes to issued + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.issued + ) + + return promises + + def create_internal_melt_quote( + self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + if not request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.unit == melt_quote.unit: + raise TransactionError("units do not match") + if not mint_quote.method == method.name: + raise TransactionError("methods do not match") + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + if not mint_quote.unpaid: + raise TransactionError("mint quote is not unpaid") + + if not mint_quote.checking_id: + raise TransactionError("mint quote has no checking id") + if melt_quote.is_mpp: + raise TransactionError("internal payments do not support mpp") + + internal_fee = Amount(unit, 0) # no internal fees + amount = Amount(unit, mint_quote.amount) + + payment_quote = PaymentQuoteResponse( + checking_id=mint_quote.checking_id, + amount=amount, + fee=internal_fee, + ) + logger.info( + f"Issuing internal melt quote: {request} ->" + f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" + ) + + return payment_quote + + def validate_payment_quote( + self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse + ): + # payment quote validation + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + if not payment_quote.checking_id: + raise Exception("quote has no checking id") + # verify that payment quote amount is as expected + if ( + melt_quote.is_mpp + and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount + ): + logger.error( + f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" + ) + raise TransactionError("quote amount not as requested") + # make sure the backend returned the amount with a correct unit + if not payment_quote.amount.unit == unit: + raise TransactionError("payment quote amount units do not match") + # fee from the backend must be in the same unit as the amount + if not payment_quote.fee.unit == unit: + raise TransactionError("payment quote fee units do not match") + + async def melt_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PostMeltQuoteResponse: + """Creates a melt quote and stores it in the database. + + Args: + melt_quote (PostMeltQuoteRequest): Melt quote request. + + Raises: + Exception: Quote invalid. + Exception: Quote already paid. + Exception: Quote already issued. + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + if settings.mint_bolt11_disable_melt: + raise NotAllowedError("Melting with bol11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + # check if there is a mint quote with the same payment request + # so that we would be able to handle the transaction internally + # and therefore respond with internal transaction fees (0 for now) + mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) + if mint_quote and mint_quote.unit == melt_quote.unit: + # check if the melt quote is partial and error if it is. + # it's just not possible to handle this case + if melt_quote.is_mpp: + raise TransactionError("internal mpp not allowed.") + payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) + else: + # not internal + # verify that the backend supports mpp if the quote request has an amount + if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: + raise TransactionError("backend does not support mpp.") + # get payment quote by backend + payment_quote = await self.backends[method][unit].get_payment_quote( + melt_quote=melt_quote + ) + + self.validate_payment_quote(melt_quote, payment_quote) + + # verify that the amount of the proofs is not larger than the maximum allowed + if ( + settings.mint_max_melt_bolt11_sat + and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat + ): + raise NotAllowedError( + f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." + ) + + # We assume that the request is a bolt11 invoice, this works since we + # support only the bol11 method for now. + invoice_obj = bolt11.decode(melt_quote.request) + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + # we set the expiry of this quote to the expiry of the bolt11 invoice + now = int(time.time()) + expiry = None + if settings.melt_quote_ttl is not None: + expiry = now + settings.melt_quote_ttl + elif invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MeltQuote( + quote=generate_uuid_v7(), + method=method.name, + request=request, + checking_id=payment_quote.checking_id, + unit=unit.name, + amount=payment_quote.amount.to(unit).amount, + state=MeltQuoteState.unpaid, + fee_reserve=payment_quote.fee.to(unit).amount, + created_time=now, + expiry=expiry, + ) + await self.db_write._store_melt_quote(quote) + await self.events.submit(quote) + + return PostMeltQuoteResponse( + quote=quote.quote, + amount=quote.amount, + unit=quote.unit, + method=quote.method, + request=quote.request, + fee_reserve=quote.fee_reserve, + state=quote.state.value, + expiry=quote.expiry, + ) + + 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 rollback_unknown is set, do the same for unknown states as for failed states. + + Args: + quote_id (str): ID of the melt quote. + rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. + + Raises: + Exception: Quote not found. + + Returns: + MeltQuote: Melt quote object. + """ + melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) + if not melt_quote: + raise Exception("quote not found") + + if melt_quote.change: + change_outputs = await self.crud.get_blinded_messages_melt_id( + melt_id=quote_id, db=self.db, signed=True + ) + if len(change_outputs) != len(melt_quote.change): + raise TransactionError("could not reconstruct melt change promises") + for output, promise in zip(change_outputs, melt_quote.change): + promise.dleq = self._generate_dleq(output, promise) + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + + # we only check the state with the backend if there is no associated internal + # mint quote for this melt quote + mint_quote = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + + is_internal = mint_quote is not None and mint_quote.unit == melt_quote.unit + + if melt_quote.pending and not is_internal: + logger.debug( + "Lightning: checking outgoing Lightning payment" + f" {melt_quote.checking_id}" + ) + status: PaymentStatus = await self.backends[method][ + unit + ].get_payment_status(melt_quote.checking_id) + logger.debug(f"State: {status.result}") + if status.settled: + logger.debug(f"Setting quote {quote_id} as paid") + melt_quote.state = MeltQuoteState.paid + if status.fee: + melt_quote.fee_paid = status.fee.to(unit, round="up").amount + if status.preimage: + melt_quote.payment_preimage = status.preimage + melt_quote.paid_time = int(time.time()) + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + + # change to compensate wallet for overpaid fees + melt_outputs = await self.crud.get_blinded_messages_melt_id( + melt_id=quote_id, db=self.db + ) + if melt_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_outputs, + melt_id=quote_id, + keyset=self.keysets[melt_outputs[0].id], + ) + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in pending_proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = ( + await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + ) + + if status.failed or (rollback_unknown and status.unknown): + logger.debug(f"Setting quote {quote_id} as unpaid") + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + + return melt_quote + + async def melt_mint_settle_internally( + self, melt_quote: MeltQuote, proofs: List[Proof] + ) -> MeltQuote: + """Settles a melt quote internally if there is a mint quote with the same payment request. + + `proofs` are passed to determine the ecash input transaction fees for this melt quote. + + Args: + melt_quote (MeltQuote): Melt quote to settle. + proofs (List[Proof]): Proofs provided for paying the Lightning invoice. + + Raises: + Exception: Melt quote already paid. + Exception: Melt quote already issued. + + Returns: + MeltQuote: Settled melt quote. + """ + # first we check if there is a mint quote with the same payment request + # so that we can handle the transaction internally without the backend + mint_quote = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + if not mint_quote: + return melt_quote + + # settle externally if units are different + if mint_quote.unit != melt_quote.unit: + return melt_quote + + # we settle the transaction internally + if melt_quote.state == MeltQuoteState.paid: + raise TransactionError("melt quote already paid") + + # verify amounts from bolt11 invoice + bolt11_request = melt_quote.request + invoice_obj = bolt11.decode(bolt11_request) + + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + if not mint_quote.amount == melt_quote.amount: + raise TransactionError("amounts do not match") + if not bolt11_request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.method == melt_quote.method: + raise TransactionError("methods do not match") + + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + + if mint_quote.state != MintQuoteState.unpaid: + raise TransactionError("mint quote is not unpaid") + + logger.info( + f"Settling bolt11 payment internally: {melt_quote.quote} ->" + f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" + ) + + melt_quote.fee_paid = 0 # no internal fees + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + + mint_quote.state = MintQuoteState.paid + mint_quote.paid_time = melt_quote.paid_time + mint_quote.updated_at = melt_quote.paid_time + + async with self.db.get_connection() as conn: + await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) + await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) + + await self.events.submit(melt_quote) + await self.events.submit(mint_quote) + + return melt_quote + + async def async_melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice asynchronously. + + Locks the melt quote and proofs as PENDING before returning, then runs + the Lightning payment in the background. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Returns: + PostMeltQuoteResponse: Melt quote response after PENDING is committed. + """ + melt_quote = await self._prepare_melt( + proofs=proofs, quote=quote, outputs=outputs + ) + + async def melt_task(): + try: + await self._execute_melt_payment(melt_quote, proofs, outputs) + except Exception as e: + logger.error(f"Error in background melt task: {e}") + + asyncio.create_task(melt_task()) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + async def melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Raises: + e: Lightning payment unsuccessful + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + melt_quote = await self._prepare_melt( + proofs=proofs, quote=quote, outputs=outputs + ) + return await self._execute_melt_payment(melt_quote, proofs, outputs) + + async def _prepare_melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> MeltQuote: + """Validates a melt request and durably sets the quote and proofs to pending.""" + # make sure we're allowed to melt + if self.disable_melt and settings.mint_disable_melt_on_error: + raise NotAllowedError("Melt is disabled. Please contact the operator.") + + # get melt quote and check if it was already paid + melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") + + unit, _ = self._verify_and_get_unit_method(melt_quote.unit, melt_quote.method) + + # make sure that the proofs are in the same unit as the quote + self._verify_proofs_unit(proofs, expected_unit=unit) + + await self._verify_transaction( + proofs=proofs, + outputs=outputs, + quote=quote, + skip_output_amount_check=True, + expected_output_unit=unit, + verify_input_output_balance=False, + ) + + # verify that the amount of the input proofs is equal to the amount of the quote + total_provided = sum_proofs(proofs) + input_fees = self.get_fees_for_proofs(proofs) + total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees + # we need the fees specifically for lightning to return the overpaid fees + fee_reserve_provided = total_provided - melt_quote.amount - input_fees + if total_provided < total_needed: + raise TransactionError( + f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" + ) + if fee_reserve_provided < melt_quote.fee_reserve: + raise TransactionError( + f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" + ) + + # set quote and proofs to pending to avoid race conditions + melt_quote = await self.db_write.verify_and_set_melt_quote_pending( + quote=melt_quote, proofs=proofs, keysets=self.keysets + ) + + try: + # store the change outputs + if outputs: + await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) + except Exception as e: + logger.debug(f"Melt failed before backend payment: {e}") + await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + raise e + + return melt_quote + + async def _execute_melt_payment( + self, + melt_quote: MeltQuote, + proofs: List[Proof], + outputs: Optional[List[BlindedMessage]], + ) -> PostMeltQuoteResponse: + """Pays the Lightning invoice for a pending melt quote and finalizes it.""" + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + input_fees = self.get_fees_for_proofs(proofs) + fee_reserve_provided = sum_proofs(proofs) - melt_quote.amount - input_fees + + try: + # if the melt corresponds to an internal mint, mark both as paid + melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) + except Exception as e: + logger.debug(f"Melt failed before backend payment: {e}") + await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + raise e + + # quote not paid yet (not internal), pay it with the backend + if melt_quote.state == MeltQuoteState.pending: + logger.debug(f"Lightning: pay invoice {melt_quote.request}") + try: + fee_limit_msat = ( + Amount(Unit[melt_quote.unit], melt_quote.fee_reserve) + .to(Unit.msat) + .amount + ) + payment = await self.backends[method][unit].pay_invoice( + melt_quote, fee_limit_msat + ) + logger.debug( + f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" + ) + if ( + payment.checking_id + and payment.checking_id != melt_quote.checking_id + ): + logger.warning( + f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." + ) + melt_quote.checking_id = payment.checking_id + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + except Exception as e: + logger.error(f"Exception during pay_invoice: {e}") + payment = PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=str(e), + ) + + match payment.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # explicitly check payment status for failed or unknown payment states + checking_id = payment.checking_id or melt_quote.checking_id + logger.debug( + f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." + ) + try: + status = await self.backends[method][unit].get_payment_status( + checking_id + ) + except Exception as e: + # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. + logger.error( + f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + match status.result: case PaymentResult.FAILED: # Only an explicit terminal failure makes it safe to # release the proofs back to the caller. - await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - if status.error_message: - logger.error( - f"Status check error: {status.error_message}" - ) + await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + if status.error_message: + logger.error( + f"Status check error: {status.error_message}" + ) raise LightningPaymentFailedError( f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}." ) @@ -1248,151 +1248,151 @@ async def _execute_melt_payment( ) self.disable_melt = True return PostMeltQuoteResponse.from_melt_quote(melt_quote) - case _: - # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. - logger.error( - f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - case PaymentResult.SETTLED: - # payment successful - if payment.fee: - melt_quote.fee_paid = payment.fee.to( - to_unit=unit, round="up" - ).amount - if payment.preimage: - melt_quote.payment_preimage = payment.preimage - # set quote as paid - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - # NOTE: This is the only branch for a successful payment - - case PaymentResult.PENDING | _: - logger.debug( - f"Lightning payment is {payment.result.name}: {payment.checking_id}" - ) - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - # melt was successful (either internal or via backend), invalidate proofs - # prepare change to compensate wallet for overpaid fees - return_promises: List[BlindedSignature] = [] - if outputs: - return_promises = await self._generate_change_promises( - fee_provided=fee_reserve_provided, - fee_paid=melt_quote.fee_paid, - outputs=outputs, - melt_id=melt_quote.quote, - keyset=self.keysets[outputs[0].id], - ) - - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - async def swap( - self, - *, - proofs: List[Proof], - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - ): - """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens - Before sending or for redeeming tokens for new ones that have been received by another wallet. - - Args: - proofs (List[Proof]): Proofs to be invalidated for the swap. - outputs (List[BlindedMessage]): New outputs that should be signed in return. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - - Raises: - Exception: Validation of proofs or outputs failed - - Returns: - List[BlindedSignature]: New promises (signatures) for the outputs. - """ - logger.trace("swap called") - await self._verify_transaction( - proofs=proofs, - outputs=outputs, - ) - await self.db_write._verify_spent_proofs_and_set_pending( - proofs, keysets=self.keysets - ) - try: - Ys = [p.Y for p in proofs] - lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} - ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) - async with self.db.get_connection( - lock_table="proofs_pending", - lock_select_statement=f"y IN ({ys_list})", - lock_parameters=lock_parameters, - ) as conn: - await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - await self.db_write.invalidate_proofs( - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - conn=conn, - ) - promises = await self._sign_blinded_messages(outputs, conn) - except Exception as e: - logger.trace(f"swap failed: {e}") - raise e - finally: - # delete proofs from pending list - await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) - - logger.trace("swap successful") - return promises - - async def restore( - self, outputs: List[BlindedMessage] - ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: - signatures: List[BlindedSignature] = [] - return_outputs: List[BlindedMessage] = [] - async with self.db.get_connection() as conn: - for output in outputs: - logger.trace(f"looking for promise: {output}") - promise = await self.crud.get_blind_signature( - b_=output.B_, db=self.db, conn=conn - ) - if promise is not None: - promise.dleq = self._generate_dleq(output, promise) - signatures.append(promise) - return_outputs.append(output) - logger.trace(f"promise found: {promise}") - return return_outputs, signatures - - # ------- BLIND SIGNATURES ------- - - def _generate_dleq(self, output: BlindedMessage, promise: BlindedSignature) -> DLEQ: - B_ = PublicKey(bytes.fromhex(output.B_)) + case _: + # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. + logger.error( + f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + case PaymentResult.SETTLED: + # payment successful + if payment.fee: + melt_quote.fee_paid = payment.fee.to( + to_unit=unit, round="up" + ).amount + if payment.preimage: + melt_quote.payment_preimage = payment.preimage + # set quote as paid + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + # NOTE: This is the only branch for a successful payment + + case PaymentResult.PENDING | _: + logger.debug( + f"Lightning payment is {payment.result.name}: {payment.checking_id}" + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + # melt was successful (either internal or via backend), invalidate proofs + # prepare change to compensate wallet for overpaid fees + return_promises: List[BlindedSignature] = [] + if outputs: + return_promises = await self._generate_change_promises( + fee_provided=fee_reserve_provided, + fee_paid=melt_quote.fee_paid, + outputs=outputs, + melt_id=melt_quote.quote, + keyset=self.keysets[outputs[0].id], + ) + + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + async def swap( + self, + *, + proofs: List[Proof], + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + ): + """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens + Before sending or for redeeming tokens for new ones that have been received by another wallet. + + Args: + proofs (List[Proof]): Proofs to be invalidated for the swap. + outputs (List[BlindedMessage]): New outputs that should be signed in return. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + + Raises: + Exception: Validation of proofs or outputs failed + + Returns: + List[BlindedSignature]: New promises (signatures) for the outputs. + """ + logger.trace("swap called") + await self._verify_transaction( + proofs=proofs, + outputs=outputs, + ) + await self.db_write._verify_spent_proofs_and_set_pending( + proofs, keysets=self.keysets + ) + try: + Ys = [p.Y for p in proofs] + lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} + ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) + async with self.db.get_connection( + lock_table="proofs_pending", + lock_select_statement=f"y IN ({ys_list})", + lock_parameters=lock_parameters, + ) as conn: + await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + await self.db_write.invalidate_proofs( + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + conn=conn, + ) + promises = await self._sign_blinded_messages(outputs, conn) + except Exception as e: + logger.trace(f"swap failed: {e}") + raise e + finally: + # delete proofs from pending list + await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) + + logger.trace("swap successful") + return promises + + async def restore( + self, outputs: List[BlindedMessage] + ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: + signatures: List[BlindedSignature] = [] + return_outputs: List[BlindedMessage] = [] + async with self.db.get_connection() as conn: + for output in outputs: + logger.trace(f"looking for promise: {output}") + promise = await self.crud.get_blind_signature( + b_=output.B_, db=self.db, conn=conn + ) + if promise is not None: + promise.dleq = self._generate_dleq(output, promise) + signatures.append(promise) + return_outputs.append(output) + logger.trace(f"promise found: {promise}") + return return_outputs, signatures + + # ------- BLIND SIGNATURES ------- + + def _generate_dleq(self, output: BlindedMessage, promise: BlindedSignature) -> DLEQ: + B_ = PublicKey(bytes.fromhex(output.B_)) if promise.id not in self.keysets: raise TransactionError(f"keyset {promise.id} not found") keyset = self.keysets[promise.id] @@ -1400,115 +1400,115 @@ def _generate_dleq(self, output: BlindedMessage, promise: BlindedSignature) -> D raise TransactionError( f"keyset {promise.id} does not support amount {promise.amount}" ) - private_key_amount = keyset.private_keys[promise.amount] - C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - if C_.format().hex() != promise.C_: - raise TransactionError("restored signature does not match promise") - return DLEQ(e=e.to_hex(), s=s.to_hex()) - - async def _store_blinded_messages( - self, - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - mint_id: Optional[str] = None, - melt_id: Optional[str] = None, - swap_id: Optional[str] = None, - conn: Optional[Connection] = None, - ) -> None: - """Stores a blinded message in the database. - - Args: - outputs (List[BlindedMessage]): Blinded messages to store. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - """ - async with self.db.get_connection(conn) as conn: - for i, output in enumerate(outputs): - keyset = keyset or self.keysets[output.id] - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - logger.trace(f"Storing blinded message with keyset {keyset.id}.") - await self.crud.store_blinded_message( - id=keyset.id, - amount=output.amount, - b_=output.B_, - mint_id=mint_id, - melt_id=melt_id, - swap_id=swap_id, - order_index=i, - db=self.db, - conn=conn, - ) - logger.trace(f"Stored blinded message for {output.amount}") - - async def _sign_blinded_messages( - self, - outputs: List[BlindedMessage], - conn: Optional[Connection] = None, - ) -> list[BlindedSignature]: - """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). - - Important: When a promises is once created it should be considered issued to the user since the user - will always be able to restore promises later through the backup restore endpoint. That means that additional - checks in the code that might decide not to return these promises should be avoided once this function is - called. Only call this function if the transaction is fully validated! - - Args: - B_s (List[BlindedMessage]): Blinded secret (point on curve) - keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. - If not given will use the keyset of the first output. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - Returns: - list[BlindedSignature]: Generated BlindedSignatures. - """ - promises: List[ - Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] - ] = [] - for output in outputs: - B_ = PublicKey(bytes.fromhex(output.B_)) - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - keyset = self.keysets[output.id] - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - keyset_id = output.id - logger.trace(f"Generating promise with keyset {keyset_id}.") - private_key_amount = keyset.private_keys[output.amount] - C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - promises.append((keyset_id, B_, output.amount, C_, e, s)) - - keyset = keyset or self.keyset - - signatures = [] - async with self.db.get_connection(conn) as conn: - for promise in promises: - keyset_id, B_, amount, C_, e, s = promise - logger.trace(f"crud: _generate_promise storing promise for {amount}") - await self.crud.update_blinded_message_signature( - amount=amount, - b_=B_.format().hex(), - c_=C_.format().hex(), - db=self.db, - conn=conn, - ) - logger.trace(f"crud: _generate_promise stored promise for {amount}") - signature = BlindedSignature( - id=keyset_id, - amount=amount, - C_=C_.format().hex(), - dleq=DLEQ(e=e.to_hex(), s=s.to_hex()), - ) - signatures.append(signature) - - # bump keyset balance - await self.crud.bump_keyset_balance( - db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn - ) - - return signatures + private_key_amount = keyset.private_keys[promise.amount] + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) + if C_.format().hex() != promise.C_: + raise TransactionError("restored signature does not match promise") + return DLEQ(e=e.to_hex(), s=s.to_hex()) + + async def _store_blinded_messages( + self, + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + mint_id: Optional[str] = None, + melt_id: Optional[str] = None, + swap_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> None: + """Stores a blinded message in the database. + + Args: + outputs (List[BlindedMessage]): Blinded messages to store. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + """ + async with self.db.get_connection(conn) as conn: + for i, output in enumerate(outputs): + keyset = keyset or self.keysets[output.id] + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + logger.trace(f"Storing blinded message with keyset {keyset.id}.") + await self.crud.store_blinded_message( + id=keyset.id, + amount=output.amount, + b_=output.B_, + mint_id=mint_id, + melt_id=melt_id, + swap_id=swap_id, + order_index=i, + db=self.db, + conn=conn, + ) + logger.trace(f"Stored blinded message for {output.amount}") + + async def _sign_blinded_messages( + self, + outputs: List[BlindedMessage], + conn: Optional[Connection] = None, + ) -> list[BlindedSignature]: + """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). + + Important: When a promises is once created it should be considered issued to the user since the user + will always be able to restore promises later through the backup restore endpoint. That means that additional + checks in the code that might decide not to return these promises should be avoided once this function is + called. Only call this function if the transaction is fully validated! + + Args: + B_s (List[BlindedMessage]): Blinded secret (point on curve) + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. + If not given will use the keyset of the first output. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + Returns: + list[BlindedSignature]: Generated BlindedSignatures. + """ + promises: List[ + Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] + ] = [] + for output in outputs: + B_ = PublicKey(bytes.fromhex(output.B_)) + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + keyset = self.keysets[output.id] + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + keyset_id = output.id + logger.trace(f"Generating promise with keyset {keyset_id}.") + private_key_amount = keyset.private_keys[output.amount] + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) + promises.append((keyset_id, B_, output.amount, C_, e, s)) + + keyset = keyset or self.keyset + + signatures = [] + async with self.db.get_connection(conn) as conn: + for promise in promises: + keyset_id, B_, amount, C_, e, s = promise + logger.trace(f"crud: _generate_promise storing promise for {amount}") + await self.crud.update_blinded_message_signature( + amount=amount, + b_=B_.format().hex(), + c_=C_.format().hex(), + db=self.db, + conn=conn, + ) + logger.trace(f"crud: _generate_promise stored promise for {amount}") + signature = BlindedSignature( + id=keyset_id, + amount=amount, + C_=C_.format().hex(), + dleq=DLEQ(e=e.to_hex(), s=s.to_hex()), + ) + signatures.append(signature) + + # bump keyset balance + await self.crud.bump_keyset_balance( + db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn + ) + + return signatures diff --git a/tests/mint/test_spending_conditions_unit_transaction.py b/tests/mint/test_spending_conditions_unit_transaction.py index 29f26ba6..8565eb6f 100644 --- a/tests/mint/test_spending_conditions_unit_transaction.py +++ b/tests/mint/test_spending_conditions_unit_transaction.py @@ -18,7 +18,8 @@ def outputs_for_amounts(amounts: list[int]) -> list[BlindedMessage]: return [ - BlindedMessage(id="ks", amount=amount, B_=f"b{i:02x}") + # B_ must be even-length hex: the v1 SIG_ALL message decodes it to bytes + BlindedMessage(id="ks", amount=amount, B_=f"0b{i:02x}") for i, amount in enumerate(amounts, start=1) ]