diff --git a/cashu/mint/conditions.py b/cashu/mint/conditions.py index f5a68265..7a8e0846 100644 --- a/cashu/mint/conditions.py +++ b/cashu/mint/conditions.py @@ -46,8 +46,8 @@ def from_p2pk_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc": try: parsed = P2PKWitness.from_witness(witness) return cls(preimage=None, signatures=parsed.signatures) - except Exception: - return cls(preimage=None, signatures=[]) + except Exception as exc: + raise TransactionError("witness could not be parsed.") from exc @classmethod def from_htlc_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc": @@ -59,8 +59,8 @@ def from_htlc_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc": return cls( preimage=parsed.preimage, signatures=list(parsed.signatures or []) ) - except Exception: - return cls(preimage=None, signatures=[]) + except Exception as exc: + raise TransactionError("witness could not be parsed.") from exc class LedgerSpendingConditions: @@ -141,7 +141,7 @@ def _verify_sigall_spending_conditions( raise TransactionError("HTLC secret does not have SIG_ALL flag") return self._verify_p2pk_or_htlc_spending_requirements( self._get_spending_requirements(unique_secret), - WitnessForP2pkOrHtlc.from_htlc_witness(first_proof.witness), + first_proof.witness, message_to_sign, ) elif isinstance(unique_secret, P2PKSecret): @@ -149,7 +149,7 @@ def _verify_sigall_spending_conditions( raise TransactionError("P2PK secret does not have SIG_ALL flag") return self._verify_p2pk_or_htlc_spending_requirements( self._get_spending_requirements(unique_secret), - WitnessForP2pkOrHtlc.from_p2pk_witness(first_proof.witness), + first_proof.witness, message_to_sign, ) else: @@ -258,7 +258,7 @@ def _verify_p2pk_or_htlc_sig_inputs( raise TransactionError("Expected P2PKSecret") return self._verify_p2pk_or_htlc_spending_requirements( requirements, - WitnessForP2pkOrHtlc.from_p2pk_witness(proof.witness), + proof.witness, message_to_sign, ) @@ -266,7 +266,7 @@ def _verify_p2pk_or_htlc_sig_inputs( raise TransactionError("Expected HTLCSecret") return self._verify_p2pk_or_htlc_spending_requirements( requirements, - WitnessForP2pkOrHtlc.from_htlc_witness(proof.witness), + proof.witness, message_to_sign, ) @@ -314,10 +314,25 @@ def _get_spending_requirements( def _verify_p2pk_or_htlc_spending_requirements( self, requirements: SpendingRequirements, - witness: WitnessForP2pkOrHtlc, + raw_witness: Optional[str], message_to_sign: str, ) -> bool: # Contract: this verifier returns True on success and raises on failure. + try: + witness = ( + WitnessForP2pkOrHtlc.from_htlc_witness(raw_witness) + if requirements.preimage_hash is not None + else WitnessForP2pkOrHtlc.from_p2pk_witness(raw_witness) + ) + except TransactionError: + # An unreadable witness supplies no preimage and no signatures, so + # no path that demands either can spend. A refund path that demands + # neither still can, and an expired lock without refund pubkeys is + # exactly that + if requirements.refund_path and requirements.refund_path.required_sigs == 0: + return True + raise + primary_path_error: Optional[Exception] = None # Try the primary path first. Any failure here is remembered and only diff --git a/tests/mint/test_spending_conditions_unit_htlc.py b/tests/mint/test_spending_conditions_unit_htlc.py index bc9e7b1a..168dd22d 100644 --- a/tests/mint/test_spending_conditions_unit_htlc.py +++ b/tests/mint/test_spending_conditions_unit_htlc.py @@ -3,6 +3,7 @@ import pytest +from cashu.core.errors import TransactionError from cashu.core.htlc import HTLCSecret from cashu.core.secret import Secret, SecretKind from cashu.mint.conditions import LedgerSpendingConditions @@ -99,3 +100,33 @@ def test_verify_htlc_preimage_rejects_wrong_preimage(): ) with pytest.raises(Exception, match="HTLC preimage does not match"): cond._verify_htlc_preimage(secret.data, wrong) + + +def test_verify_input_spending_conditions_unparsable_htlc_witness_reports_parse_failure(): + cond = LedgerSpendingConditions() + preimage = "55" * 32 + digest = sha256(bytes.fromhex(preimage)).hexdigest() + raw_secret = secret_str(kind=SecretKind.HTLC, data=digest) + p = proof(raw_secret) + p.witness = "{not json" + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "witness could not be parsed" in str(exc_info.value) + + +def test_verify_input_spending_conditions_htlc_witness_without_preimage_is_not_a_parse_failure(): + # HTLCWitness declares every field optional, so a witness carrying none of + # them still parses. Only genuinely unreadable input is a parse failure here + cond = LedgerSpendingConditions() + preimage = "66" * 32 + digest = sha256(bytes.fromhex(preimage)).hexdigest() + raw_secret = secret_str(kind=SecretKind.HTLC, data=digest) + p = proof(raw_secret) + p.witness = '{"foo":"bar"}' + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "no HTLC preimage provided" in str(exc_info.value) diff --git a/tests/mint/test_spending_conditions_unit_p2pk.py b/tests/mint/test_spending_conditions_unit_p2pk.py index 52d2a9d2..d1fd35dc 100644 --- a/tests/mint/test_spending_conditions_unit_p2pk.py +++ b/tests/mint/test_spending_conditions_unit_p2pk.py @@ -207,3 +207,97 @@ def test_verify_p2pk_sig_inputs_allows_anyone_after_locktime_without_refund_pubk extra_tags=[["locktime", past]], ) assert cond._verify_input_spending_conditions(proof(raw_secret)) + + +def test_verify_p2pk_sig_inputs_absent_witness_reports_no_signatures(): + # Pins the wire contract for an absent witness (cashubtc/nutshell#1126): + # code 11000 with "no signatures in proof.". Clients assert on both. + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-absent-witness") + raw_secret = secret_str(kind=SecretKind.P2PK, data=pub) + p = proof(raw_secret) + assert p.witness is None + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "no signatures in proof" in str(exc_info.value) + + +def test_verify_p2pk_sig_inputs_empty_signature_list_reports_no_signatures(): + # A witness that parses cleanly to zero signatures is the absent case too, + # and reaches it by a different route than witness=None: it goes through + # the parser rather than returning ahead of it. + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-empty-signature-list") + raw_secret = secret_str(kind=SecretKind.P2PK, data=pub) + p = proof(raw_secret, signatures=[]) + assert p.witness == '{"signatures":[]}' + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "no signatures in proof" in str(exc_info.value) + + +def test_verify_p2pk_sig_inputs_unparsable_witness_reports_parse_failure(): + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-unparsable-witness") + raw_secret = secret_str(kind=SecretKind.P2PK, data=pub) + p = proof(raw_secret) + p.witness = "{not json" + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "witness could not be parsed" in str(exc_info.value) + + +def test_verify_p2pk_sig_inputs_wrong_shape_witness_reports_parse_failure(): + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-wrong-shape-witness") + raw_secret = secret_str(kind=SecretKind.P2PK, data=pub) + p = proof(raw_secret) + p.witness = '{"foo":"bar"}' + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "witness could not be parsed" in str(exc_info.value) + + +def test_verify_p2pk_sig_inputs_unparsable_witness_still_spends_via_zero_sig_refund(): + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-unparsable-zero-sig-refund") + past = str(int(time.time()) - 60) + raw_secret = secret_str( + kind=SecretKind.P2PK, + data=pub, + extra_tags=[["locktime", past]], + ) + p = proof(raw_secret) + p.witness = "{not json" + + assert cond._verify_input_spending_conditions(p) + + +def test_verify_p2pk_sig_inputs_unparsable_witness_outranks_refund_path_complaint(): + # An expired lock with refund pubkeys gives the refund attempt its own + # failure, which would otherwise be the last one recorded and report + # "no signatures in proof." -- the message this change exists to stop. + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-unparsable-refund-needs-sigs") + refund_pub, _ = pubkey_and_sig("msg-unparsable-refund-pubkey") + past = str(int(time.time()) - 60) + raw_secret = secret_str( + kind=SecretKind.P2PK, + data=pub, + extra_tags=[["locktime", past], ["refund", refund_pub]], + ) + p = proof(raw_secret) + p.witness = "{not json" + + with pytest.raises(TransactionError) as exc_info: + cond._verify_input_spending_conditions(p) + assert exc_info.value.code == 11000 + assert "witness could not be parsed" in str(exc_info.value) diff --git a/tests/mint/test_spending_conditions_unit_sigall.py b/tests/mint/test_spending_conditions_unit_sigall.py index bf2e38b3..ce9e2286 100644 --- a/tests/mint/test_spending_conditions_unit_sigall.py +++ b/tests/mint/test_spending_conditions_unit_sigall.py @@ -2,6 +2,7 @@ from cashu.core.base import BlindedMessage, P2PKWitness from cashu.core.crypto.secp import PrivateKey +from cashu.core.errors import TransactionError from cashu.core.nuts import nut11 from cashu.core.p2pk import SigFlags, schnorr_sign from cashu.core.secret import SecretKind @@ -89,3 +90,19 @@ def test_verify_input_output_spending_conditions_requires_equal_secrets_with_sig outputs = [BlindedMessage(id="ks", amount=1, B_="b1")] with pytest.raises(Exception, match="not all secrets are equal"): cond._verify_input_output_spending_conditions([p1, p2], outputs) + + +def test_verify_sigall_spending_conditions_absent_witness_reports_no_signatures(): + # Pins the wire contract for an absent SIG_ALL witness + # (cashubtc/nutshell#1126): code 11000 with "no signatures in proof.". + cond = LedgerSpendingConditions() + pub, _ = pubkey_and_sig("msg-sigall-absent-witness") + raw_secret = secret_str(kind=SecretKind.P2PK, data=pub, sigflag=SigFlags.SIG_ALL) + proofs = [proof(raw_secret), proof(raw_secret)] + outputs = [BlindedMessage(id="ks", amount=1, B_="c1")] + assert all(p.witness is None for p in proofs) + + with pytest.raises(TransactionError) as exc_info: + cond._verify_sigall_spending_conditions(proofs, outputs) + assert exc_info.value.code == 11000 + assert "no signatures in proof" in str(exc_info.value) diff --git a/tests/mint/test_spending_conditions_unit_transaction.py b/tests/mint/test_spending_conditions_unit_transaction.py index 6185d812..6d11ef2b 100644 --- a/tests/mint/test_spending_conditions_unit_transaction.py +++ b/tests/mint/test_spending_conditions_unit_transaction.py @@ -1,3 +1,4 @@ +import json import time from hashlib import sha256 @@ -12,7 +13,7 @@ schnorr_sign, ) from cashu.core.secret import Secret, SecretKind -from cashu.mint.conditions import LedgerSpendingConditions, WitnessForP2pkOrHtlc +from cashu.mint.conditions import LedgerSpendingConditions from tests.mint.spending_conditions_test_helpers import proof, secret_str @@ -79,7 +80,9 @@ def test_p2pk_requirements_ignore_stray_preimage_in_normalized_witness(): P2PKSecret.from_secret(Secret.deserialize(secret)) ) sig = p2pk_sig_inputs_signature(secret, signer) - witness = WitnessForP2pkOrHtlc(preimage="11" * 32, signatures=[sig]) + # A P2PK witness carrying a stray preimage: P2PKWitness ignores the extra + # key, so the preimage never reaches the requirements verifier. + witness = json.dumps({"preimage": "11" * 32, "signatures": [sig]}) assert cond._verify_p2pk_or_htlc_spending_requirements( requirements, witness,