Encoding and transcript specification¶
This page pins down the byte-level details an independent implementation needs to produce bit-identical proofs: how group elements and scalars are encoded, the exact order in which the transcript absorbs them, and how a challenge is derived from the hash state. Two implementations that agree on this page interoperate; two that disagree on any field will compute different challenges and reject each other's proofs. (That sensitivity is a feature — it is also what makes a proof non-malleable.)
Everything here is normative and matches transcript.py and rangeproof.py.
Group parameters¶
The default group is the prime-order subgroup of quadratic residues of the RFC 3526 2048-bit MODP safe prime \(p = 2q + 1\):
| Symbol | Value |
|---|---|
| \(p\) | RFC 3526 group 14 prime (2048 bits) |
| \(q\) | \((p-1)/2\) (prime; the subgroup order) |
| \(g\) | 4 (a quadratic residue, hence a generator of the order-\(q\) subgroup) |
| \(h\) | sha512("sigma-rangeproof:h:v1" ‖ ctr)² mod p, smallest ctr ≥ 0 giving h > 1 |
elem_bytes |
\(\lceil \mathrm{bitlen}(p)/8 \rceil = 256\) |
Group elements are integers in \([1, p)\); scalars are integers in \([0, q)\).
Field encoding¶
The transcript is a single running SHA-256. Each value is absorbed as a labelled, length-delimited field:
absorb(label, data):
sha256.update( len(label) as 4-byte big-endian )
sha256.update( label )
sha256.update( len(data) as 8-byte big-endian )
sha256.update( data )
The length prefixes make the encoding unambiguous: no two distinct sequences of fields can produce the same byte stream, so there is no concatenation ambiguity.
Two kinds of data appear:
- Group elements and small integers (
g,h, commitments,avalues, the threshold, the bit count) are encoded as(value mod p)in big-endian over exactlyelem_bytes(256) bytes. Group elements are already in range; the reduction is defensive. - The modulus
pis encoded as its raw 256-byte big-endian value — not reduced modp, which would collapse it to zero and leave the modulus unbound.
Challenge derivation (hash-to-scalar)¶
A challenge is read from the current transcript state \(S\) after absorbing a per-challenge label:
challenge(label):
absorb("challenge", label) # fold the label into the state S
d = sha256( S.digest() ‖ 0x00 ) # 32 bytes
‖ sha256( S.digest() ‖ 0x01 ) # + 32 bytes -> 512 bits
return int_big_endian(d) mod q
The 512-bit output is interpreted big-endian and reduced mod \(q\). Because \(2^{512} < q\), the reduction is injective (no modular bias), and 512 bits is far more challenge entropy than the security level requires.
Transcript order for a range proof¶
To prove value ≥ threshold over bits bits, the transcript absorbs the
following, in this exact order. C is the public commitment, \(C_i\) the
per-bit commitments, \((a0_i, a1_i)\) the OR-proof commitments for bit \(i\).
1. ("domain", "sigma-rangeproof:v1")
2. ("p", p as 256-byte big-endian)
3. ("g", g)
4. ("h", h)
5. ("C", commitment)
6. ("threshold", threshold)
7. ("bits", bits)
8. ("Ci", C_0), ("Ci", C_1), ..., ("Ci", C_{bits-1}) # in index order
9. for i in 0 .. bits-1:
("a0", a0_i)
("a1", a1_i)
e_i = challenge("bit") # derived here, after a0_i, a1_i
The prover and verifier run steps 1–9 identically; the only difference is that the prover chooses the \(a\) values and the verifier reads them from the proof. Because \(C\), the threshold, the bit count, and every \(C_i\) are folded in before any challenge is drawn, each challenge \(e_i\) is bound to the whole statement — the property the Sigma page calls strong Fiat-Shamir.
Proof serialization¶
RangeProof.to_dict() produces a JSON-friendly object; integers are Python
hex() strings (lower-case, 0x-prefixed), and from_dict parses them back with
int(s, 16).
{
"bits": 10,
"commitments": ["0x…", "0x…", "…"],
"bit_proofs": [
{ "a0": "0x…", "a1": "0x…",
"e0": "0x…", "e1": "0x…",
"z0": "0x…", "z1": "0x…" },
"…"
]
}
commitments and bit_proofs each have exactly bits entries. The commitment
itself is not part of the proof object — it is the public value the verifier
already holds and passes to verify_ge alongside the threshold.
Verifier obligations¶
A conforming verifier, given (commitment, threshold, proof):
- Rejects unless
len(commitments) == len(bit_proofs) == bitsandbits ≥ 1. - Rejects unless
commitmentand every \(C_i\) and every \(a\) value is in the order-\(q\) subgroup (\(x^q \equiv 1 \bmod p\), \(x \neq 0\)). - Rejects unless every scalar
e0,e1,z0,z1is canonical (in \([0, q)\)). - Computes \(C' = C \cdot g^{-\text{threshold}} \bmod p\) and rejects unless \(\prod_i C_i^{2^i} = C'\).
- Replays steps 1–9 above and rejects unless every per-bit check holds: \(e0_i + e1_i \equiv e_i \pmod q\) and \(h^{z b_i} = a b_i \cdot Y b_i^{\,e b_i}\) for both branches, where \(Y0_i = C_i\) and \(Y1_i = C_i \cdot g^{-1}\).
Steps 2 and 3 are what make the proof non-malleable and immune to out-of-subgroup inputs; see Security and parameters.
Verifier limits¶
A conforming verifier processing a proof from an untrusted source MUST also enforce the following bounds before performing any modular exponentiation, so that hostile input cannot inflate verifier cost:
1 ≤ bits ≤ 64. The default range proof usesbits = 32; the cap of 64 covers every realistic integer threshold and lets the verifier reject an oversized proof in constant time.0 ≤ threshold < q. A threshold outside the scalar range cannot describe a meaningful statement; reject without further work.- Each serialized group element MUST fit in
2 · elem_byteshex characters (= 512for the default group,+ 2for the optional0xprefix). Each serialized scalar MUST fit in2 · scalar_byteshex characters. len(commitments) == len(bit_proofs) == bits, and each bit-proof entry MUST contain exactly the keysa0,a1,e0,e1,z0,z1.
These limits are exposed in the reference implementation as
sigma_rangeproof.MAX_BITS and Params.{elem_bytes, scalar_bytes}.