Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
12 changes: 9 additions & 3 deletions cashu/mint/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class SpendingRequirements:
class WitnessForP2pkOrHtlc:
preimage: str | None
signatures: List[str]
malformed: bool = False

@classmethod
def from_p2pk_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc":
Expand All @@ -47,7 +48,7 @@ def from_p2pk_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc":
parsed = P2PKWitness.from_witness(witness)
return cls(preimage=None, signatures=parsed.signatures)
except Exception:
return cls(preimage=None, signatures=[])
return cls(preimage=None, signatures=[], malformed=True)

@classmethod
def from_htlc_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc":
Expand All @@ -60,7 +61,7 @@ def from_htlc_witness(cls, witness: Optional[str]) -> "WitnessForP2pkOrHtlc":
preimage=parsed.preimage, signatures=list(parsed.signatures or [])
)
except Exception:
return cls(preimage=None, signatures=[])
return cls(preimage=None, signatures=[], malformed=True)


class LedgerSpendingConditions:
Expand Down Expand Up @@ -361,7 +362,12 @@ def _verify_p2pk_or_htlc_spending_requirements(
except Exception as exc:
primary_path_error = exc

# No path succeeded, so surface the best available error.
# No path succeeded, so surface the best available error. A witness
# that could not be read outranks whichever path complained last:
# the client did send something, so "no signatures" would mislead.
if witness.malformed:
raise TransactionError("witness could not be parsed.")

if primary_path_error:
raise primary_path_error

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)
Loading