Skip to content

zorch.pcs.ipa.prover

IPA prover: commit and open by log-n basis folding.

commit is the Pedersen MSM P = ⟨a, G⟩ = msm(coeffs, basis). open proves p(x) = ⟨a, b⟩ for b = (1, x, …, x^{n-1}) by the Bulletproofs/Halo fold. The opening first squeezes a seed challenge ξ₀ binding (P, x, v) and scales the inner-product generator to h' = U·ξ₀ (arkworks ipa_pc's h_prime); each of the k = log₂ n rounds then sends two cross-term group elements

L_j = ⟨a_hi, G_lo⟩ + ⟨a_hi, b_lo⟩·h'
R_j = ⟨a_lo, G_hi⟩ + ⟨a_lo, b_hi⟩·h'

absorbs them into the Fiat-Shamir transcript, samples a challenge u_j, and folds all three vectors in half

a ← a_lo + a_hi·u_j⁻¹
b ← b_lo + b_hi·u_j
G ← G_lo + G_hi·u_j

The L/R labeling (L pairs a_hi with G_lo) and the no-inverse fold (the low half carried unscaled, the high half scaled by u_j) are arkworks ipa_pc's convention — the same the check polynomial h(X) = ∏(1 + u_j·X^…) and the decider's final-key MSM are written against (see math.py). Folding continues until each vector collapses to a single element. Each cross term is one lax.msm (the h' term folded in as one extra (scalar, point) pair), so the only raw EC arithmetic is the basis fold G_lo + G_hi·u — vectorized scalar-mul and point-add, with the result converted back to affine each round to keep the point representation (and thus the next round's lax.msm input) stable. The fold is a lax.scan over the round count, not a Python unroll: the unroll's static-slice fold fuses cleanly but recompiles per size (compile grows linearly in k = log₂ n), while the scan compiles in O(1). The scan's fixed-shape carry keeps a/b/G at full size n and reads the collapsing half with a masked dynamic_slice, byte-identical to the shrinking fold (0·P = identity) — trading the unroll's static-slice fusion for the dynamic_slice/scatter fusion boundaries the scan needs (a compile-time-for-fusion trade, not a claim of one fused kernel). Warm runtime is unchanged at tested sizes (FS-permute-bound); the valid_count msm operand removes the resulting k·n mask padding (see _open_one).

Scope: one base-field polynomial per opening, power-of-two length. open is transparent (no blinding); the hiding/zk _open_one_zk below blinds the witness and opens an s-randomized commitment. A demonstration of the seam, not a hardened prover.

IpaProverData dataclass

