initial version of the RLN circuit

This commit is contained in:
Balazs Komuves 2026-01-14 21:50:23 +01:00
parent 1538864d49
commit 6953a54dc1
No known key found for this signature in database
GPG Key ID: F63B7AEF18435562
8 changed files with 460 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.DS_Store
build

61
README.md Normal file
View File

@ -0,0 +1,61 @@
rln-fast
--------
This is a proof-of-concept implemetation of the following idea:
We want to speed up the proof generation of [Rate Limiting Nullifiers](https://rate-limiting-nullifier.github.io/rln-docs/) (RLN), by exploiting the following simple observation:
A dominating part of the witness of the Groth16 zero-knowledge proof changes much less often than the proof generation happens; and because of this, the structure of Groth16 proofs allows a significant amount of precalculation.
Our [`circom`](https://docs.circom.io/) circuit is very similar to the one in [the PSE repo](https://github.com/Rate-Limiting-Nullifier/), and implements essentially the [RLNv2 spec](https://rfc.vac.dev/vac/raw/rln-v2), but it's not exactly the same (see below for the details). However this optimization should apply to those too the same way.
### Benchmarks
TODO after the implementation :)
### Differences from the PSE circuit
Note that we are not generic in the curve/field choice, requiring BN254 curve. This is only a limitation of the implementation of this PoC, it would work exactly the same with eg. BLS12-381.
Actual differences:
- we use Poseidon2 hash instead of `circomlib`'s Poseidon
- we only use the Poseidon2 permutation with fixed width `t=3` (for simplicity)
- when computing `a1`, we use the formula `a1 := H(sk+j|ext)` instead of `H(sk|ext|j)`, as this results in one less hash invocation (but shouldn't really cause any difference)
### Partial witness generation
A first step is to figure out which part of the witness is constant (in practice, it's of course not constant just rarely changed, but we will call it constant for brevity).
As we prefer to use (relatively) high-level languages (eg. `circom`) to write our circuit, this should be automated; and we also need to split the witness generation into precalculation and finishing.
This is not too hard to do: We only need to annotation which inputs (both private and public inputs) are constant, and then from the witness calculation graph we can derive all elements of the witness which are constant.
Then based on this we can also easily split the witness generation into two.
### Proof precalculation
Recall the formulas to calculate a Groth16 proof:
$$
\begin{align*}
\pi_a &:= [\alpha]_1 \;+\; \sum_{j=1}^M z_j*[\mathcal{A}_j(\tau)]_1 \;+\; r*[\delta]_1 \\
\rho &:= [\beta]_1 \;+\; \sum_{j=1}^M z_j*[\mathcal{B}_j(\tau)]_1 \;+\; s*[\delta]_1 \\
\pi_b &:= [\beta]_2 \;+\; \sum_{j=1}^M z_j*[\mathcal{B}_j(\tau)]_2 \;+\; s*[\delta]_2 \\
\pi_c &:= \sum_{j=\ell+1}^M z_j*[K_j]_1 \;+\; \sum_{i=1}^N q_i*[Z_i]_1 \;+\; s*\pi_a \;+\; r*\rho \;-\; (rs)*[\delta]_1
\end{align*}
$$
Here $z\in\mathbb{F}^M$ denotes the witness vector, $[\mathcal{A}_j(\tau)]_1,\; [\mathcal{B}_j(\tau)]_1\in\mathbb{G}_1$, $[K_j]_1,\;[Z_i]_1\in\mathbb{G}_1$ and $[\mathcal{B}_j(\tau)]_2\in\mathbb{G}_2$ are fixed elliptic curve points (depending only the circuit, and some on the trusted setup too) $[\alpha]_1,\;[\beta]_1,\;[\beta]_2,\;[\delta]_1,\;[\delta]_2$ come from the so-called "toxic waste"; $q_i$ are coefficients of the quotient polynomials (remark: the [`snarkjs`](https://github.com/iden3/snarkjs) version is slightly different here); $r,s\in\mathbb{F}$ are blinding coefficients; and $*$ denotes elliptic curve scalar multiplication.
Observe that this computation is dominated by 5 multi-scalar multiplications (MSM), _4 of which_ depends only on the witness (the 5th is with the coefficients of the quotient polynomial, that unfortunately cannot be precalculated).
So we will simply precalculate the sums (MSM-s) of the form
$$\sum_{j\in \mathcal{F}}^M z_j*[\mathcal{A}_j(\tau)]_1 $$
where $\mathcal{F}\subset[1\dots M]$ is the set of witness indices which are unchanged, and the the remaing sum (over the complement indices) at the final proof generation.
The only other significant computation is computing the quotient polynomial; that's usually done with FFT. Some part of that can be partially precomputed, but probably won't give a significant speedup.

View File

@ -0,0 +1,9 @@
pragma circom 2.1.1;
include "rln_proof.circom";
// argument conventions:
// RLN(limit_bits, merkle_depth)
component main {public [msg_hash, ext_null, merkle_root]} = RLN(16,20);

33
circuit/merkle.circom Normal file
View File

@ -0,0 +1,33 @@
pragma circom 2.1.1;
include "misc.circom";
include "poseidon2/compression.circom";
//------------------------------------------------------------------------------
// check a Merkle inclusion proof
template MerkleCheck(merkle_depth) {
signal input leaf_hash;
signal input leaf_idx;
signal input merkle_path[merkle_depth];
signal input merkle_root;
signal idx_bits[merkle_depth] <== ToBits(merkle_depth)(leaf_idx);
signal aux[merkle_depth+1];
signal left[merkle_depth];
signal right[merkle_depth];
aux[0] <== leaf_hash;
// reconstruct the root
for(var i=0; i<merkle_depth; i++) {
(left[i],right[i]) <== SwapIfOne()( idx_bits[i] , aux[i] , merkle_path[i] );
aux[i+1] <== Compress() ( left[i], right[i] );
}
aux[merkle_depth] === merkle_root; // check that the reconstructed root matches !!!
}
//------------------------------------------------------------------------------

89
circuit/misc.circom Normal file
View File

@ -0,0 +1,89 @@
pragma circom 2.1.1;
//------------------------------------------------------------------------------
function FloorLog2(n) {
return (n==0) ? -1 : (1 + FloorLog2(n>>1));
}
function CeilLog2(n) {
return (n==0) ? 0 : (1 + FloorLog2(n-1));
}
//------------------------------------------------------------------------------
// decompose an n-bit number into bits (least significant bit first)
template ToBits(n) {
signal input inp;
signal output out[n];
var sum = 0;
for(var i=0; i<n; i++) {
out[i] <-- (inp >> i) & 1;
out[i] * (1-out[i]) === 0;
sum += (1<<i) * out[i];
}
inp === sum;
}
//------------------------------------------------------------------------------
// check range (0 <= i < limit)
template RangeCheck(limit_bits) {
signal input what;
signal input limit;
_ <== ToBits(limit_bits)( what ); // 0 <= what < 2^limit_bits
_ <== ToBits(limit_bits)( limit - 1 - what ); // 0 <= limit - 1 - what < 2^limit_bits
// note that `0 <= limit-1-what` is equivalent to `what < limit`,
// and we already have `0 <= what` from the first one.
}
//------------------------------------------------------------------------------
// conditional swap
// We swap if selector = 1, we don't swap if selector = 0
// NOTEe: we assume that the selector is already checked to be a bit!
template SwapIfOne() {
signal input selector; // assumed to be a bit!
signal input inp0, inp1;
signal output out0, out1;
out0 <== inp0 + selector * ( inp1 - inp0 );
out1 <== inp1 + selector * ( inp0 - inp1 );
}
//------------------------------------------------------------------------------
// check equality to zero; that is, compute `(inp==0) ? 1 : 0`
template IsZero() {
signal input inp;
signal output out;
// guess the inverse
signal inv;
inv <-- (inp != 0) ? (1/inp) : 0 ;
// if `inp==0`, then by definition `out==1`
// if `out==0`, then the inverse must must exist, so `inp!=0`
out <== 1 - inp * inv;
// enfore that either `inp` or `out` must be zero
inp*out === 0;
}
//------------------------------------------------------------------------------
// check equality of two field elements; that is, computes `(A==B) ? 1 : 0`
template IsEqual() {
signal input A,B;
signal output out;
component isz = IsZero();
isz.inp <== A - B;
isz.out ==> out;
}
//------------------------------------------------------------------------------

View File

@ -0,0 +1,27 @@
pragma circom 2.1.1;
include "permutation.circom";
//
// The Poseidon2 compression function
// (used for example when constructing binary Merkle trees)
//
//------------------------------------------------------------------------------
// the "compression function" takes 2 field elements as input and produces
// 1 field element as output. It is a trivial application of the permutation.
template Compress() {
signal input inp0;
signal input inp1;
signal output out;
component perm = Permutation();
perm.inp[0] <== inp0;
perm.inp[1] <== inp1;
perm.inp[2] <== 0;
perm.out[0] ==> out;
}
//------------------------------------------------------------------------------

View File

@ -0,0 +1,201 @@
pragma circom 2.1.1;
//
// The Poseidon2 permutation for BN254 and t=3
//
//------------------------------------------------------------------------------
// The S-box
template SBox() {
signal input inp;
signal output out;
signal x2 <== inp*inp;
signal x4 <== x2*x2;
out <== inp*x4;
}
//------------------------------------------------------------------------------
// partial or internal round
template InternalRound(i) {
signal input inp[3];
signal output out[3];
var round_consts[56] =
[ 0x15ce7e5ae220e8623a40b3a3b22d441eff0c9be1ae1d32f1b777af84eea7e38c
, 0x1bf60ac8bfff0f631983c93e218ca0d4a4059c254b4299b1d9984a07edccfaf0
, 0x0fab0c9387cb2bec9dc11b2951088b9e1e1d2978542fc131f74a8f8fdac95b40
, 0x07d085a48750738019784663bccd460656dc62c1b18964a0d27a5bd0c27ee453
, 0x10d57b1fad99da9d3fe16cf7f5dae05be844f67b2e7db3472a2e96e167578bc4
, 0x0c36c40f7bd1934b7d5525031467aa39aeaea461996a70eda5a2a704e1733bb0
, 0x0e4b65a0f3e1f9d3166a2145063c999bd08a4679676d765f4d11f97ed5c080ae
, 0x1ce5561061120d5c7ea09da2528c4c041b9ad0f05d655f38b10d79878b69f29d
, 0x2d323f651c3da8f0e0754391a10fa111b25dfa00471edf5493c44dfc3f28add6
, 0x05a0741ee5bdc3e099fd6bdad9a0865bc9ceecd13ea4e702e536dd370b8f1953
, 0x176a2ec4746fc0e0eca9e5e11d6facaee05524a92e5785c8b8161780a4435136
, 0x0691faf0f42a9ed97629b1ae0dc7f1b019c06dd852cb6efe57f7eeb1aa865aef
, 0x0e46cf138dad09d61b9a7cab95a23b5c8cb276874f3715598bacb55d5ad271de
, 0x0f18c3d95bac1ac424160d240cdffc2c44f7b6315ba65ed3ff2eff5b3e48b4f2
, 0x2eea6af14b592ec45a4119ac1e6e6f0312ecd090a096e340d472283e543ddff7
, 0x06b0d7a8f4ce97d049ae994139f5f71dca4899d4f1cd3dd83a32a89a58c0a8e6
, 0x019df0b9828eed5892dd55c1ad6408196f6293d600ef4491703a1b37e119ba8e
, 0x08ca5e3c93817cdb1c2b2a12d02c779d74c1bb12b6668f3ab3ddd7837f3a4a00
, 0x28382d747e3fd6cb2e0d8e8edd79c5313eed307a3517c11046245b1476e4f701
, 0x0ca89aecd5675b77c8271765da98cfcb6875b3053d4742c9ff502861bd16ad28
, 0x19046bc0b03ca90802ec83f212001e7ffd7f9224cfffae523451deb52eab3787
, 0x036fd7dfa1c05110b3428e6abcc43e1de9abba915320c4a600f843bfb676ca51
, 0x08f0a7abcb1a2f6595a9b7380c5028e3999db4fe5cb21892e5bb5cb11a7757ba
, 0x0b614acc1ce3fbe9048f8385e4ee24c3843deea186bacea3c904c9f6340ad8cb
, 0x00b2d98c5d988f9b41f2c98e017fc954a6ae423b2261575941f8eac8835d985c
, 0x1457f18555b7973ba5b311d57ec5d77e936980b97f5973875f1f7cc765a4fc95
, 0x002b453debc1bee525cb751bc10641a6b86f847d696418cf1144950982591bfa
, 0x0c2af1abcc6ece77218315d2af445ccbfc6647b7af2510682882cc792c6bb8cf
, 0x0e2825d9eb84b59902a1adb49ac0c2c291dee7c45d2e8c30369a4d595039e8ad
, 0x297e2e86a8c672d39f3343b8dfce7a6f20f3571bfd5c8a28e3905aa2dcfeca44
, 0x00d397281d902e49ec6504ba9186e806db9ad4fc8f86e7277aa7f1467eb6f9de
, 0x2fb7c89c372d7e2050e7377ed471000c73544a2b9fd66557f3577c09cac98b4b
, 0x16125247be4387a8c3e62490167f0cffdba02eda4f018d0b40639a13bb0cfef9
, 0x2291fd9d442f2d9b97ab22f7d4d52c2a82e41f852cf620b144612650a39e26e8
, 0x1eec61f16a275ae238540feaeeadfec56d32171b1cc393729d06f37f476fde71
, 0x259ce871ba5dacbb48d8aed3d8513eef51558dc0b360f28c1a15dbfc5e7f6ca2
, 0x2d3376a14ddbf95587e2f7567ff04fe13a3c7cb17363c8b9c5dd1d9262a210cb
, 0x13b843d9f65f4cddd7ce10d9cad9b8b99ac5e9a8c4269288173a91c0f3c3b084
, 0x0b52e9b2f1aa9fd204e4a42c481cc76c704783e34114b8e93e026a50fa9764e8
, 0x1fd083229276c7f27d3ad941476b394ff37bd44d3a1e9caca1400d9077a2056c
, 0x22743c328a6283f3ba7379af22c684c498568fd7ad9fad5151368c913197cbd9
, 0x043007aefd9741070d95caaaba0c1b070e4eec8eef8c1e512c8e579c6ed64f76
, 0x17ab175144f64bc843074f6b3a0c57c5dd2c954af8723c029ee642539496a7b3
, 0x2befcad3d53fba5eeef8cae9668fed5c1e9e596a46e8458e218f7a665fddf4eb
, 0x15151c4116d97de74bfa6ca3178f73c8fe8fe612c70c6f85a7a1551942cb71cc
, 0x2ac40bf6c3176300a6835d5fc7cc4fd5e5d299fb1baa86487268ec1b9eedfa97
, 0x0f151de1f01b4e24ffe04279318f0a68efabb485188f191e37e6915ff6059f6e
, 0x2e43dffc34537535182aebac1ad7bf0a5533b88f65f9652f0ad584e2ffc4dd1f
, 0x2ebabc2c37ef53d8b13b24a2a2b729d536735f58956125a3876da0664c2442d7
, 0x0dc3beceb34e49f5ad7226dd202c5cf879dffcc9a6dd32a300e8f2a4b59edf03
, 0x2f1ddeccce83adf68779c53b639871a8f81d4d00aefe1e812efce8ec999d457d
, 0x1f63e41280ff5c021715d52b19780298ed8bd3d5eb506316b527e24149d4d4f1
, 0x1b8c1252a5888f8cb2672effb5df49c633d3fd7183271488a1c40d0f88e7636e
, 0x0f45697130f5498e2940568ef0d5e9e16b1095a6cdbb6411df20a973c605e70b
, 0x0780ccc403cdd68983acbd34cda41cacfb2cf911a93076bc25587b4b0aed4929
, 0x238d26ca97c691591e929f32199a643550f325f23a85d420080b289d7cecc9d4
];
component sb = SBox();
sb.inp <== inp[0] + round_consts[i];
out[0] <== 2*sb.out + inp[1] + inp[2];
out[1] <== sb.out + 2*inp[1] + inp[2];
out[2] <== sb.out + inp[1] + 3*inp[2];
}
//------------------------------------------------------------------------------
// external rounds
template ExternalRound(i) {
signal input inp[3];
signal output out[3];
var round_consts[8][3] =
[ [ 0x2c4c51fd1bb9567c27e99f5712b49e0574178b41b6f0a476cddc41d242cf2b43
, 0x1c5f8d18acb9c61ec6fcbfcda5356f1b3fdee7dc22c99a5b73a2750e5b054104
, 0x2d3c1988b4541e4c045595b8d574e98a7c2820314a82e67a4e380f1c4541ba90
]
, [ 0x052547dc9e6d936cab6680372f1734c39f490d0cb970e2077c82f7e4172943d3
, 0x29d967f4002adcbb5a6037d644d36db91f591b088f69d9b4257694f5f9456bc2
, 0x0350084b8305b91c426c25aeeecafc83fc5feec44b9636cb3b17d2121ec5b88a
]
, [ 0x1815d1e52a8196127530cc1e79f07a0ccd815fb5d94d070631f89f6c724d4cbe
, 0x17b5ba882530af5d70466e2b434b0ccb15b7a8c0138d64455281e7724a066272
, 0x1c859b60226b443767b73cd1b08823620de310bc49ea48662626014cea449aee
]
, [ 0x1b26e7f0ac7dd8b64c2f7a1904c958bb48d2635478a90d926f5ff2364effab37
, 0x2da7f36850e6c377bdcdd380efd9e7c419555d3062b0997952dfbe5c54b1a22e
, 0x17803c56450e74bc6c7ff97275390c017f682db11f3f4ca6e1f714efdfb9bd66
]
, [ 0x25672a14b5d085e31a30a7e1d5675ebfab034fb04dc2ec5e544887523f98dede
, 0x0cf702434b891e1b2f1d71883506d68cdb1be36fa125674a3019647b3a98accd
, 0x1837e75235ff5d112a5eddf7a4939448748339e7b5f2de683cf0c0ae98bdfbb3
]
, [ 0x1cd8a14cff3a61f04197a083c6485581a7d836941f6832704837a24b2d15613a
, 0x266f6d85be0cef2ece525ba6a54b647ff789785069882772e6cac8131eecc1e4
, 0x0538fde2183c3f5833ecd9e07edf30fe977d28dd6f246d7960889d9928b506b3
]
, [ 0x07a0693ff41476abb4664f3442596aa8399fdccf245d65882fce9a37c268aa04
, 0x11eb49b07d33de2bd60ea68e7f652beda15644ed7855ee5a45763b576d216e8e
, 0x08f8887da6ce51a8c06041f64e22697895f34bacb8c0a39ec12bf597f7c67cfc
]
, [ 0x2a912ec610191eb7662f86a52cc64c0122bd5ba762e1db8da79b5949fdd38092
, 0x2031d7fd91b80857aa1fef64e23cfad9a9ba8fe8c8d09de92b1edb592a44c290
, 0x0f81ebce43c47711751fa64d6c007221016d485641c28c507d04fd3dc7fba1d2
]
];
component sb[3];
for(var j=0; j<3; j++) {
sb[j] = SBox();
sb[j].inp <== inp[j] + round_consts[i][j];
}
out[0] <== 2*sb[0].out + sb[1].out + sb[2].out;
out[1] <== sb[0].out + 2*sb[1].out + sb[2].out;
out[2] <== sb[0].out + sb[1].out + 2*sb[2].out;
}
//------------------------------------------------------------------------------
// the initial linear layer
template LinearLayer() {
signal input inp[3];
signal output out[3];
out[0] <== 2*inp[0] + inp[1] + inp[2];
out[1] <== inp[0] + 2*inp[1] + inp[2];
out[2] <== inp[0] + inp[1] + 2*inp[2];
}
//------------------------------------------------------------------------------
// the Poseidon2 permutation for t=3
template Permutation() {
signal input inp[3];
signal output out[3];
signal aux[65][3];
component ll = LinearLayer();
for(var j=0; j<3; j++) { ll.inp[j] <== inp[j]; }
for(var j=0; j<3; j++) { ll.out[j] ==> aux[0][j]; }
component ext[8];
for(var k=0; k<8; k++) { ext[k] = ExternalRound(k); }
component int[56];
for(var k=0; k<56; k++) { int[k] = InternalRound(k); }
// first 4 external rounds
for(var k=0; k<4; k++) {
for(var j=0; j<3; j++) { ext[k].inp[j] <== aux[k ][j]; }
for(var j=0; j<3; j++) { ext[k].out[j] ==> aux[k+1][j]; }
}
// the 56 internal rounds
for(var k=0; k<56; k++) {
for(var j=0; j<3; j++) { int[k].inp[j] <== aux[k+4][j]; }
for(var j=0; j<3; j++) { int[k].out[j] ==> aux[k+5][j]; }
}
// last 4 external rounds
for(var k=0; k<4; k++) {
for(var j=0; j<3; j++) { ext[k+4].inp[j] <== aux[k+60][j]; }
for(var j=0; j<3; j++) { ext[k+4].out[j] ==> aux[k+61][j]; }
}
for(var j=0; j<3; j++) { out[j] <== aux[64][j]; }
}
//------------------------------------------------------------------------------

38
circuit/rln_proof.circom Normal file
View File

@ -0,0 +1,38 @@
pragma circom 2.1.1;
include "merkle.circom";
include "poseidon2/compression.circom";
include "misc.circom";
template RLN(limit_bits, merkle_depth) {
// public input
signal input msg_hash; // x = hash of the message
signal input ext_null; // the external "nullifier" ext = H(protocol|epoch)
signal input merkle_root; // the Merkle root we check against
// private input
signal input secret_key; // the user's secret key
signal input msg_limit; // message limit per epoch
signal input msg_idx; // the message index (should be 0 <= msg_idx < msg_limit)
signal input leaf_idx; // leaf index in the Merkle tree
signal input merkle_path[merkle_depth]; // the Merkle inclusion proof
// public output
signal output y_value; // the value y = sk + x * a1
signal output local_null; // the "epoch-local nullifier" null = H(a1) (to detect repeated a1)
// computations
signal pk <== Compress()( secret_key , msg_limit ); // public key - this doesn't ever change
signal pre_a1 <== Compress()( secret_key , ext_null ); // H(sk|ext) - this doesn't change often
signal a1 <== Compress()( pre_a1 , msg_idx ); // a1 = H(H(sk|ext)|j)
local_null <== Compress()( a1 , 0 ); // H(a1);
y_value <== secret_key + msg_hash * a1; // y = sk + x*a1
// checks
RangeCheck (limit_bits) ( msg_idx , msg_limit ); // range check for the message index
MerkleCheck(merkle_depth)( pk , leaf_idx , merkle_path , merkle_root ); // Merkle inclusion proof check
}