zorch.lnp.transcript¶
The lnp transcript wire: how a ring stack becomes transcript bytes.
Every protocol in this package absorbs the same kind of object — stacks of ring elements in canonical residue form — and the two sides of a Fiat-Shamir proof must agree on the encoding down to the byte. That agreement is two decisions, and they belong together in one place:
- Serialization. Canonical residues as C-order little-endian u64. The
ring's public contract is already
uint64arrays, so this is a reinterpretation and a copy, never a conversion. - The slice count.
observe_sliceis told how many ring elements a payload carries, not how many machine words. The count is domain-separation input, so the two conventions produce different transcripts from the same bytes; picking the element count keeps the transcript describing the algebra rather than the storage layout.
A protocol module that respelled either half would fork the wire, and no single-module test can see it — both sides of that protocol would agree with each other while disagreeing with its sibling. Hence one definition, imported.
stack_bytes ¶
stack_bytes(stack: np.ndarray) -> bytes
A ring stack's canonical residues as C-order little-endian u64.
The dtype gate is what makes this a reinterpretation rather than a
conversion, which is the claim the module docstring makes above. Without
it astype would quietly reinterpret a signed array — -1 and the
residue 2**64 - 1 produce byte-identical transcript input — and
truncate a float one. Two different values binding to one transcript is
the failure this wire exists to prevent, so it is refused at the door
rather than trusted to every caller.
It gates dtype only, deliberately: injectivity of the byte map is a dtype property, and an out-of-range residue creates no collision. That is also all this function can check — it never sees the moduli. So the error says "uint64", not "canonical", which in lattice-frx's vocabulary would also promise every residue is below its modulus.
Source code in zorch/lnp/transcript.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | |
absorb_stacks ¶
absorb_stacks(
transcript: ByteTranscript, *stacks: np.ndarray
) -> ByteTranscript
Absorb ring stacks in order, each counted in ring elements.
ByteTranscript is functional, so this returns the advanced transcript
and leaves the caller's value untouched — which is what lets a rejected
Fiat-Shamir-with-aborts attempt restart from the original.
Source code in zorch/lnp/transcript.py
67 68 69 70 71 72 73 74 75 | |
absorb_signed ¶
absorb_signed(
transcript: ByteTranscript,
ring: "HostSplitRing",
*arrays: np.ndarray
) -> ByteTranscript
Absorb signed integer arrays, through the ring's canonical form.
stack_bytes refuses a signed array at the door, and rightly: -1 and
the residue 2**64 - 1 are byte-identical, so serializing signed input
directly is how two values bind to one transcript. But a protocol does
sometimes have to absorb one — Fig. 9's revealed projection ⃗z lives in
unreduced ℤ, because its norm is the statement and a residue has none.
So the reduction is named here rather than left to each caller. The
choice being pinned is that a signed array is absorbed as the ring
element it reduces to, and that lives beside absorb_stacks for the
reason the module docstring gives: the second protocol to need this
would otherwise pick its own convention (raw two's-complement? balanced?
mod q?), and no single-module suite could see the fork — both sides of
that protocol would agree with each other.
Each array is (k, d) signed integers, absorbed as k ring elements.
Source code in zorch/lnp/transcript.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
sampling_modulus ¶
sampling_modulus(ring: 'HostSplitRing') -> int
q as one integer, gated for the uniform sampler's u64 chunking.
Every layer that squeezes Z_q draws off the transcript needs this
number and this gate; two layers that each derived them raised two
near-identical errors, and the ceiling would have had to be found twice
the day the sampler widens.
Source code in zorch/lnp/transcript.py
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |
squeeze_uniform ¶
squeeze_uniform(
transcript: ByteTranscript,
label: bytes,
modulus: int,
count: int,
) -> tuple[ByteTranscript, np.ndarray]
Absorb label, then squeeze count uniform Z_q draws.
The squeeze half of this module's charter. uniform_bytes_needed and
uniform_from_bytes must be called at the same modulus and count or
the byte stream desynchronises, and a protocol that respelled the
pairing would fork the wire exactly as one respelling absorb_stacks
would — with the same invisibility, since both sides of that protocol
would still agree with each other.
Reshaping the draws, and what to build from them, stays with the caller:
that part genuinely differs (a ring element per relation for Fig. 7, a
λ×M scalar matrix for the ENS20 aggregation).
range.py deliberately does not come through here — its Bin_1
matrix has a power-of-two support, where uniform_from_bytes' general
path would spend a full u64 per two-bit draw. That is an exception to
this rule, and it reads as one only because the rule is named.
Source code in zorch/lnp/transcript.py
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | |