Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions cashu/mint/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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:
Expand Down Expand Up @@ -141,15 +141,15 @@ 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):
if unique_secret.sigflag != SigFlags.SIG_ALL:
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:
Expand Down Expand Up @@ -258,15 +258,15 @@ 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,
)

if not isinstance(secret, HTLCSecret):
raise TransactionError("Expected HTLCSecret")
return self._verify_p2pk_or_htlc_spending_requirements(
requirements,
WitnessForP2pkOrHtlc.from_htlc_witness(proof.witness),
proof.witness,
message_to_sign,
)

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/mint/test_spending_conditions_unit_htlc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
94 changes: 94 additions & 0 deletions tests/mint/test_spending_conditions_unit_p2pk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 17 additions & 0 deletions tests/mint/test_spending_conditions_unit_sigall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
7 changes: 5 additions & 2 deletions tests/mint/test_spending_conditions_unit_transaction.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import time
from hashlib import sha256

Expand All @@ -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


Expand Down Expand Up @@ -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,
Expand Down
Loading