Skip to content

Commit 7823bd9

Browse files
tcoratgerclaude
andauthored
refactor(testing): simplify aggregation prover mock (leanEthereum#1152)
Collapse the hand-rolled stack-based placeholder hashing into a single repr-based digest and inline it at its only call site. The recursive type-tagged length-framed serialization handled inputs that a plain repr already distinguishes for the prover's actual argument domain, so the bool isinstance-ordering footgun disappears with it. Trim the verbose verifier comment block to the load-bearing soundness invariant and tighten the module and context-manager docstrings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c7ec04b commit 7823bd9

1 file changed

Lines changed: 15 additions & 71 deletions

File tree

packages/testing/src/consensus_testing/crypto_mode.py

Lines changed: 15 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
"""
2-
Mock the Rust aggregation prover at the FFI boundary, from the test layer.
3-
4-
Most vectors do not test proofs, yet building one costs a recursive SNARK merge.
5-
6-
Mocking lets the fast scheme skip that work while leaving the spec untouched.
7-
8-
Vectors whose purpose is proof validity opt back in with the real_crypto marker.
9-
"""
1+
"""Mock the Rust aggregation prover at the FFI boundary, from the test layer."""
102

113
import hashlib
124
from collections.abc import Callable, Iterator
@@ -52,87 +44,39 @@ def get_mode(cls) -> CryptoMode:
5244
"""Return the process-wide crypto mode."""
5345
return cls._mode
5446

55-
@staticmethod
56-
def _placeholder_proof(positional_args: tuple[object, ...]) -> bytes:
57-
"""
58-
Build a deterministic placeholder proof bound to a call's inputs.
59-
60-
Length framing keeps distinct inputs distinct.
61-
62-
So two different blocks never share proof bytes by accident.
63-
"""
64-
digest = hashlib.sha256()
65-
stack: list[object] = [positional_args]
66-
while stack:
67-
value = stack.pop()
68-
if isinstance(value, bool):
69-
digest.update(b"b\x01" if value else b"b\x00")
70-
elif isinstance(value, int):
71-
encoded = str(value).encode()
72-
digest.update(b"i" + len(encoded).to_bytes(8, "big") + encoded)
73-
elif isinstance(value, bytes):
74-
digest.update(b"y" + len(value).to_bytes(8, "big") + value)
75-
elif isinstance(value, (list, tuple)):
76-
digest.update(b"s" + len(value).to_bytes(8, "big"))
77-
stack.extend(reversed(value))
78-
elif value is None:
79-
digest.update(b"n")
80-
else:
81-
raise TypeError(f"unmockable prover argument of type {type(value).__name__}")
82-
return MOCK_PROOF_PREFIX + digest.digest()
83-
8447
@classmethod
8548
@contextmanager
8649
def mocked(cls) -> Iterator[None]:
8750
"""
8851
Swap the Rust prover bindings on the spec module for in-process stubs.
8952
90-
- Provers return a placeholder proof;
53+
- Provers return a placeholder proof.
9154
- Verifiers accept that placeholder.
92-
93-
The spec's own structural checks still run; only the Rust call is skipped.
94-
A real proof reaching the mocked verifier falls through to the real one.
9555
"""
9656
originals = {
9757
name: getattr(aggregation, name) for name in (*cls._prover_names, *cls._verifier_names)
9858
}
9959

100-
def prove(*positional_args: object, **_keyword_args: object) -> tuple[None, bytes]:
101-
return None, cls._placeholder_proof(positional_args)
60+
def prove(
61+
*positional_arguments: object, **_keyword_arguments: object
62+
) -> tuple[None, bytes]:
63+
# Distinct inputs hash to distinct bytes, so two blocks never collide.
64+
fingerprint = repr(positional_arguments).encode()
65+
return None, MOCK_PROOF_PREFIX + hashlib.sha256(fingerprint).digest()
10266

10367
def make_verifier(real_verifier: Callable[..., None]) -> Callable[..., None]:
104-
def verify(*positional_args: object, **keyword_args: object) -> None:
105-
# A placeholder proof is recognized by its sentinel prefix alone.
106-
# Acceptance is then an unconditional no-op.
107-
# The message, slot, and public keys go unchecked.
108-
#
109-
# This is deliberately weaker than recompute-and-compare.
110-
# The placeholder is content-bound when the prover builds it.
111-
# But the prover hashes inputs the verifier never receives.
112-
#
113-
# The single-message prover folds in the raw signatures.
114-
# It also folds in the child proofs and the rate exponent.
115-
# The verifier only sees the public keys, message, slot, and proof.
116-
#
117-
# Merging and splitting reshape a proof after the fact.
118-
# Its bytes then match no single prover call the verifier could replay.
119-
# So reconstructing the placeholder to compare it is infeasible.
120-
#
121-
# Invariant: this no-op acceptance stays sound only because every
122-
# vector asserting proof validity or rejection carries the
123-
# real_crypto marker and runs against the real prover, not the mock.
124-
# A proof-rejection vector lacking that marker would silently pass
125-
# here, even though a conforming client must reject it.
126-
# Never add a proof-tamper vector under the mock; mark it real_crypto.
68+
def verify(*positional_arguments: object, **keyword_arguments: object) -> None:
69+
# Accept any sentinel-prefixed placeholder unchecked.
70+
# Invariant: sound only because proof vectors carry the real-crypto marker.
12771
carries_placeholder = any(
12872
isinstance(argument, bytes) and argument.startswith(MOCK_PROOF_PREFIX)
129-
for argument in positional_args
73+
for argument in positional_arguments
13074
)
13175
if carries_placeholder:
13276
return None
133-
# A real proof can still arrive when a vector mixes real and mocked
134-
# inputs, so fall through to the real verifier for those bytes.
135-
return real_verifier(*positional_args, **keyword_args)
77+
# A vector mixing real and mocked inputs can still pass real bytes.
78+
# Fall through to the real verifier for those.
79+
return real_verifier(*positional_arguments, **keyword_arguments)
13680

13781
return verify
13882

0 commit comments

Comments
 (0)