Retained witness from IpaProver.commit: the coefficient vectors (to drive the fold in open) and the commitments (the opening's Fiat-Shamir binds them as part of the statement). Holds references to the (immutable) inputs — no polynomial data is copied.

Source code in zorch/pcs/ipa/prover.py
81
82
83
84
85
86
87
88
89
@dataclass(frozen=True)
class IpaProverData:
    """Retained witness from `IpaProver.commit`: the coefficient vectors (to drive
    the fold in `open`) and the commitments (the opening's Fiat-Shamir binds them
    as part of the statement). Holds references to the (immutable) inputs — no
    polynomial data is copied."""

    coeffs: tuple[Array, ...]
    commitments: Array  # G1 affine [K] — P_j per poly, bound into the FS seed

IpaProver dataclass

Bases: ProverStage[OpeningClaim[IpaCommitment], OpeningWitness[IpaProverData], TrivialClaim, OpeningProof[list[IpaProof]]]

Source code in zorch/pcs/ipa/prover.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
@dataclass(frozen=True)
class IpaProver(
    ProverStage[
        OpeningClaim[IpaCommitment],
        OpeningWitness[IpaProverData],
        TrivialClaim,
        OpeningProof[list[IpaProof]],
    ]
):
    key: IpaKey

    def commit(self, polys: Sequence[Array]) -> tuple[IpaCommitment, IpaProverData]:
        """Pedersen-commit a batch of coefficient vectors: `P_j = ⟨a_j, G⟩`.
        Returns the stacked G1 commitments and the prover data (coeffs plus the
        commitments, which the opening's Fiat-Shamir binds)."""
        commitments = fnp.stack(
            [lax.msm(c, self.key.basis[: c.shape[0]]) for c in polys]
        )
        return commitments, IpaProverData(tuple(polys), commitments)

    def commit_zk(
        self, polys: Sequence[Array], randomnesses: Sequence[Array]
    ) -> tuple[IpaCommitment, IpaProverData]:
        """Hiding Pedersen commit: `P_j = ⟨a_j, G⟩ + r_j·s`. The blinding `r_j·s`
        makes the commitment hiding; `_open_one_zk` opens such a commitment to a
        zero-knowledge proof, removing the blinding inside the fold (the
        `− combined_rand·s` term, which is why the commitment must carry `r_j·s` to
        begin with). Requires the key's blinding generator `key.s`."""
        s = self.key.s
        if s is None:
            raise ValueError("zk commit requires the blinding generator key.s")
        if len(polys) != len(randomnesses):
            raise ValueError(
                f"batch mismatch: {len(polys)} polys vs "
                f"{len(randomnesses)} randomnesses"
            )
        commitments = fnp.stack(
            [
                _hiding_commit(c, r, self.key.basis, s)
                for c, r in zip(polys, randomnesses)
            ]
        )
        return commitments, IpaProverData(tuple(polys), commitments)

    def prove(
        self,
        claim: OpeningClaim[IpaCommitment],
        witness: OpeningWitness[IpaProverData],
        transcript: Transcript,
    ) -> ProveResult[TrivialClaim, OpeningProof[list[IpaProof]]]:
        """Open the committed polynomials at the claim's points.

        Terminal: an opening closes its claim rather than reducing it."""
        values, proof, transcript = self._open(
            witness.prover_data, claim.points, transcript
        )
        return ProveResult(TrivialClaim(), OpeningProof(values, proof), transcript)

    def _open(
        self,
        prover_data: IpaProverData,
        points: Sequence[Array],
        transcript: Transcript,
    ) -> tuple[Array, list[IpaProof], Transcript]:
        """Open poly `j` at `points[j]`. Returns `(values, proofs, transcript)`
        with `values[j] = p_j(points[j])` and one `IpaProof` per opening. Wraps the
        transcript in the default `TranscriptChallenger` (the zorch-native FS) and
        threads it through every opening; a byte-exact consumer drives `_open_one`
        with its own `IpaChallenger` instead."""
        if len(prover_data.coeffs) != len(points):
            raise ValueError(
                f"batch mismatch: {len(prover_data.coeffs)} polys vs "
                f"{len(points)} points"
            )
        fs = TranscriptChallenger(transcript, prover_data.coeffs[0].dtype)
        values, proofs = [], []
        for commitment, coeffs, x in zip(
            prover_data.commitments, prover_data.coeffs, points
        ):
            fs, value, proof, _ = _open_one(self.key, commitment, coeffs, x, fs)
            values.append(value)
            proofs.append(proof)
        return fnp.stack(values), proofs, fs.transcript

commit

commit(
    polys: Sequence[Array],
) -> tuple[IpaCommitment, IpaProverData]

Pedersen-commit a batch of coefficient vectors: P_j = ⟨a_j, G⟩. Returns the stacked G1 commitments and the prover data (coeffs plus the commitments, which the opening's Fiat-Shamir binds).

Source code in zorch/pcs/ipa/prover.py
103
104
105
106
107
108
109
110
def commit(self, polys: Sequence[Array]) -> tuple[IpaCommitment, IpaProverData]:
    """Pedersen-commit a batch of coefficient vectors: `P_j = ⟨a_j, G⟩`.
    Returns the stacked G1 commitments and the prover data (coeffs plus the
    commitments, which the opening's Fiat-Shamir binds)."""
    commitments = fnp.stack(
        [lax.msm(c, self.key.basis[: c.shape[0]]) for c in polys]
    )
    return commitments, IpaProverData(tuple(polys), commitments)

commit_zk

commit_zk(
    polys: Sequence[Array], randomnesses: Sequence[Array]
) -> tuple[IpaCommitment, IpaProverData]

Hiding Pedersen commit: P_j = ⟨a_j, G⟩ + r_j·s. The blinding r_j·s makes the commitment hiding; _open_one_zk opens such a commitment to a zero-knowledge proof, removing the blinding inside the fold (the − combined_rand·s term, which is why the commitment must carry r_j·s to begin with). Requires the key's blinding generator key.s.

Source code in zorch/pcs/ipa/prover.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def commit_zk(
    self, polys: Sequence[Array], randomnesses: Sequence[Array]
) -> tuple[IpaCommitment, IpaProverData]:
    """Hiding Pedersen commit: `P_j = ⟨a_j, G⟩ + r_j·s`. The blinding `r_j·s`
    makes the commitment hiding; `_open_one_zk` opens such a commitment to a
    zero-knowledge proof, removing the blinding inside the fold (the
    `− combined_rand·s` term, which is why the commitment must carry `r_j·s` to
    begin with). Requires the key's blinding generator `key.s`."""
    s = self.key.s
    if s is None:
        raise ValueError("zk commit requires the blinding generator key.s")
    if len(polys) != len(randomnesses):
        raise ValueError(
            f"batch mismatch: {len(polys)} polys vs "
            f"{len(randomnesses)} randomnesses"
        )
    commitments = fnp.stack(
        [
            _hiding_commit(c, r, self.key.basis, s)
            for c, r in zip(polys, randomnesses)
        ]
    )
    return commitments, IpaProverData(tuple(polys), commitments)

prove

prove(
    claim: OpeningClaim[IpaCommitment],
    witness: OpeningWitness[IpaProverData],
    transcript: Transcript,
) -> ProveResult[
    TrivialClaim, OpeningProof[list[IpaProof]]
]

Open the committed polynomials at the claim's points.

Terminal: an opening closes its claim rather than reducing it.

Source code in zorch/pcs/ipa/prover.py
136
137
138
139
140
141
142
143
144
145
146
147
148
def prove(
    self,
    claim: OpeningClaim[IpaCommitment],
    witness: OpeningWitness[IpaProverData],
    transcript: Transcript,
) -> ProveResult[TrivialClaim, OpeningProof[list[IpaProof]]]:
    """Open the committed polynomials at the claim's points.

    Terminal: an opening closes its claim rather than reducing it."""
    values, proof, transcript = self._open(
        witness.prover_data, claim.points, transcript
    )
    return ProveResult(TrivialClaim(), OpeningProof(values, proof), transcript)