"""Tests for extended MPay dual-signature (Ed25519 + secp256k1 / AVAX). Verifies that :func:`build_payment_claim` correctly: 1. Produces required Ed25519 MSign signature in all cases (backward compat). 2. Produces ``payer_avax_address``, ``eth_sig`` when ``avax_private_key`` is set. 3. Produces ``recipient_avax_address`` when that argument is passed. 4. Leaves AVAX fields absent when no secp256k1 key is supplied. 5. ``eth_sig`` is a valid EIP-191 signature verifiable with ``eip191_verify``. 6. ``PaymentClaim`` TypedDict shape is correct (required + optional fields). 7. Dual-signed claims are deterministic for the same inputs (given fixed ``ts``). 8. Large payloads and extreme argument values don't break signing. Test categories --------------- - unit : PaymentClaim field structure - integration : dual-sig claim round-trip (Ed25519 + secp256k1) - e2e : verify eth_sig with eip191_verify against derived address - stress : 50 consecutive dual-sig claims - data-integrity : canonical message matches both Ed25519 and EIP-191 signer - performance : dual-sig claim under 200 ms per claim - security : AVAX fields absent when no key supplied; no key leakage - docstrings : public API has docstrings """ from __future__ import annotations import hashlib import time from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest from muse.core.types import b64url_decode if TYPE_CHECKING: from eth_keys.keys import PrivateKey as _AvaxKey # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- BIP39_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about" ) @pytest.fixture(scope="module") def seed() -> bytes: from muse.core.bip39 import mnemonic_to_seed return mnemonic_to_seed(BIP39_MNEMONIC) @pytest.fixture(scope="module") def avax_key(seed: bytes) -> "_AvaxKey": from muse.core.secp256k1_sign import derive_avax_key return derive_avax_key(seed) @pytest.fixture(scope="module") def ed25519_signing(seed: bytes) -> MagicMock: """A minimal SigningIdentity-compatible object with a real Ed25519 key.""" from muse.core.hdkeys import derive_identity_key, dk_to_ed25519 dk = derive_identity_key(seed) private_key = dk_to_ed25519(dk) signing = MagicMock() signing.private_key = private_key signing.handle = "gabriel" return signing @pytest.fixture(scope="module") def avax_address(avax_key: "_AvaxKey") -> str: from muse.core.secp256k1_sign import avax_c_chain_address return avax_c_chain_address(avax_key.public_key) # --------------------------------------------------------------------------- # Unit: PaymentClaim TypedDict shape # --------------------------------------------------------------------------- class TestPaymentClaimShape: """PaymentClaim TypedDict structure — required and optional fields.""" def test_required_fields_present_without_avax(self, ed25519_signing: MagicMock) -> None: """Required fields are always present even without AVAX key.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000_000, "nanoMUSE", "a" * 64, "test memo", ts=1_700_000_000, ) assert claim["from_handle"] == "gabriel" assert claim["to_handle"] == "alice" assert claim["amount_nano"] == 1_000_000 assert claim["currency"] == "nanoMUSE" assert claim["nonce_hex"] == "a" * 64 assert claim["memo"] == "test memo" assert claim["ts"] == 1_700_000_000 assert "signature_b64" in claim assert "canonical_message" in claim def test_avax_fields_absent_without_key(self, ed25519_signing: MagicMock) -> None: """AVAX fields must be absent when ``avax_private_key`` is not supplied.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000_000, "nanoMUSE", "a" * 64, "memo", ts=1, ) assert "payer_avax_address" not in claim assert "eth_sig" not in claim assert "recipient_avax_address" not in claim def test_avax_fields_present_with_key(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """AVAX fields appear when ``avax_private_key`` is supplied.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000_000, "nanoMUSE", "b" * 64, "memo", ts=1, avax_private_key=avax_key, ) assert "payer_avax_address" in claim assert "eth_sig" in claim assert "recipient_avax_address" not in claim def test_recipient_avax_address_stored(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """``recipient_avax_address`` is stored verbatim when provided.""" from muse.core.msign import build_payment_claim recipient = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 500, "nanoMUSE", "c" * 64, "memo", ts=2, avax_private_key=avax_key, recipient_avax_address=recipient, ) assert claim["recipient_avax_address"] == recipient def test_recipient_without_payer_key(self, ed25519_signing: MagicMock) -> None: """``recipient_avax_address`` can be stored without a secp256k1 key.""" from muse.core.msign import build_payment_claim recipient = "0xDeAD000000000000000042069420694206942069" claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 100, "nanoMUSE", "d" * 64, "memo", ts=3, recipient_avax_address=recipient, ) assert claim["recipient_avax_address"] == recipient assert "payer_avax_address" not in claim assert "eth_sig" not in claim # --------------------------------------------------------------------------- # Integration: dual-sig claim round-trip # --------------------------------------------------------------------------- class TestDualSigRoundTrip: """Ed25519 + secp256k1 dual-signature integration tests.""" def test_payer_address_matches_derived(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None: """``payer_avax_address`` must equal the derived AVAX C-Chain address.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000, "nanoMUSE", "e" * 64, "memo", ts=10, avax_private_key=avax_key, ) assert claim["payer_avax_address"] == avax_address def test_eth_sig_is_65_bytes_hex(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """``eth_sig`` is a 130-char hex string encoding 65 bytes.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000, "nanoMUSE", "f" * 64, "memo", ts=11, avax_private_key=avax_key, ) eth_sig_hex = claim["eth_sig"] assert len(eth_sig_hex) == 130, "65 bytes → 130 hex chars" sig_bytes = bytes.fromhex(eth_sig_hex) assert len(sig_bytes) == 65 # v must be 27 or 28 (legacy Ethereum recovery ID) assert sig_bytes[64] in (27, 28) def test_ed25519_sig_still_valid(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """Ed25519 ``signature_b64`` must remain valid even with AVAX key.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 999, "nanoMUSE", "aa" * 32, "memo", ts=12, avax_private_key=avax_key, ) # Verify the Ed25519 signature manually from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey pub = ed25519_signing.private_key.public_key() sig_b64 = claim["signature_b64"] sig_bytes = b64url_decode(sig_b64) canonical_bytes = claim["canonical_message"].encode() # Should not raise pub.verify(sig_bytes, canonical_bytes) def test_deterministic_given_fixed_ts(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """Two identical calls with the same ``ts`` must produce identical claims.""" from muse.core.msign import build_payment_claim kwargs = dict( avax_private_key=avax_key, ts=42, ) c1 = build_payment_claim(ed25519_signing, "gabriel", "alice", 7, "nanoMUSE", "00" * 32, "m", **kwargs) c2 = build_payment_claim(ed25519_signing, "gabriel", "alice", 7, "nanoMUSE", "00" * 32, "m", **kwargs) assert c1["signature_b64"] == c2["signature_b64"] assert c1["eth_sig"] == c2["eth_sig"] assert c1["payer_avax_address"] == c2["payer_avax_address"] # --------------------------------------------------------------------------- # E2E: verify eth_sig with eip191_verify # --------------------------------------------------------------------------- class TestEip191Verification: """Verify the dual-sig eth_sig using eip191_verify.""" def test_eip191_verify_accepts_eth_sig(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None: """``eip191_verify`` must accept the ``eth_sig`` for the canonical message.""" from muse.core.msign import build_payment_claim from muse.core.secp256k1_sign import eip191_verify claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 5_000, "nanoMUSE", "bb" * 32, "verify test", ts=100, avax_private_key=avax_key, ) canonical_bytes = claim["canonical_message"].encode() sig_bytes = bytes.fromhex(claim["eth_sig"]) assert eip191_verify(sig_bytes, canonical_bytes, avax_address) def test_eip191_verify_rejects_wrong_address(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", seed: bytes) -> None: """Verification must fail for a different address.""" from muse.core.msign import build_payment_claim from muse.core.secp256k1_sign import avax_c_chain_address, derive_avax_key, eip191_verify claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1, "nanoMUSE", "cc" * 32, "memo", ts=200, avax_private_key=avax_key, ) # Derive a different key (account=1) to get a different address other_key = derive_avax_key(seed, account=1) other_address = avax_c_chain_address(other_key.public_key) canonical_bytes = claim["canonical_message"].encode() sig_bytes = bytes.fromhex(claim["eth_sig"]) assert not eip191_verify(sig_bytes, canonical_bytes, other_address) def test_eip191_verify_rejects_tampered_message(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None: """Verification must fail when the canonical message is tampered.""" from muse.core.msign import build_payment_claim from muse.core.secp256k1_sign import eip191_verify claim = build_payment_claim( ed25519_signing, "gabriel", "alice", 1, "nanoMUSE", "dd" * 32, "memo", ts=300, avax_private_key=avax_key, ) sig_bytes = bytes.fromhex(claim["eth_sig"]) tampered = b"MPAY\ngabriel\nalice\n9999999\nnanoMUSE\n" + b"d" * 64 + b"\nmemo\n300" assert not eip191_verify(sig_bytes, tampered, avax_address) # --------------------------------------------------------------------------- # Stress: 50 consecutive dual-sig claims # --------------------------------------------------------------------------- class TestStress: """Stress: 50 back-to-back dual-signed claims.""" def test_50_claims(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey", avax_address: str) -> None: """Produce 50 dual-signed claims; all must have correct AVAX address.""" from muse.core.msign import build_payment_claim for i in range(50): nonce = hashlib.sha256(str(i).encode()).hexdigest() claim = build_payment_claim( ed25519_signing, "gabriel", "alice", i * 100, "nanoMUSE", nonce, f"stress-{i}", ts=i, avax_private_key=avax_key, ) assert claim["payer_avax_address"] == avax_address assert len(claim["eth_sig"]) == 130 # --------------------------------------------------------------------------- # Data integrity: canonical_message matches signer input # --------------------------------------------------------------------------- class TestDataIntegrity: """Canonical message structure matches what both signers used.""" def test_canonical_message_format(self, ed25519_signing: MagicMock) -> None: """canonical_message must follow MPAY\\n…\\nTS format.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "payer", "payee", 42_000, "nanoETH", "0" * 64, "stem:sha256:abc", ts=1_744_000_000, ) expected = f"MPAY\npayer\npayee\n42000\nnanoETH\n{'0' * 64}\nstem:sha256:abc\n1744000000" assert claim["canonical_message"] == expected def test_dual_sig_uses_same_canonical_message(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """Ed25519 and EIP-191 must sign the identical canonical bytes.""" from muse.core.msign import build_payment_claim from muse.core.secp256k1_sign import eip191_verify, avax_c_chain_address claim = build_payment_claim( ed25519_signing, "p", "q", 1, "nanoMUSE", "ee" * 32, "x", ts=999, avax_private_key=avax_key, ) addr = avax_c_chain_address(avax_key.public_key) sig_bytes = bytes.fromhex(claim["eth_sig"]) # EIP-191 verification uses canonical_message bytes — proves same input was signed assert eip191_verify(sig_bytes, claim["canonical_message"].encode(), addr) # --------------------------------------------------------------------------- # Performance: dual-sig claim under 200 ms # --------------------------------------------------------------------------- class TestPerformance: """Single dual-sig claim must complete within 200 ms.""" def test_claim_latency(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """build_payment_claim with dual-sig must complete in < 200 ms.""" from muse.core.msign import build_payment_claim start = time.perf_counter() build_payment_claim( ed25519_signing, "gabriel", "alice", 1_000_000, "nanoMUSE", "ff" * 32, "perf test", avax_private_key=avax_key, ) duration_ms = (time.perf_counter() - start) * 1000 assert duration_ms < 200, f"Too slow: {duration_ms:.1f} ms" # --------------------------------------------------------------------------- # Security: AVAX fields absent by default; no key material in claim dict # --------------------------------------------------------------------------- class TestSecurity: """Security properties of dual-sig claims.""" def test_no_avax_fields_by_default(self, ed25519_signing: MagicMock) -> None: """AVAX fields must be completely absent unless explicitly requested.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "a", "b", 1, "nanoMUSE", "00" * 32, "", ts=0, ) avax_keys = {"payer_avax_address", "eth_sig", "recipient_avax_address"} assert not avax_keys.intersection(claim.keys()) def test_no_private_key_in_claim(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """The claim dict must not contain any private key material.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "a", "b", 1, "nanoMUSE", "11" * 32, "", ts=5, avax_private_key=avax_key, ) # Private key bytes are 32 bytes; they must not appear as a value priv_hex = avax_key.to_bytes().hex() for v in claim.values(): if isinstance(v, str): assert priv_hex not in v def test_ed25519_domain_separation(self, ed25519_signing: MagicMock, avax_key: "_AvaxKey") -> None: """Ed25519 sig must differ from EIP-191 sig bytes — no cross-protocol reuse.""" from muse.core.msign import build_payment_claim claim = build_payment_claim( ed25519_signing, "a", "b", 1, "nanoMUSE", "22" * 32, "", ts=6, avax_private_key=avax_key, ) ed_sig_hex = b64url_decode(claim["signature_b64"]).hex() assert ed_sig_hex != claim["eth_sig"] # --------------------------------------------------------------------------- # Docstrings: public API coverage # --------------------------------------------------------------------------- class TestDocstrings: """Public API must have docstrings.""" def test_build_payment_claim_docstring(self) -> None: from muse.core.msign import build_payment_claim assert build_payment_claim.__doc__ def test_payment_claim_docstring(self) -> None: from muse.core.msign import PaymentClaim assert PaymentClaim.__doc__