Source code for primitives.transcript

"""Fiat-Shamir transcript using Poseidon2 duplex sponge.

Matches Plonky3's DuplexChallenger<BabyBear, Poseidon2<16>, 16, 8> bit-exactly.

Reference:
    p3-challenger-0.4.1/src/duplex_challenger.rs
"""

from primitives.field import FF4, Fe
from primitives.poseidon2 import permute

[docs] WIDTH = 16
[docs] RATE = 8
[docs] class Challenger: """Fiat-Shamir challenger using Poseidon2 duplex sponge. Reference: p3-challenger-0.4.1/src/duplex_challenger.rs """ def __init__(self) -> None:
[docs] self.sponge_state: list[Fe] = [0] * WIDTH
[docs] self.input_buffer: list[Fe] = []
[docs] self.output_buffer: list[Fe] = []
@classmethod
[docs] def from_state( cls, sponge_state: list[Fe], input_buffer: list[Fe], output_buffer: list[Fe], ) -> "Challenger": """Create Challenger from exported internal state (for transcript replay).""" c = cls() c.sponge_state = list(sponge_state) c.input_buffer = list(input_buffer) c.output_buffer = list(output_buffer) return c
# <doc-anchor id="observe">
[docs] def observe(self, value: Fe) -> None: """Absorb a single field element. Reference: duplex_challenger.rs lines 111-120 """ self.output_buffer.clear() self.input_buffer.append(value) if len(self.input_buffer) == RATE: self._duplexing()
[docs] def observe_many(self, values) -> None: """Absorb multiple field elements or an FF4 scalar. Reference: duplex_challenger.rs lines 142-146 """ if isinstance(values, FF4): for i in range(4): self.observe(int(values._d[i])) return for v in values: self.observe(v)
# <doc-anchor id="sample">
[docs] def sample(self) -> Fe: """Squeeze one base field element (LIFO from output buffer). Reference: duplex_challenger.rs lines 172-184 """ if len(self.input_buffer) > 0 or len(self.output_buffer) == 0: self._duplexing() return self.output_buffer.pop()
[docs] def sample_ext(self) -> FF4: """Squeeze one extension field element. Reference: duplex_challenger.rs (CanSample<EF>) """ return FF4([self.sample() for _ in range(4)])
[docs] def sample_bits(self, bits: int) -> int: """Sample a random index with the given number of bits. Reference: duplex_challenger.rs lines 201-207 (CanSampleBits) """ val = self.sample() return val & ((1 << bits) - 1)
def _duplexing(self) -> None: """Apply duplex sponge: overwrite rate portion, permute, extract output. IMPORTANT: Does NOT zero-pad unused rate positions. Reference: duplex_challenger.rs lines 81-94 """ # Overwrite only the positions that have input (no zero-padding) for i, val in enumerate(self.input_buffer): self.sponge_state[i] = val self.input_buffer.clear() self.sponge_state = permute(self.sponge_state) self.output_buffer = list(self.sponge_state[:RATE])
[docs] def clone(self) -> "Challenger": """Deep copy this challenger's state. Used by the prover for proof-of-work grinding. Reference: duplex_challenger.rs Clone impl """ return Challenger.from_state( self.sponge_state, self.input_buffer, self.output_buffer )
# <doc-anchor id="check-witness">
[docs] def check_witness(challenger: Challenger, bits: int, witness: int) -> bool: """Verify a proof-of-work witness against the Fiat-Shamir transcript. Observes the witness, samples `bits` bits, and checks == 0. Reference: p3-challenger GrindingChallenger::check_witness """ if bits == 0: return True challenger.observe(witness) return challenger.sample_bits(bits) == 0
[docs] def grind(challenger: Challenger, bits: int) -> int: """Brute-force search for a proof-of-work witness. Tries witness = 0, 1, 2, ... until check_witness passes. Then calls check_witness on the original challenger to update its state. Args: challenger: The Fiat-Shamir challenger. bits: Number of bits for the PoW check. Returns: The winning witness value. Reference: p3-challenger grinding_challenger.rs GrindingChallenger::grind """ if bits == 0: return 0 for witness in range(2**31): test = challenger.clone() test.observe(witness) if test.sample_bits(bits) == 0: # Update original challenger state challenger.observe(witness) challenger.sample_bits(bits) return witness raise RuntimeError(f"failed to find PoW witness for {bits} bits")