@coordination-network/revocation-registry 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,454 @@
1
+ import { poseidon2 } from 'poseidon-lite/poseidon2';
2
+ import { poseidon3 } from 'poseidon-lite/poseidon3';
3
+ import { LeanIMTPlus } from '@zk-kit/lean-imt-plus';
4
+
5
+ /**
6
+ * The BN254 (alt_bn128) scalar field order. Every commitment stored in a
7
+ * revocation registry, and every hash the tree produces, is an element of this
8
+ * field. It matches `SNARK_SCALAR_FIELD` in the LeanIMT+ Solidity library and
9
+ * the field a BN254 zero-knowledge circuit works over.
10
+ */
11
+ const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
12
+ /**
13
+ * Domain-separation tag mixed into every leaf commitment as the third input of
14
+ * the 3-input leaf hash. Kept here so callers can reproduce a leaf commitment
15
+ * without depending on LeanIMT+ internals.
16
+ */
17
+ const TAG_LEAF = 1n;
18
+ /**
19
+ * Proof discriminator. `MEMBERSHIP` proves the commitment is in the registry
20
+ * (the credential is revoked); `NON_MEMBERSHIP` proves it is absent (the
21
+ * credential is not revoked). A holder proving a valid credential always
22
+ * produces a `NON_MEMBERSHIP` proof.
23
+ */
24
+ const PROOF_TYPE = {
25
+ MEMBERSHIP: 0,
26
+ NON_MEMBERSHIP: 1
27
+ };
28
+ /**
29
+ * Physical index of the sentinel leaf, which the first revocation creates. The
30
+ * sentinel holds `{ value: 0, nextValue: firstRealCommitment }` and is the
31
+ * predecessor of the smallest commitment in the registry.
32
+ */
33
+ const SENTINEL_INDEX = 0;
34
+ /**
35
+ * Version tag of the snapshot format produced by `export` and accepted by
36
+ * `import`. It must match the version the underlying LeanIMT+ writes, which
37
+ * enforces it on import.
38
+ */
39
+ const SNAPSHOT_VERSION = 1;
40
+
41
+ /**
42
+ * The hash pair every revocation registry uses, in every language.
43
+ *
44
+ * - `leaf` is a 3-input Poseidon over `(value, nextValue, TAG_LEAF)`. It matches
45
+ * `PoseidonT4` in the LeanIMT+ Solidity library and `poseidon::bn254::hash_3`
46
+ * in a BN254 Poseidon circuit.
47
+ * - `internal` is a 2-input Poseidon for inner nodes. It matches `PoseidonT3`
48
+ * and `poseidon::bn254::hash_2`.
49
+ *
50
+ * The differing arity is what domain-separates a leaf commitment from an
51
+ * internal-node hash, which closes the second-preimage attack in which an
52
+ * attacker repackages an internal node as a leaf. All three implementations
53
+ * must agree on this pair or their roots will not match.
54
+ */
55
+ const revocationHashes = {
56
+ leaf: (a, b, c) => poseidon3([a, b, c]),
57
+ internal: (a, b) => poseidon2([a, b])
58
+ };
59
+
60
+ /**
61
+ * Coerces a value to a BN254 field element, rejecting anything outside the
62
+ * field.
63
+ *
64
+ * Every leaf field and every hash the registry produces must be a field element:
65
+ * outside that range two distinct values reduce to the same element, which would
66
+ * let one revocation silently cover an unrelated credential and would break the
67
+ * sorted ordering a non-membership proof relies on.
68
+ *
69
+ * Zero is accepted, because the sentinel and tombstone leaf slots hold it. Use
70
+ * {@link toCommitment} for anything that must be a real commitment.
71
+ *
72
+ * @param value The value, as a bigint, number or decimal string.
73
+ * @returns The value as a bigint.
74
+ */
75
+ function toFieldElement(value) {
76
+ const element = BigInt(value);
77
+ if (element < 0n) {
78
+ throw new Error("RevocationRegistry: a field element must not be negative");
79
+ }
80
+ if (element >= SNARK_SCALAR_FIELD) {
81
+ throw new Error("RevocationRegistry: a field element must be less than the SNARK scalar field order");
82
+ }
83
+ return element;
84
+ }
85
+ /**
86
+ * Coerces a value to a revocation commitment, rejecting anything the registry
87
+ * cannot hold as one.
88
+ *
89
+ * A commitment must be a field element, for the reason given on
90
+ * {@link toFieldElement}, and must additionally be non-zero, because zero is
91
+ * reserved for the sentinel leaf and for tombstones.
92
+ *
93
+ * These are the rules of the structure itself, so every implementation of a
94
+ * registry enforces them.
95
+ *
96
+ * @param value The commitment, as a bigint, number or decimal string.
97
+ * @returns The commitment as a bigint.
98
+ */
99
+ function toCommitment(value) {
100
+ const commitment = BigInt(value);
101
+ if (commitment <= 0n) {
102
+ throw new Error("RevocationRegistry: a commitment must be greater than zero");
103
+ }
104
+ return toFieldElement(commitment);
105
+ }
106
+
107
+ /**
108
+ * A revocation registry: a
109
+ * {@link https://pse.dev/blog/lean-imt-plus-efficient-merkle-tree-for-membership-and-non-membership-proofs | LeanIMT+}
110
+ * whose leaves are the revocation commitments of revoked credentials.
111
+ *
112
+ * The class is a self-contained implementation of the structure, not a client
113
+ * for any particular backend. It can hold a registry outright, which is what a
114
+ * service that publishes revocations from its own database does, or mirror one
115
+ * whose authoritative copy lives elsewhere, which is what a holder does before
116
+ * building a proof. Nothing here reads or writes anything external.
117
+ *
118
+ * Two operations serve the two roles:
119
+ *
120
+ * - **Issuer.** {@link revoke} inserts a commitment. Where the authoritative
121
+ * registry is a separate system that validates a predecessor index rather than
122
+ * searching for one, {@link lowLeafIndexOf} computes that index.
123
+ * - **Holder.** {@link generateProof} produces a proof about a commitment. A
124
+ * non-membership proof (`proofType: 1`) is what shows a credential is not
125
+ * revoked, and is the witness a zero-knowledge circuit consumes to show it
126
+ * without revealing the commitment.
127
+ *
128
+ * When this instance is a mirror rather than the authority, it is trusted for
129
+ * availability only, never for integrity: compare {@link root} against the root
130
+ * the authority reports before using any proof built from it. Building the tree
131
+ * locally is also what keeps a holder private, since they read the whole public
132
+ * registry and never disclose which commitment they care about.
133
+ *
134
+ * An empty registry has a root of `0` and cannot produce proofs: nothing has
135
+ * been revoked yet, so there is nothing to prove against. Verifiers must handle
136
+ * that case as "no revocations", not as a failure.
137
+ */
138
+ class RevocationRegistry {
139
+ /**
140
+ * Creates a registry, optionally pre-loaded with revoked commitments.
141
+ * Passing the full list is more efficient than revoking them one by one,
142
+ * because each affected internal node is rehashed at most once.
143
+ *
144
+ * The tree is position-addressable, so a different insertion order produces
145
+ * a different root even for the same set of commitments. When rebuilding a
146
+ * registry that already exists elsewhere, the commitments must be supplied
147
+ * in the order that registry inserted them.
148
+ *
149
+ * This reconstructs a registry only while nothing has ever been un-revoked.
150
+ * An un-revocation leaves a tombstoned slot behind, and that slot still
151
+ * occupies a Merkle position, so replaying the surviving commitments would
152
+ * produce a different root. Use {@link RevocationRegistry.fromLeaves} or
153
+ * {@link RevocationRegistry.import} for a registry that may have tombstones.
154
+ *
155
+ * @param commitments Revoked commitments, in insertion order.
156
+ */
157
+ constructor(commitments = []) {
158
+ this.leanIMTPlus = new LeanIMTPlus(revocationHashes, commitments.map(toCommitment));
159
+ }
160
+ /** The current revocation root. `0` while the registry is empty. */
161
+ get root() {
162
+ return this.leanIMTPlus.root ?? 0n;
163
+ }
164
+ /** The current tree depth. */
165
+ get depth() {
166
+ return this.leanIMTPlus.depth;
167
+ }
168
+ /** The number of revoked commitments, excluding the sentinel and tombstones. */
169
+ get size() {
170
+ return this.leanIMTPlus.size;
171
+ }
172
+ /**
173
+ * The revoked commitments in insertion order. Reading `.value` off each leaf
174
+ * gives the commitments; `.nextValue` exposes the sorted linked list the
175
+ * non-membership proof relies on.
176
+ */
177
+ get leaves() {
178
+ return this.leanIMTPlus.leaves;
179
+ }
180
+ /**
181
+ * The revoked commitments, in insertion order. Excludes the sentinel and any
182
+ * tombstones, so this is a lossy view of the tree: it round-trips through the
183
+ * constructor only while nothing has been un-revoked. See
184
+ * {@link leafSlots}.
185
+ */
186
+ get commitments() {
187
+ return this.leanIMTPlus.leaves.map((leaf) => leaf.value);
188
+ }
189
+ /**
190
+ * Every physical leaf slot in index order, sentinel and tombstones included.
191
+ *
192
+ * This is the lossless view of the registry and the counterpart of
193
+ * {@link RevocationRegistry.fromLeaves}: an authority publishes these slots,
194
+ * and anyone can rebuild the exact same tree from them. It mirrors what the
195
+ * on-chain registry exposes through `getLeaf`.
196
+ *
197
+ * Prefer {@link commitments} to enumerate what is revoked. This walks the
198
+ * whole tree, so treat it as a publishing operation rather than a hot path.
199
+ */
200
+ get leafSlots() {
201
+ const { leaves } = JSON.parse(this.export());
202
+ return leaves.map(({ value, nextValue }) => ({ value: BigInt(value), nextValue: BigInt(nextValue) }));
203
+ }
204
+ /** Returns true if the commitment has been revoked. */
205
+ isRevoked(commitment) {
206
+ return this.leanIMTPlus.has(toCommitment(commitment));
207
+ }
208
+ /**
209
+ * Returns the physical leaf index of a revoked commitment, or `-1` if it is
210
+ * not revoked.
211
+ *
212
+ * This is a slot in the tree, never a position in {@link commitments}. The
213
+ * two always differ by at least one, because slot 0 is the sentinel, and
214
+ * they drift further apart with every tombstone.
215
+ */
216
+ indexOf(commitment) {
217
+ return this.leanIMTPlus.indexOf(toCommitment(commitment));
218
+ }
219
+ /**
220
+ * Revokes a credential by inserting its commitment.
221
+ *
222
+ * @param commitment The revocation commitment of the credential.
223
+ */
224
+ revoke(commitment) {
225
+ this.leanIMTPlus.insert(toCommitment(commitment));
226
+ }
227
+ /**
228
+ * Revokes several credentials in one pass. Cheaper than calling
229
+ * {@link revoke} once per commitment, because each affected internal node is
230
+ * rehashed at most once.
231
+ *
232
+ * Not transactional: if a commitment mid-batch is rejected, the earlier ones
233
+ * have already been applied. Validate with {@link isRevoked} first when the
234
+ * input is untrusted.
235
+ *
236
+ * @param commitments The revocation commitments, in insertion order.
237
+ */
238
+ revokeMany(commitments) {
239
+ this.leanIMTPlus.insertMany(commitments.map(toCommitment));
240
+ }
241
+ /**
242
+ * Un-revokes a credential, tombstoning its slot.
243
+ *
244
+ * Revocation is normally permanent, and a registry that never un-revokes is
245
+ * the simpler thing to reason about: an un-revoked credential becomes valid
246
+ * again for every verifier that has not yet seen the new root. The operation
247
+ * is exposed because the underlying tree supports it and some issuers need
248
+ * to undo a mistaken revocation, but reach for it deliberately.
249
+ *
250
+ * It also leaves a tombstoned slot behind, after which the registry can no
251
+ * longer be rebuilt from {@link commitments} alone. Publish the leaf slots
252
+ * (see {@link RevocationRegistry.fromLeaves}) or a snapshot instead.
253
+ *
254
+ * @param commitment The revocation commitment to un-revoke.
255
+ */
256
+ unrevoke(commitment) {
257
+ this.leanIMTPlus.remove(toCommitment(commitment));
258
+ }
259
+ /**
260
+ * Replaces a revoked commitment with another one, in place, reusing the old
261
+ * physical slot. Cheaper than an {@link unrevoke} followed by a
262
+ * {@link revoke}, which would tombstone a slot and append a new one.
263
+ *
264
+ * @param oldCommitment The revoked commitment to replace.
265
+ * @param newCommitment The commitment to put in its place.
266
+ */
267
+ updateCommitment(oldCommitment, newCommitment) {
268
+ this.leanIMTPlus.update(toCommitment(oldCommitment), toCommitment(newCommitment));
269
+ }
270
+ /**
271
+ * Generates a proof for a commitment: a non-membership proof
272
+ * (`proofType: 1`) if the credential is not revoked, a membership proof
273
+ * (`proofType: 0`) if it is.
274
+ *
275
+ * The proof carries the root it was built against. It proves nothing until
276
+ * the verifier independently accepts that root as one the authoritative
277
+ * registry currently publishes.
278
+ *
279
+ * @param commitment The revocation commitment of the credential.
280
+ * @returns The revocation proof.
281
+ */
282
+ generateProof(commitment) {
283
+ if (this.root === 0n) {
284
+ throw new Error("RevocationRegistry: cannot generate a proof against an empty registry");
285
+ }
286
+ return this.leanIMTPlus.generateProof(toCommitment(commitment));
287
+ }
288
+ /**
289
+ * Verifies a proof against this tree's current root. Use the static
290
+ * {@link RevocationRegistry.verifyProof} to check a proof without a local tree.
291
+ */
292
+ verifyProof(proof) {
293
+ return proof.root === this.root && this.leanIMTPlus.verifyProof(proof);
294
+ }
295
+ /**
296
+ * Verifies that a proof is internally consistent with the root it carries.
297
+ *
298
+ * This does not bind the proof to any registry: the caller supplies
299
+ * `proof.root`, so on its own this returns true for a proof against an
300
+ * attacker-chosen root. Always compare `proof.root` against a root you
301
+ * independently accept.
302
+ */
303
+ static verifyProof(proof) {
304
+ return LeanIMTPlus.verifyProof(proof, revocationHashes);
305
+ }
306
+ /**
307
+ * Returns the physical index of the low leaf of a commitment: the
308
+ * predecessor a constrained implementation needs supplied rather than
309
+ * searched for.
310
+ *
311
+ * {@link revoke} does not need this, since it finds the predecessor itself.
312
+ * It exists for registries whose authoritative implementation validates a
313
+ * caller-supplied index in O(1) instead of searching, which is how a version
314
+ * that has to bound its own cost per write is built. Such an index is only
315
+ * valid against the tree state the write applies to, so if another revocation
316
+ * lands first it must be recomputed against the new state.
317
+ *
318
+ * @param commitment The commitment about to be revoked.
319
+ * @returns The physical index of its predecessor, or `0` for the first
320
+ * revocation, where the argument is ignored and the sentinel is created.
321
+ */
322
+ lowLeafIndexOf(commitment) {
323
+ const value = toCommitment(commitment);
324
+ if (this.root === 0n) {
325
+ return SENTINEL_INDEX;
326
+ }
327
+ const proof = this.leanIMTPlus.generateProof(value);
328
+ if (proof.proofType === PROOF_TYPE.MEMBERSHIP) {
329
+ throw new Error("RevocationRegistry: the commitment has already been revoked");
330
+ }
331
+ // Not `proof.leafIndex`: that is the compacted Merkle path, which skips the
332
+ // levels where the leaf's ancestor had no sibling, so it does not equal the
333
+ // physical slot. Resolve the slot from the low leaf's value instead. A low
334
+ // leaf holding zero is the sentinel, which always sits at index 0.
335
+ return proof.leaf.value === 0n ? SENTINEL_INDEX : this.leanIMTPlus.indexOf(proof.leaf.value);
336
+ }
337
+ /**
338
+ * Returns the low leaf indices for a batch of commitments, as a batched
339
+ * write expects them: index `i` is computed against the tree state after
340
+ * commitments `0..i-1` have been inserted.
341
+ *
342
+ * Computed on a throwaway copy, so this tree is left untouched.
343
+ *
344
+ * @param commitments The commitments about to be revoked, in insertion order.
345
+ */
346
+ lowLeafIndicesOf(commitments) {
347
+ const scratch = RevocationRegistry.import(this.export(), false);
348
+ return commitments.map((commitment) => {
349
+ const index = scratch.lowLeafIndexOf(commitment);
350
+ scratch.revoke(commitment);
351
+ return index;
352
+ });
353
+ }
354
+ /**
355
+ * Returns the physical index of the leaf whose `nextValue` is this
356
+ * commitment. The counterpart of {@link lowLeafIndexOf} for un-revoking and
357
+ * for replacing a commitment.
358
+ *
359
+ * Returns `0` when the commitment is the smallest in the registry, because
360
+ * the sentinel at index 0 is then its predecessor.
361
+ *
362
+ * @param commitment A revoked commitment.
363
+ */
364
+ predecessorIndexOf(commitment) {
365
+ const value = toCommitment(commitment);
366
+ if (!this.leanIMTPlus.has(value)) {
367
+ throw new Error("RevocationRegistry: the commitment has not been revoked");
368
+ }
369
+ const predecessor = this.leanIMTPlus.leaves.find((leaf) => leaf.nextValue === value);
370
+ return predecessor === undefined ? SENTINEL_INDEX : this.leanIMTPlus.indexOf(predecessor.value);
371
+ }
372
+ /**
373
+ * Serializes the whole tree, hashes included.
374
+ *
375
+ * This is the snapshot format: publishing a snapshot lets a holder rebuild a
376
+ * large registry without replaying every revocation since genesis. A snapshot
377
+ * is trusted for availability only, so an importing client must still compare
378
+ * {@link root} against the root the authoritative registry publishes.
379
+ */
380
+ export() {
381
+ return this.leanIMTPlus.export();
382
+ }
383
+ /**
384
+ * Rebuilds a tree from {@link export} output.
385
+ *
386
+ * @param snapshot The serialized tree.
387
+ * @param validate Recompute every commitment and check it against the
388
+ * serialized hashes, rejecting a tampered snapshot. Defaults to `true`; pass
389
+ * `false` only for data you produced yourself.
390
+ */
391
+ static import(snapshot, validate = true) {
392
+ const tree = new RevocationRegistry();
393
+ tree.leanIMTPlus = LeanIMTPlus.import(revocationHashes, snapshot, { validate });
394
+ return tree;
395
+ }
396
+ /**
397
+ * Rebuilds a registry from its full physical leaf slots, sentinel and
398
+ * tombstones included.
399
+ *
400
+ * This is the reconstruction path that always works. The constructor takes
401
+ * only the active commitments, which is enough only while no commitment has
402
+ * ever been un-revoked: an un-revocation leaves a tombstoned slot behind, and
403
+ * a slot still occupies a Merkle position, so a registry that has one cannot
404
+ * be reproduced from its commitments alone.
405
+ *
406
+ * The leaf slots are exactly what an authoritative registry can publish
407
+ * verbatim: {@link leafSlots} here, or `getLeaf(registryId, i)` for `i` in
408
+ * `0..getRevocationTreeLeafCount(registryId) - 1` on chain.
409
+ *
410
+ * Hashes are recomputed here rather than taken on trust, so unlike a snapshot
411
+ * the input carries no node data to tamper with. The caller must still
412
+ * compare {@link root} against the root the authority publishes, which is
413
+ * what detects missing or reordered slots.
414
+ *
415
+ * @param leaves The physical leaf slots, in index order starting at the
416
+ * sentinel.
417
+ * @returns The reconstructed registry.
418
+ */
419
+ static fromLeaves(leaves) {
420
+ if (leaves.length === 0) {
421
+ return new RevocationRegistry();
422
+ }
423
+ const records = leaves.map(({ value, nextValue }) => ({
424
+ value: toFieldElement(value),
425
+ nextValue: toFieldElement(nextValue)
426
+ }));
427
+ if (records[0].value !== 0n) {
428
+ throw new Error("RevocationRegistry: the leaf at index 0 must be the sentinel, whose value is zero");
429
+ }
430
+ // Rebuild every level of the LeanIMT, promoting an unpaired node unchanged.
431
+ const nodes = [records.map((leaf) => revocationHashes.leaf(leaf.value, leaf.nextValue, TAG_LEAF))];
432
+ while (nodes[nodes.length - 1].length > 1) {
433
+ const level = nodes[nodes.length - 1];
434
+ const parents = [];
435
+ for (let i = 0; i < level.length; i += 2) {
436
+ parents.push(i + 1 < level.length ? revocationHashes.internal(level[i], level[i + 1]) : level[i]);
437
+ }
438
+ nodes.push(parents);
439
+ }
440
+ const snapshot = JSON.stringify({
441
+ version: SNAPSHOT_VERSION,
442
+ nodes: nodes.map((level) => level.map(String)),
443
+ leaves: records.map(({ value, nextValue }) => ({
444
+ value: value.toString(),
445
+ nextValue: nextValue.toString()
446
+ }))
447
+ });
448
+ // The node hashes were just derived from the leaves, so re-validating them
449
+ // would be circular. Integrity comes from comparing the root to the authority.
450
+ return RevocationRegistry.import(snapshot, false);
451
+ }
452
+ }
453
+
454
+ export { PROOF_TYPE, RevocationRegistry, SNAPSHOT_VERSION, SNARK_SCALAR_FIELD, TAG_LEAF, revocationHashes, toCommitment, toFieldElement };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@coordination-network/revocation-registry",
3
+ "version": "0.0.1",
4
+ "description": "LeanIMT+ revocation registry in TypeScript: revoke credential commitments and generate membership and non-membership proofs, as the registry itself or as a mirror of one held elsewhere.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/revocation-registry"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/revocation-registry",
12
+ "bugs": "https://github.com/zk-kit/coordination-network/issues",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "main": "dist/index.cjs",
16
+ "module": "dist/index.js",
17
+ "types": "types/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./types/index.d.ts",
21
+ "require": "./dist/index.cjs",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist/",
27
+ "types/",
28
+ "src/",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "rimraf dist types && rollup -c rollup.config.mjs"
37
+ },
38
+ "dependencies": {
39
+ "@zk-kit/lean-imt-plus": "0.1.2",
40
+ "poseidon-lite": "0.3.0"
41
+ },
42
+ "devDependencies": {
43
+ "@rollup/plugin-typescript": "^12.3.0",
44
+ "rimraf": "^6.1.3",
45
+ "rollup": "^4.62.2",
46
+ "rollup-plugin-cleanup": "^3.2.1",
47
+ "rollup-plugin-dts": "^6.4.1",
48
+ "tslib": "^2.8.0",
49
+ "typescript": "^6.0.3"
50
+ }
51
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The BN254 (alt_bn128) scalar field order. Every commitment stored in a
3
+ * revocation registry, and every hash the tree produces, is an element of this
4
+ * field. It matches `SNARK_SCALAR_FIELD` in the LeanIMT+ Solidity library and
5
+ * the field a BN254 zero-knowledge circuit works over.
6
+ */
7
+ export const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
8
+
9
+ /**
10
+ * Domain-separation tag mixed into every leaf commitment as the third input of
11
+ * the 3-input leaf hash. Kept here so callers can reproduce a leaf commitment
12
+ * without depending on LeanIMT+ internals.
13
+ */
14
+ export const TAG_LEAF = 1n
15
+
16
+ /**
17
+ * Proof discriminator. `MEMBERSHIP` proves the commitment is in the registry
18
+ * (the credential is revoked); `NON_MEMBERSHIP` proves it is absent (the
19
+ * credential is not revoked). A holder proving a valid credential always
20
+ * produces a `NON_MEMBERSHIP` proof.
21
+ */
22
+ export const PROOF_TYPE = {
23
+ MEMBERSHIP: 0,
24
+ NON_MEMBERSHIP: 1
25
+ } as const
26
+
27
+ /**
28
+ * Physical index of the sentinel leaf, which the first revocation creates. The
29
+ * sentinel holds `{ value: 0, nextValue: firstRealCommitment }` and is the
30
+ * predecessor of the smallest commitment in the registry.
31
+ */
32
+ export const SENTINEL_INDEX = 0
33
+
34
+ /**
35
+ * Version tag of the snapshot format produced by `export` and accepted by
36
+ * `import`. It must match the version the underlying LeanIMT+ writes, which
37
+ * enforces it on import.
38
+ */
39
+ export const SNAPSHOT_VERSION = 1
package/src/hashes.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { LeanIMTPlusHashFunctions } from "@zk-kit/lean-imt-plus"
2
+ import { poseidon2 } from "poseidon-lite/poseidon2"
3
+ import { poseidon3 } from "poseidon-lite/poseidon3"
4
+
5
+ /**
6
+ * The hash pair every revocation registry uses, in every language.
7
+ *
8
+ * - `leaf` is a 3-input Poseidon over `(value, nextValue, TAG_LEAF)`. It matches
9
+ * `PoseidonT4` in the LeanIMT+ Solidity library and `poseidon::bn254::hash_3`
10
+ * in a BN254 Poseidon circuit.
11
+ * - `internal` is a 2-input Poseidon for inner nodes. It matches `PoseidonT3`
12
+ * and `poseidon::bn254::hash_2`.
13
+ *
14
+ * The differing arity is what domain-separates a leaf commitment from an
15
+ * internal-node hash, which closes the second-preimage attack in which an
16
+ * attacker repackages an internal node as a leaf. All three implementations
17
+ * must agree on this pair or their roots will not match.
18
+ */
19
+ export const revocationHashes: LeanIMTPlusHashFunctions<bigint> = {
20
+ leaf: (a, b, c) => poseidon3([a, b, c]),
21
+ internal: (a, b) => poseidon2([a, b])
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { PROOF_TYPE, SNAPSHOT_VERSION, SNARK_SCALAR_FIELD, TAG_LEAF } from "./constants"
2
+ export { revocationHashes } from "./hashes"
3
+ export { RevocationRegistry } from "./revocation-registry"
4
+ export type { BigNumberish, RevocationLeaf, RevocationProof } from "./types"
5
+ export { toCommitment, toFieldElement } from "./utils"