@coordination-network/issuer-set 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethereum Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @coordination-network/issuer-set
2
+
3
+ The approved-issuer set in TypeScript: a [LeanIMT](https://github.com/zk-kit/zk-kit) accumulator over the credential signing keys of approved network members, mirroring `ApprovedIssuerSet.sol`.
4
+
5
+ A verifier needs to know that whoever signed a credential was an approved issuer at the time. The set answers that with a single Merkle proof against a root everyone agrees on, and that proof is the witness a zero-knowledge circuit consumes to show the same thing without revealing which issuer signed.
6
+
7
+ Keys are Baby Jubjub public keys in affine form, `[x, y]`. Leaves and internal nodes are Poseidon hashes over the BN254 scalar field, domain-separated by `ISSUER_LEAF_DOMAIN`, which is what keeps this implementation, the Solidity one and the circuit in agreement.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ yarn add @coordination-network/issuer-set
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Building the set and proving membership
18
+
19
+ ```ts
20
+ import { ApprovedIssuerSet } from "@coordination-network/issuer-set"
21
+
22
+ const set = new ApprovedIssuerSet(publicKeys)
23
+
24
+ if (!set.isApproved(issuerKey)) throw new Error("issuer is not approved")
25
+
26
+ const witness = set.generateProof(issuerKey)
27
+
28
+ // witness.root is what a verifier pins against the authority
29
+ ```
30
+
31
+ Order matters. The tree is position-addressable, so the same keys inserted in a different order produce a different root. When rebuilding a set that already exists, supply the keys in the order that set published them.
32
+
33
+ ### Rebuilding from the authority
34
+
35
+ The on-chain set is the authoritative copy.
36
+
37
+ ```ts
38
+ const set = await ApprovedIssuerSet.fromContract({
39
+ getLeafCount: () => contract.read.getLeafCount(),
40
+ getLeaves: (start, end) => contract.read.getLeaves([start, end])
41
+ })
42
+ ```
43
+
44
+ ### Publishing and retiring keys
45
+
46
+ ```ts
47
+ const leaf = set.publish(issuerKey) // curve-validated, unlike the constructor
48
+ set.publishMany(issuerKeys)
49
+ set.retire(issuerKey) // tombstones the slot; leafCount never shrinks
50
+ ```
51
+
52
+ ### Verifying a proof
53
+
54
+ ```ts
55
+ set.verifyProof(witness) // against this set's current root
56
+ ApprovedIssuerSet.verifyProof(witness) // against the root the witness carries
57
+ ```
58
+
59
+ ## Notes
60
+
61
+ - The set must not be empty, and it is capped at `2 ** ISSUER_SET_MAX_DEPTH` (depth 20) because that is the circuit's `binary_merkle_root` capacity. The tree's actual depth is dynamic and tracks `ceil(log2(leafCount))`.
package/dist/index.cjs ADDED
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+
3
+ var leanImt = require('@zk-kit/lean-imt');
4
+ var poseidon2 = require('poseidon-lite/poseidon2');
5
+ var poseidon3 = require('poseidon-lite/poseidon3');
6
+ var babyJubjub = require('@zk-kit/baby-jubjub');
7
+
8
+ /**
9
+ * The BN254 (alt_bn128) scalar field order. Every leaf and internal-node hash,
10
+ * and every signing-key coordinate, is an element of this field.
11
+ */
12
+ const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
13
+ /**
14
+ * Baby Jubjub's cofactor. A point has low order exactly when multiplying it by
15
+ * this gives the identity, which is what disqualifies a signing key: see
16
+ * `toPublicKey`.
17
+ */
18
+ const COFACTOR = 8n;
19
+ /**
20
+ * The tree's capacity bound: spec 03's construction and the circuit's
21
+ * `binary_merkle_root`. The tree's actual depth is dynamic; see
22
+ * `ApprovedIssuerSet.depth`.
23
+ */
24
+ const ISSUER_SET_MAX_DEPTH = 20;
25
+ /**
26
+ * Domain separator mixed into every leaf commitment, matching
27
+ * `IssuerSetHashes.ISSUER_LEAF_DOMAIN` in Solidity and `LEAF_DOMAIN` in the
28
+ * circuit.
29
+ */
30
+ const ISSUER_LEAF_DOMAIN = 185404551131306766954100762329350135288498146007932686448214248919067924295n;
31
+
32
+ /**
33
+ * Commits a Baby Jubjub public key to the leaf value the tree stores for it.
34
+ * Matches `IssuerSetHashes.issuerKeyLeaf` in Solidity (`PoseidonT4`) and the
35
+ * leaf hash in `packages/circuits/src/issuer.nr`; all three must agree or a
36
+ * proof built against one never verifies against a root from another.
37
+ */
38
+ function issuerKeyLeaf(publicKey) {
39
+ return poseidon3.poseidon3([ISSUER_LEAF_DOMAIN, publicKey[0], publicKey[1]]);
40
+ }
41
+ /** Alias for the name `packages/credential`'s re-export carries forward. */
42
+ const issuerLeaf = issuerKeyLeaf;
43
+ /** Combines two child nodes into their parent. Matches `IssuerSetHashes.internalNode` (`PoseidonT3`). */
44
+ function internalNode(left, right) {
45
+ return poseidon2.poseidon2([left, right]);
46
+ }
47
+
48
+ /**
49
+ * Coerces a value to a BN254 field element, rejecting anything outside the
50
+ * field.
51
+ *
52
+ * Every leaf and every hash the set produces must be a field element: outside
53
+ * that range two distinct values reduce to the same element, so one key's
54
+ * membership proof would open against another's leaf.
55
+ *
56
+ * Zero is accepted, because a tombstoned or never-written slot holds it.
57
+ *
58
+ * @param value The value, as a bigint, number or decimal string.
59
+ * @returns The value as a bigint.
60
+ */
61
+ function toFieldElement(value) {
62
+ const element = BigInt(value);
63
+ if (element < 0n) {
64
+ throw new Error("ApprovedIssuerSet: a field element must not be negative");
65
+ }
66
+ if (element >= SNARK_SCALAR_FIELD) {
67
+ throw new Error("ApprovedIssuerSet: a field element must be less than the SNARK scalar field order");
68
+ }
69
+ return element;
70
+ }
71
+ /**
72
+ * Coerces a pair to a Baby Jubjub public key of full order, rejecting a point
73
+ * that is not on the curve or that lies in the small-order subgroup.
74
+ *
75
+ * The set would happily commit to any two field elements, so without the curve
76
+ * check a caller could publish a commitment to a point that does not exist.
77
+ *
78
+ * The order check matters more. Under a low-order public key `A` the term `h·A`
79
+ * in EdDSA verification takes at most eight values whatever the message, so
80
+ * anyone can solve `S·B = R + h·A` for a signature over a claim tree they chose;
81
+ * at the identity it is immediate. Publishing such a key would hand every
82
+ * stranger the member's issuing power. Mirrors the same rejection in
83
+ * `ApprovedIssuerSet.sol`.
84
+ *
85
+ * @param publicKey The signing key as `[x, y]`.
86
+ * @returns The key with both coordinates as bigints.
87
+ */
88
+ function toPublicKey(publicKey) {
89
+ const point = [toFieldElement(publicKey[0]), toFieldElement(publicKey[1])];
90
+ if (!babyJubjub.inCurve(point)) {
91
+ throw new Error("ApprovedIssuerSet: the public key is not a point on Baby Jubjub");
92
+ }
93
+ // A point has low order exactly when eight times it is the identity, the curve's cofactor being
94
+ // eight. That is the same set of eight points the contract rejects by x coordinate.
95
+ const [x, y] = babyJubjub.mulPointEscalar(point, COFACTOR);
96
+ if (x === 0n && y === 1n) {
97
+ throw new Error("ApprovedIssuerSet: the public key has low order");
98
+ }
99
+ return point;
100
+ }
101
+
102
+ /** How many leaves {@link ApprovedIssuerSet.fromContract} requests per `getLeaves` call. */
103
+ const FROM_CONTRACT_PAGE_SIZE = 1000;
104
+ /**
105
+ * The approved-issuer set: a LeanIMT accumulator, capped at depth
106
+ * {@link ISSUER_SET_MAX_DEPTH}, over network members' credential signing keys
107
+ * spec 03 defines, mirroring `ApprovedIssuerSet.sol`.
108
+ *
109
+ * A retired key's slot is tombstoned to `0` rather than removed, the same
110
+ * dense, append-only storage the contract keeps: `leafCount` never shrinks, so
111
+ * a slot's Merkle position never changes once assigned. A node with no right
112
+ * sibling is promoted unchanged rather than hashed against a zero, so the
113
+ * tree's depth tracks `ceil(log2(leafCount))` instead of staying fixed.
114
+ */
115
+ class ApprovedIssuerSet {
116
+ /**
117
+ * @param publicKeys The set's current signing keys, in order. Not
118
+ * curve-validated: this bulk-load path mirrors an already-validated
119
+ * on-chain state, unlike {@link publish}.
120
+ */
121
+ constructor(publicKeys) {
122
+ if (publicKeys.length === 0)
123
+ throw new Error("approved issuer set must not be empty");
124
+ if (publicKeys.length > 2 ** ISSUER_SET_MAX_DEPTH) {
125
+ throw new Error("approved issuer set exceeds circuit capacity");
126
+ }
127
+ const leaves = publicKeys.map(issuerKeyLeaf);
128
+ // Compared as leaves, not as coordinate pairs, so this agrees with `publish`'s own check.
129
+ if (new Set(leaves).size !== leaves.length) {
130
+ throw new Error("approved issuer set contains a duplicate key");
131
+ }
132
+ this.tree = new leanImt.LeanIMT(internalNode, leaves);
133
+ }
134
+ /** The current root. Never `undefined`: both constructors require at least one slot. */
135
+ get root() {
136
+ return this.tree.root;
137
+ }
138
+ /** The number of physical leaf slots, including tombstoned ones. Never decreases. */
139
+ get leafCount() {
140
+ return this.tree.size;
141
+ }
142
+ /** The number of currently approved (non-tombstoned) keys. */
143
+ get size() {
144
+ return this.tree.leaves.filter((leaf) => leaf !== 0n).length;
145
+ }
146
+ /** `ceil(log2(leafCount))`, 0 for a one-slot set. Grows as slots are added; never shrinks. */
147
+ get depth() {
148
+ return this.tree.depth;
149
+ }
150
+ /** Every physical slot, in order, `0` for a tombstone. A copy: mutate through {@link publish}/{@link retire}. */
151
+ get leafSlots() {
152
+ return this.tree.leaves;
153
+ }
154
+ /** The leaf a key would occupy, without publishing it. */
155
+ static leafOf(publicKey) {
156
+ return issuerKeyLeaf(toPublicKey(publicKey));
157
+ }
158
+ /**
159
+ * Rebuilds a set from the physical slot table a holder reads with
160
+ * `ApprovedIssuerSet.getLeaves` — the private path that never asks the
161
+ * chain which leaf it holds a witness for (see the contract's
162
+ * `siblingsOf` natspec).
163
+ *
164
+ * @param leaves Every slot, in physical order, `0` for a tombstone.
165
+ * @throws if `leaves` is empty. A drained set (every slot tombstoned) is
166
+ * a valid, nonzero-length input; the on-chain analogue is `leafCount`
167
+ * being monotonic, so a set that has ever published a key is never truly
168
+ * empty again.
169
+ */
170
+ static fromLeaves(leaves) {
171
+ if (leaves.length === 0)
172
+ throw new Error("approved issuer set must not be empty");
173
+ // Bypasses the public constructor, which validates a list of public keys: these are already
174
+ // leaf hashes, not keys, mirroring a state the caller trusts (the contract's own storage).
175
+ const set = Object.create(ApprovedIssuerSet.prototype);
176
+ set.tree = new leanImt.LeanIMT(internalNode, leaves.map(toFieldElement));
177
+ return set;
178
+ }
179
+ /**
180
+ * Rebuilds a set from a deployed `ApprovedIssuerSet.sol`, paging through
181
+ * `getLeaves` so a large set never needs one unbounded call. Pages are
182
+ * fetched concurrently — the reader isn't block-pinned, so this is no
183
+ * weaker a read than the sequential form.
184
+ */
185
+ static async fromContract(reader) {
186
+ const leafCount = Number(await reader.getLeafCount());
187
+ const ranges = [];
188
+ for (let start = 0; start < leafCount; start += FROM_CONTRACT_PAGE_SIZE) {
189
+ ranges.push([start, Math.min(start + FROM_CONTRACT_PAGE_SIZE, leafCount)]);
190
+ }
191
+ const pages = await Promise.all(ranges.map(([start, end]) => reader.getLeaves(start, end)));
192
+ return ApprovedIssuerSet.fromLeaves(pages.flat());
193
+ }
194
+ /** Whether a witness is internally consistent with the root it carries, independent of any set. */
195
+ static verifyProof(proof) {
196
+ if (proof.indices.length !== proof.siblings.length)
197
+ return false;
198
+ let packed = 0;
199
+ for (let level = 0; level < proof.indices.length; level += 1) {
200
+ if (proof.indices[level])
201
+ packed |= 1 << level;
202
+ }
203
+ if (packed !== proof.pathIndex)
204
+ return false;
205
+ return leanImt.LeanIMT.verifyProof({ root: proof.root, leaf: proof.leaf, index: proof.pathIndex, siblings: proof.siblings }, internalNode);
206
+ }
207
+ /** Whether a signing key is currently approved. */
208
+ isApproved(publicKey) {
209
+ return this.indexOf(publicKey) >= 0;
210
+ }
211
+ /** The physical index of a currently approved key, or `-1`. */
212
+ indexOf(publicKey) {
213
+ return this.tree.indexOf(issuerKeyLeaf(publicKey));
214
+ }
215
+ /**
216
+ * Publishes a signing key, appended at the next free slot.
217
+ * @returns The leaf commitment inserted.
218
+ * @throws if the key is off-curve, low-order, or already approved.
219
+ */
220
+ publish(publicKey) {
221
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
222
+ if (this.tree.has(leaf)) {
223
+ throw new Error("approved issuer set contains a duplicate key");
224
+ }
225
+ this.tree.insert(leaf);
226
+ return leaf;
227
+ }
228
+ /**
229
+ * {@link publish}, for several keys in order. Each is checked against the
230
+ * ones already in this batch too, and the set is rebuilt once at the end
231
+ * rather than after every key.
232
+ * @throws if any key is invalid or a duplicate, leaving the set unchanged.
233
+ */
234
+ publishMany(publicKeys) {
235
+ if (publicKeys.length === 0)
236
+ return [];
237
+ const leaves = [];
238
+ for (const publicKey of publicKeys) {
239
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
240
+ if (this.tree.has(leaf) || leaves.includes(leaf)) {
241
+ throw new Error("approved issuer set contains a duplicate key");
242
+ }
243
+ leaves.push(leaf);
244
+ }
245
+ this.tree.insertMany(leaves);
246
+ return leaves;
247
+ }
248
+ /**
249
+ * Tombstones a key's slot to `0`, ending its ability to verify. The slot
250
+ * stays allocated; a republished key gets a new one.
251
+ * @throws if the key is off-curve, low-order, or not currently approved.
252
+ */
253
+ retire(publicKey) {
254
+ const index = this.indexOf(toPublicKey(publicKey));
255
+ if (index < 0)
256
+ throw new Error("credential issuer is not in the approved set");
257
+ this.tree.update(index, 0n);
258
+ }
259
+ /**
260
+ * Generates the membership witness the credential circuit consumes.
261
+ * @throws if the key is off-curve, low-order, or not currently approved.
262
+ */
263
+ generateProof(publicKey) {
264
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
265
+ const leafIndex = this.tree.indexOf(leaf);
266
+ if (leafIndex < 0)
267
+ throw new Error("credential issuer is not in the approved set");
268
+ const proof = this.tree.generateProof(leafIndex);
269
+ const indices = Array.from({ length: proof.siblings.length }, (_, level) => ((proof.index >> level) & 1) === 1);
270
+ return { root: this.root, leafIndex, pathIndex: proof.index, siblings: proof.siblings, indices, leaf };
271
+ }
272
+ /** Instance form of {@link ApprovedIssuerSet.verifyProof}, additionally bound to this set's current root. */
273
+ verifyProof(proof) {
274
+ return proof.root === this.root && ApprovedIssuerSet.verifyProof(proof);
275
+ }
276
+ }
277
+
278
+ exports.ApprovedIssuerSet = ApprovedIssuerSet;
279
+ exports.COFACTOR = COFACTOR;
280
+ exports.ISSUER_LEAF_DOMAIN = ISSUER_LEAF_DOMAIN;
281
+ exports.ISSUER_SET_MAX_DEPTH = ISSUER_SET_MAX_DEPTH;
282
+ exports.SNARK_SCALAR_FIELD = SNARK_SCALAR_FIELD;
283
+ exports.internalNode = internalNode;
284
+ exports.issuerKeyLeaf = issuerKeyLeaf;
285
+ exports.issuerLeaf = issuerLeaf;
286
+ exports.toFieldElement = toFieldElement;
287
+ exports.toPublicKey = toPublicKey;
package/dist/index.js ADDED
@@ -0,0 +1,276 @@
1
+ import { LeanIMT } from '@zk-kit/lean-imt';
2
+ import { poseidon2 } from 'poseidon-lite/poseidon2';
3
+ import { poseidon3 } from 'poseidon-lite/poseidon3';
4
+ import { inCurve, mulPointEscalar } from '@zk-kit/baby-jubjub';
5
+
6
+ /**
7
+ * The BN254 (alt_bn128) scalar field order. Every leaf and internal-node hash,
8
+ * and every signing-key coordinate, is an element of this field.
9
+ */
10
+ const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
11
+ /**
12
+ * Baby Jubjub's cofactor. A point has low order exactly when multiplying it by
13
+ * this gives the identity, which is what disqualifies a signing key: see
14
+ * `toPublicKey`.
15
+ */
16
+ const COFACTOR = 8n;
17
+ /**
18
+ * The tree's capacity bound: spec 03's construction and the circuit's
19
+ * `binary_merkle_root`. The tree's actual depth is dynamic; see
20
+ * `ApprovedIssuerSet.depth`.
21
+ */
22
+ const ISSUER_SET_MAX_DEPTH = 20;
23
+ /**
24
+ * Domain separator mixed into every leaf commitment, matching
25
+ * `IssuerSetHashes.ISSUER_LEAF_DOMAIN` in Solidity and `LEAF_DOMAIN` in the
26
+ * circuit.
27
+ */
28
+ const ISSUER_LEAF_DOMAIN = 185404551131306766954100762329350135288498146007932686448214248919067924295n;
29
+
30
+ /**
31
+ * Commits a Baby Jubjub public key to the leaf value the tree stores for it.
32
+ * Matches `IssuerSetHashes.issuerKeyLeaf` in Solidity (`PoseidonT4`) and the
33
+ * leaf hash in `packages/circuits/src/issuer.nr`; all three must agree or a
34
+ * proof built against one never verifies against a root from another.
35
+ */
36
+ function issuerKeyLeaf(publicKey) {
37
+ return poseidon3([ISSUER_LEAF_DOMAIN, publicKey[0], publicKey[1]]);
38
+ }
39
+ /** Alias for the name `packages/credential`'s re-export carries forward. */
40
+ const issuerLeaf = issuerKeyLeaf;
41
+ /** Combines two child nodes into their parent. Matches `IssuerSetHashes.internalNode` (`PoseidonT3`). */
42
+ function internalNode(left, right) {
43
+ return poseidon2([left, right]);
44
+ }
45
+
46
+ /**
47
+ * Coerces a value to a BN254 field element, rejecting anything outside the
48
+ * field.
49
+ *
50
+ * Every leaf and every hash the set produces must be a field element: outside
51
+ * that range two distinct values reduce to the same element, so one key's
52
+ * membership proof would open against another's leaf.
53
+ *
54
+ * Zero is accepted, because a tombstoned or never-written slot holds it.
55
+ *
56
+ * @param value The value, as a bigint, number or decimal string.
57
+ * @returns The value as a bigint.
58
+ */
59
+ function toFieldElement(value) {
60
+ const element = BigInt(value);
61
+ if (element < 0n) {
62
+ throw new Error("ApprovedIssuerSet: a field element must not be negative");
63
+ }
64
+ if (element >= SNARK_SCALAR_FIELD) {
65
+ throw new Error("ApprovedIssuerSet: a field element must be less than the SNARK scalar field order");
66
+ }
67
+ return element;
68
+ }
69
+ /**
70
+ * Coerces a pair to a Baby Jubjub public key of full order, rejecting a point
71
+ * that is not on the curve or that lies in the small-order subgroup.
72
+ *
73
+ * The set would happily commit to any two field elements, so without the curve
74
+ * check a caller could publish a commitment to a point that does not exist.
75
+ *
76
+ * The order check matters more. Under a low-order public key `A` the term `h·A`
77
+ * in EdDSA verification takes at most eight values whatever the message, so
78
+ * anyone can solve `S·B = R + h·A` for a signature over a claim tree they chose;
79
+ * at the identity it is immediate. Publishing such a key would hand every
80
+ * stranger the member's issuing power. Mirrors the same rejection in
81
+ * `ApprovedIssuerSet.sol`.
82
+ *
83
+ * @param publicKey The signing key as `[x, y]`.
84
+ * @returns The key with both coordinates as bigints.
85
+ */
86
+ function toPublicKey(publicKey) {
87
+ const point = [toFieldElement(publicKey[0]), toFieldElement(publicKey[1])];
88
+ if (!inCurve(point)) {
89
+ throw new Error("ApprovedIssuerSet: the public key is not a point on Baby Jubjub");
90
+ }
91
+ // A point has low order exactly when eight times it is the identity, the curve's cofactor being
92
+ // eight. That is the same set of eight points the contract rejects by x coordinate.
93
+ const [x, y] = mulPointEscalar(point, COFACTOR);
94
+ if (x === 0n && y === 1n) {
95
+ throw new Error("ApprovedIssuerSet: the public key has low order");
96
+ }
97
+ return point;
98
+ }
99
+
100
+ /** How many leaves {@link ApprovedIssuerSet.fromContract} requests per `getLeaves` call. */
101
+ const FROM_CONTRACT_PAGE_SIZE = 1000;
102
+ /**
103
+ * The approved-issuer set: a LeanIMT accumulator, capped at depth
104
+ * {@link ISSUER_SET_MAX_DEPTH}, over network members' credential signing keys
105
+ * spec 03 defines, mirroring `ApprovedIssuerSet.sol`.
106
+ *
107
+ * A retired key's slot is tombstoned to `0` rather than removed, the same
108
+ * dense, append-only storage the contract keeps: `leafCount` never shrinks, so
109
+ * a slot's Merkle position never changes once assigned. A node with no right
110
+ * sibling is promoted unchanged rather than hashed against a zero, so the
111
+ * tree's depth tracks `ceil(log2(leafCount))` instead of staying fixed.
112
+ */
113
+ class ApprovedIssuerSet {
114
+ /**
115
+ * @param publicKeys The set's current signing keys, in order. Not
116
+ * curve-validated: this bulk-load path mirrors an already-validated
117
+ * on-chain state, unlike {@link publish}.
118
+ */
119
+ constructor(publicKeys) {
120
+ if (publicKeys.length === 0)
121
+ throw new Error("approved issuer set must not be empty");
122
+ if (publicKeys.length > 2 ** ISSUER_SET_MAX_DEPTH) {
123
+ throw new Error("approved issuer set exceeds circuit capacity");
124
+ }
125
+ const leaves = publicKeys.map(issuerKeyLeaf);
126
+ // Compared as leaves, not as coordinate pairs, so this agrees with `publish`'s own check.
127
+ if (new Set(leaves).size !== leaves.length) {
128
+ throw new Error("approved issuer set contains a duplicate key");
129
+ }
130
+ this.tree = new LeanIMT(internalNode, leaves);
131
+ }
132
+ /** The current root. Never `undefined`: both constructors require at least one slot. */
133
+ get root() {
134
+ return this.tree.root;
135
+ }
136
+ /** The number of physical leaf slots, including tombstoned ones. Never decreases. */
137
+ get leafCount() {
138
+ return this.tree.size;
139
+ }
140
+ /** The number of currently approved (non-tombstoned) keys. */
141
+ get size() {
142
+ return this.tree.leaves.filter((leaf) => leaf !== 0n).length;
143
+ }
144
+ /** `ceil(log2(leafCount))`, 0 for a one-slot set. Grows as slots are added; never shrinks. */
145
+ get depth() {
146
+ return this.tree.depth;
147
+ }
148
+ /** Every physical slot, in order, `0` for a tombstone. A copy: mutate through {@link publish}/{@link retire}. */
149
+ get leafSlots() {
150
+ return this.tree.leaves;
151
+ }
152
+ /** The leaf a key would occupy, without publishing it. */
153
+ static leafOf(publicKey) {
154
+ return issuerKeyLeaf(toPublicKey(publicKey));
155
+ }
156
+ /**
157
+ * Rebuilds a set from the physical slot table a holder reads with
158
+ * `ApprovedIssuerSet.getLeaves` — the private path that never asks the
159
+ * chain which leaf it holds a witness for (see the contract's
160
+ * `siblingsOf` natspec).
161
+ *
162
+ * @param leaves Every slot, in physical order, `0` for a tombstone.
163
+ * @throws if `leaves` is empty. A drained set (every slot tombstoned) is
164
+ * a valid, nonzero-length input; the on-chain analogue is `leafCount`
165
+ * being monotonic, so a set that has ever published a key is never truly
166
+ * empty again.
167
+ */
168
+ static fromLeaves(leaves) {
169
+ if (leaves.length === 0)
170
+ throw new Error("approved issuer set must not be empty");
171
+ // Bypasses the public constructor, which validates a list of public keys: these are already
172
+ // leaf hashes, not keys, mirroring a state the caller trusts (the contract's own storage).
173
+ const set = Object.create(ApprovedIssuerSet.prototype);
174
+ set.tree = new LeanIMT(internalNode, leaves.map(toFieldElement));
175
+ return set;
176
+ }
177
+ /**
178
+ * Rebuilds a set from a deployed `ApprovedIssuerSet.sol`, paging through
179
+ * `getLeaves` so a large set never needs one unbounded call. Pages are
180
+ * fetched concurrently — the reader isn't block-pinned, so this is no
181
+ * weaker a read than the sequential form.
182
+ */
183
+ static async fromContract(reader) {
184
+ const leafCount = Number(await reader.getLeafCount());
185
+ const ranges = [];
186
+ for (let start = 0; start < leafCount; start += FROM_CONTRACT_PAGE_SIZE) {
187
+ ranges.push([start, Math.min(start + FROM_CONTRACT_PAGE_SIZE, leafCount)]);
188
+ }
189
+ const pages = await Promise.all(ranges.map(([start, end]) => reader.getLeaves(start, end)));
190
+ return ApprovedIssuerSet.fromLeaves(pages.flat());
191
+ }
192
+ /** Whether a witness is internally consistent with the root it carries, independent of any set. */
193
+ static verifyProof(proof) {
194
+ if (proof.indices.length !== proof.siblings.length)
195
+ return false;
196
+ let packed = 0;
197
+ for (let level = 0; level < proof.indices.length; level += 1) {
198
+ if (proof.indices[level])
199
+ packed |= 1 << level;
200
+ }
201
+ if (packed !== proof.pathIndex)
202
+ return false;
203
+ return LeanIMT.verifyProof({ root: proof.root, leaf: proof.leaf, index: proof.pathIndex, siblings: proof.siblings }, internalNode);
204
+ }
205
+ /** Whether a signing key is currently approved. */
206
+ isApproved(publicKey) {
207
+ return this.indexOf(publicKey) >= 0;
208
+ }
209
+ /** The physical index of a currently approved key, or `-1`. */
210
+ indexOf(publicKey) {
211
+ return this.tree.indexOf(issuerKeyLeaf(publicKey));
212
+ }
213
+ /**
214
+ * Publishes a signing key, appended at the next free slot.
215
+ * @returns The leaf commitment inserted.
216
+ * @throws if the key is off-curve, low-order, or already approved.
217
+ */
218
+ publish(publicKey) {
219
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
220
+ if (this.tree.has(leaf)) {
221
+ throw new Error("approved issuer set contains a duplicate key");
222
+ }
223
+ this.tree.insert(leaf);
224
+ return leaf;
225
+ }
226
+ /**
227
+ * {@link publish}, for several keys in order. Each is checked against the
228
+ * ones already in this batch too, and the set is rebuilt once at the end
229
+ * rather than after every key.
230
+ * @throws if any key is invalid or a duplicate, leaving the set unchanged.
231
+ */
232
+ publishMany(publicKeys) {
233
+ if (publicKeys.length === 0)
234
+ return [];
235
+ const leaves = [];
236
+ for (const publicKey of publicKeys) {
237
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
238
+ if (this.tree.has(leaf) || leaves.includes(leaf)) {
239
+ throw new Error("approved issuer set contains a duplicate key");
240
+ }
241
+ leaves.push(leaf);
242
+ }
243
+ this.tree.insertMany(leaves);
244
+ return leaves;
245
+ }
246
+ /**
247
+ * Tombstones a key's slot to `0`, ending its ability to verify. The slot
248
+ * stays allocated; a republished key gets a new one.
249
+ * @throws if the key is off-curve, low-order, or not currently approved.
250
+ */
251
+ retire(publicKey) {
252
+ const index = this.indexOf(toPublicKey(publicKey));
253
+ if (index < 0)
254
+ throw new Error("credential issuer is not in the approved set");
255
+ this.tree.update(index, 0n);
256
+ }
257
+ /**
258
+ * Generates the membership witness the credential circuit consumes.
259
+ * @throws if the key is off-curve, low-order, or not currently approved.
260
+ */
261
+ generateProof(publicKey) {
262
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey));
263
+ const leafIndex = this.tree.indexOf(leaf);
264
+ if (leafIndex < 0)
265
+ throw new Error("credential issuer is not in the approved set");
266
+ const proof = this.tree.generateProof(leafIndex);
267
+ const indices = Array.from({ length: proof.siblings.length }, (_, level) => ((proof.index >> level) & 1) === 1);
268
+ return { root: this.root, leafIndex, pathIndex: proof.index, siblings: proof.siblings, indices, leaf };
269
+ }
270
+ /** Instance form of {@link ApprovedIssuerSet.verifyProof}, additionally bound to this set's current root. */
271
+ verifyProof(proof) {
272
+ return proof.root === this.root && ApprovedIssuerSet.verifyProof(proof);
273
+ }
274
+ }
275
+
276
+ export { ApprovedIssuerSet, COFACTOR, ISSUER_LEAF_DOMAIN, ISSUER_SET_MAX_DEPTH, SNARK_SCALAR_FIELD, internalNode, issuerKeyLeaf, issuerLeaf, toFieldElement, toPublicKey };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@coordination-network/issuer-set",
3
+ "version": "0.0.1",
4
+ "description": "Approved-issuer set in TypeScript: publish network members' credential signing keys into the LeanIMT accumulator (dynamic depth, capacity 2^20) spec 03 defines, and generate the membership proofs a credential circuit consumes, as the set itself or as a mirror of one held on chain.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/issuer-set"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/issuer-set",
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/baby-jubjub": "1.0.3",
40
+ "@zk-kit/lean-imt": "2.2.5",
41
+ "poseidon-lite": "0.3.0"
42
+ },
43
+ "devDependencies": {
44
+ "@rollup/plugin-typescript": "^12.3.0",
45
+ "rimraf": "^6.1.3",
46
+ "rollup": "^4.62.2",
47
+ "rollup-plugin-cleanup": "^3.2.1",
48
+ "rollup-plugin-dts": "^6.4.1",
49
+ "tslib": "^2.8.0",
50
+ "typescript": "^6.0.3"
51
+ }
52
+ }
@@ -0,0 +1,217 @@
1
+ import { LeanIMT } from "@zk-kit/lean-imt"
2
+ import { ISSUER_SET_MAX_DEPTH } from "./constants"
3
+ import { internalNode, issuerKeyLeaf } from "./hashes"
4
+ import type { BigNumberish, ContractLeafReader, IssuerMembershipWitness, IssuerPublicKey, IssuerSet } from "./types"
5
+ import { toFieldElement, toPublicKey } from "./utils"
6
+
7
+ /** How many leaves {@link ApprovedIssuerSet.fromContract} requests per `getLeaves` call. */
8
+ const FROM_CONTRACT_PAGE_SIZE = 1000
9
+
10
+ /**
11
+ * The approved-issuer set: a LeanIMT accumulator, capped at depth
12
+ * {@link ISSUER_SET_MAX_DEPTH}, over network members' credential signing keys
13
+ * spec 03 defines, mirroring `ApprovedIssuerSet.sol`.
14
+ *
15
+ * A retired key's slot is tombstoned to `0` rather than removed, the same
16
+ * dense, append-only storage the contract keeps: `leafCount` never shrinks, so
17
+ * a slot's Merkle position never changes once assigned. A node with no right
18
+ * sibling is promoted unchanged rather than hashed against a zero, so the
19
+ * tree's depth tracks `ceil(log2(leafCount))` instead of staying fixed.
20
+ */
21
+ export class ApprovedIssuerSet implements IssuerSet {
22
+ private tree: LeanIMT<bigint>
23
+
24
+ /**
25
+ * @param publicKeys The set's current signing keys, in order. Not
26
+ * curve-validated: this bulk-load path mirrors an already-validated
27
+ * on-chain state, unlike {@link publish}.
28
+ */
29
+ constructor(publicKeys: IssuerPublicKey[]) {
30
+ if (publicKeys.length === 0) throw new Error("approved issuer set must not be empty")
31
+ if (publicKeys.length > 2 ** ISSUER_SET_MAX_DEPTH) {
32
+ throw new Error("approved issuer set exceeds circuit capacity")
33
+ }
34
+
35
+ const leaves = publicKeys.map(issuerKeyLeaf)
36
+ // Compared as leaves, not as coordinate pairs, so this agrees with `publish`'s own check.
37
+ if (new Set(leaves).size !== leaves.length) {
38
+ throw new Error("approved issuer set contains a duplicate key")
39
+ }
40
+
41
+ this.tree = new LeanIMT<bigint>(internalNode, leaves)
42
+ }
43
+
44
+ /** The current root. Never `undefined`: both constructors require at least one slot. */
45
+ get root(): bigint {
46
+ return this.tree.root
47
+ }
48
+
49
+ /** The number of physical leaf slots, including tombstoned ones. Never decreases. */
50
+ get leafCount(): number {
51
+ return this.tree.size
52
+ }
53
+
54
+ /** The number of currently approved (non-tombstoned) keys. */
55
+ get size(): number {
56
+ return this.tree.leaves.filter((leaf) => leaf !== 0n).length
57
+ }
58
+
59
+ /** `ceil(log2(leafCount))`, 0 for a one-slot set. Grows as slots are added; never shrinks. */
60
+ get depth(): number {
61
+ return this.tree.depth
62
+ }
63
+
64
+ /** Every physical slot, in order, `0` for a tombstone. A copy: mutate through {@link publish}/{@link retire}. */
65
+ get leafSlots(): bigint[] {
66
+ return this.tree.leaves
67
+ }
68
+
69
+ /** The leaf a key would occupy, without publishing it. */
70
+ static leafOf(publicKey: IssuerPublicKey): bigint {
71
+ return issuerKeyLeaf(toPublicKey(publicKey))
72
+ }
73
+
74
+ /**
75
+ * Rebuilds a set from the physical slot table a holder reads with
76
+ * `ApprovedIssuerSet.getLeaves` — the private path that never asks the
77
+ * chain which leaf it holds a witness for (see the contract's
78
+ * `siblingsOf` natspec).
79
+ *
80
+ * @param leaves Every slot, in physical order, `0` for a tombstone.
81
+ * @throws if `leaves` is empty. A drained set (every slot tombstoned) is
82
+ * a valid, nonzero-length input; the on-chain analogue is `leafCount`
83
+ * being monotonic, so a set that has ever published a key is never truly
84
+ * empty again.
85
+ */
86
+ static fromLeaves(leaves: BigNumberish[]): ApprovedIssuerSet {
87
+ if (leaves.length === 0) throw new Error("approved issuer set must not be empty")
88
+
89
+ // Bypasses the public constructor, which validates a list of public keys: these are already
90
+ // leaf hashes, not keys, mirroring a state the caller trusts (the contract's own storage).
91
+ const set = Object.create(ApprovedIssuerSet.prototype) as ApprovedIssuerSet
92
+
93
+ set.tree = new LeanIMT<bigint>(internalNode, leaves.map(toFieldElement))
94
+
95
+ return set
96
+ }
97
+
98
+ /**
99
+ * Rebuilds a set from a deployed `ApprovedIssuerSet.sol`, paging through
100
+ * `getLeaves` so a large set never needs one unbounded call. Pages are
101
+ * fetched concurrently — the reader isn't block-pinned, so this is no
102
+ * weaker a read than the sequential form.
103
+ */
104
+ static async fromContract(reader: ContractLeafReader): Promise<ApprovedIssuerSet> {
105
+ const leafCount = Number(await reader.getLeafCount())
106
+
107
+ const ranges: [number, number][] = []
108
+ for (let start = 0; start < leafCount; start += FROM_CONTRACT_PAGE_SIZE) {
109
+ ranges.push([start, Math.min(start + FROM_CONTRACT_PAGE_SIZE, leafCount)])
110
+ }
111
+
112
+ const pages = await Promise.all(ranges.map(([start, end]) => reader.getLeaves(start, end)))
113
+
114
+ return ApprovedIssuerSet.fromLeaves(pages.flat())
115
+ }
116
+
117
+ /** Whether a witness is internally consistent with the root it carries, independent of any set. */
118
+ static verifyProof(proof: IssuerMembershipWitness): boolean {
119
+ if (proof.indices.length !== proof.siblings.length) return false
120
+
121
+ let packed = 0
122
+ for (let level = 0; level < proof.indices.length; level += 1) {
123
+ if (proof.indices[level]) packed |= 1 << level
124
+ }
125
+ if (packed !== proof.pathIndex) return false
126
+
127
+ return LeanIMT.verifyProof(
128
+ { root: proof.root, leaf: proof.leaf, index: proof.pathIndex, siblings: proof.siblings },
129
+ internalNode
130
+ )
131
+ }
132
+
133
+ /** Whether a signing key is currently approved. */
134
+ isApproved(publicKey: IssuerPublicKey): boolean {
135
+ return this.indexOf(publicKey) >= 0
136
+ }
137
+
138
+ /** The physical index of a currently approved key, or `-1`. */
139
+ indexOf(publicKey: IssuerPublicKey): number {
140
+ return this.tree.indexOf(issuerKeyLeaf(publicKey))
141
+ }
142
+
143
+ /**
144
+ * Publishes a signing key, appended at the next free slot.
145
+ * @returns The leaf commitment inserted.
146
+ * @throws if the key is off-curve, low-order, or already approved.
147
+ */
148
+ publish(publicKey: IssuerPublicKey): bigint {
149
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey))
150
+
151
+ if (this.tree.has(leaf)) {
152
+ throw new Error("approved issuer set contains a duplicate key")
153
+ }
154
+
155
+ this.tree.insert(leaf)
156
+
157
+ return leaf
158
+ }
159
+
160
+ /**
161
+ * {@link publish}, for several keys in order. Each is checked against the
162
+ * ones already in this batch too, and the set is rebuilt once at the end
163
+ * rather than after every key.
164
+ * @throws if any key is invalid or a duplicate, leaving the set unchanged.
165
+ */
166
+ publishMany(publicKeys: IssuerPublicKey[]): bigint[] {
167
+ if (publicKeys.length === 0) return []
168
+
169
+ const leaves: bigint[] = []
170
+
171
+ for (const publicKey of publicKeys) {
172
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey))
173
+
174
+ if (this.tree.has(leaf) || leaves.includes(leaf)) {
175
+ throw new Error("approved issuer set contains a duplicate key")
176
+ }
177
+
178
+ leaves.push(leaf)
179
+ }
180
+
181
+ this.tree.insertMany(leaves)
182
+
183
+ return leaves
184
+ }
185
+
186
+ /**
187
+ * Tombstones a key's slot to `0`, ending its ability to verify. The slot
188
+ * stays allocated; a republished key gets a new one.
189
+ * @throws if the key is off-curve, low-order, or not currently approved.
190
+ */
191
+ retire(publicKey: IssuerPublicKey): void {
192
+ const index = this.indexOf(toPublicKey(publicKey))
193
+ if (index < 0) throw new Error("credential issuer is not in the approved set")
194
+
195
+ this.tree.update(index, 0n)
196
+ }
197
+
198
+ /**
199
+ * Generates the membership witness the credential circuit consumes.
200
+ * @throws if the key is off-curve, low-order, or not currently approved.
201
+ */
202
+ generateProof(publicKey: IssuerPublicKey): IssuerMembershipWitness {
203
+ const leaf = issuerKeyLeaf(toPublicKey(publicKey))
204
+ const leafIndex = this.tree.indexOf(leaf)
205
+ if (leafIndex < 0) throw new Error("credential issuer is not in the approved set")
206
+
207
+ const proof = this.tree.generateProof(leafIndex)
208
+ const indices = Array.from({ length: proof.siblings.length }, (_, level) => ((proof.index >> level) & 1) === 1)
209
+
210
+ return { root: this.root, leafIndex, pathIndex: proof.index, siblings: proof.siblings, indices, leaf }
211
+ }
212
+
213
+ /** Instance form of {@link ApprovedIssuerSet.verifyProof}, additionally bound to this set's current root. */
214
+ verifyProof(proof: IssuerMembershipWitness): boolean {
215
+ return proof.root === this.root && ApprovedIssuerSet.verifyProof(proof)
216
+ }
217
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The BN254 (alt_bn128) scalar field order. Every leaf and internal-node hash,
3
+ * and every signing-key coordinate, is an element of this field.
4
+ */
5
+ export const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
6
+
7
+ /**
8
+ * Baby Jubjub's cofactor. A point has low order exactly when multiplying it by
9
+ * this gives the identity, which is what disqualifies a signing key: see
10
+ * `toPublicKey`.
11
+ */
12
+ export const COFACTOR = 8n
13
+
14
+ /**
15
+ * The tree's capacity bound: spec 03's construction and the circuit's
16
+ * `binary_merkle_root`. The tree's actual depth is dynamic; see
17
+ * `ApprovedIssuerSet.depth`.
18
+ */
19
+ export const ISSUER_SET_MAX_DEPTH = 20
20
+
21
+ /**
22
+ * Domain separator mixed into every leaf commitment, matching
23
+ * `IssuerSetHashes.ISSUER_LEAF_DOMAIN` in Solidity and `LEAF_DOMAIN` in the
24
+ * circuit.
25
+ */
26
+ export const ISSUER_LEAF_DOMAIN = 185404551131306766954100762329350135288498146007932686448214248919067924295n
package/src/hashes.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { poseidon2 } from "poseidon-lite/poseidon2"
2
+ import { poseidon3 } from "poseidon-lite/poseidon3"
3
+ import { ISSUER_LEAF_DOMAIN } from "./constants"
4
+ import type { IssuerPublicKey } from "./types"
5
+
6
+ /**
7
+ * Commits a Baby Jubjub public key to the leaf value the tree stores for it.
8
+ * Matches `IssuerSetHashes.issuerKeyLeaf` in Solidity (`PoseidonT4`) and the
9
+ * leaf hash in `packages/circuits/src/issuer.nr`; all three must agree or a
10
+ * proof built against one never verifies against a root from another.
11
+ */
12
+ export function issuerKeyLeaf(publicKey: IssuerPublicKey): bigint {
13
+ return poseidon3([ISSUER_LEAF_DOMAIN, publicKey[0], publicKey[1]])
14
+ }
15
+
16
+ /** Alias for the name `packages/credential`'s re-export carries forward. */
17
+ export const issuerLeaf = issuerKeyLeaf
18
+
19
+ /** Combines two child nodes into their parent. Matches `IssuerSetHashes.internalNode` (`PoseidonT3`). */
20
+ export function internalNode(left: bigint, right: bigint): bigint {
21
+ return poseidon2([left, right])
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { ApprovedIssuerSet } from "./approved-issuer-set"
2
+ export { COFACTOR, ISSUER_LEAF_DOMAIN, ISSUER_SET_MAX_DEPTH, SNARK_SCALAR_FIELD } from "./constants"
3
+ export { internalNode, issuerKeyLeaf, issuerLeaf } from "./hashes"
4
+ export type {
5
+ BigNumberish,
6
+ ContractLeafReader,
7
+ IssuerMembershipProof,
8
+ IssuerMembershipWitness,
9
+ IssuerPublicKey,
10
+ IssuerSet
11
+ } from "./types"
12
+ export { toFieldElement, toPublicKey } from "./utils"
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ /** Anything that can be coerced to a field element with `BigInt()`. */
2
+ export type BigNumberish = bigint | number | string
3
+
4
+ /** A Baby Jubjub public key in affine form, `[x, y]`. */
5
+ export type IssuerPublicKey = [bigint, bigint]
6
+
7
+ /**
8
+ * A membership proof for one signing key: a Merkle proof of a single leaf.
9
+ * Structurally identical to `packages/credential`'s type of the same name, so
10
+ * a re-export from there type-checks unchanged. `leafIndex` is the leaf's
11
+ * physical slot; `pathIndex` packs the direction bits of the levels actually
12
+ * present in `siblings` (promoted levels have no sibling and are absent), and
13
+ * is what the proof opens against `root` with.
14
+ */
15
+ export interface IssuerMembershipProof {
16
+ root: bigint
17
+ leafIndex: number
18
+ pathIndex: number
19
+ siblings: bigint[]
20
+ }
21
+
22
+ /**
23
+ * `ApprovedIssuerSet.generateProof`'s actual return shape: a membership proof
24
+ * plus the per-level direction bits and the leaf itself, together enough to
25
+ * fold to `root` with no other context. `indices` covers only the levels
26
+ * `siblings` includes — `indices[i]` is bit `i` of `pathIndex` — the circuit's
27
+ * `binary_merkle_root` witness.
28
+ */
29
+ export interface IssuerMembershipWitness extends IssuerMembershipProof {
30
+ indices: boolean[]
31
+ leaf: bigint
32
+ }
33
+
34
+ /**
35
+ * The structural interface `packages/credential` consumes: a root and a way
36
+ * to prove membership against it. `ApprovedIssuerSet` widens `generateProof`'s
37
+ * return type to {@link IssuerMembershipWitness}, which still satisfies this.
38
+ */
39
+ export interface IssuerSet {
40
+ root: bigint
41
+ generateProof(publicKey: IssuerPublicKey): IssuerMembershipProof
42
+ }
43
+
44
+ /**
45
+ * The minimal read surface {@link ApprovedIssuerSet.fromContract} needs from
46
+ * a deployed `ApprovedIssuerSet.sol`. Structural, not a viem `Contract`
47
+ * instance, so this package carries no on-chain dependency.
48
+ */
49
+ export interface ContractLeafReader {
50
+ getLeafCount(): Promise<bigint | number>
51
+ getLeaves(start: number, end: number): Promise<(bigint | string | number)[]>
52
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { inCurve, mulPointEscalar } from "@zk-kit/baby-jubjub"
2
+ import { COFACTOR, SNARK_SCALAR_FIELD } from "./constants"
3
+ import type { BigNumberish, IssuerPublicKey } from "./types"
4
+
5
+ /**
6
+ * Coerces a value to a BN254 field element, rejecting anything outside the
7
+ * field.
8
+ *
9
+ * Every leaf and every hash the set produces must be a field element: outside
10
+ * that range two distinct values reduce to the same element, so one key's
11
+ * membership proof would open against another's leaf.
12
+ *
13
+ * Zero is accepted, because a tombstoned or never-written slot holds it.
14
+ *
15
+ * @param value The value, as a bigint, number or decimal string.
16
+ * @returns The value as a bigint.
17
+ */
18
+ export function toFieldElement(value: BigNumberish): bigint {
19
+ const element = BigInt(value)
20
+
21
+ if (element < 0n) {
22
+ throw new Error("ApprovedIssuerSet: a field element must not be negative")
23
+ }
24
+
25
+ if (element >= SNARK_SCALAR_FIELD) {
26
+ throw new Error("ApprovedIssuerSet: a field element must be less than the SNARK scalar field order")
27
+ }
28
+
29
+ return element
30
+ }
31
+
32
+ /**
33
+ * Coerces a pair to a Baby Jubjub public key of full order, rejecting a point
34
+ * that is not on the curve or that lies in the small-order subgroup.
35
+ *
36
+ * The set would happily commit to any two field elements, so without the curve
37
+ * check a caller could publish a commitment to a point that does not exist.
38
+ *
39
+ * The order check matters more. Under a low-order public key `A` the term `h·A`
40
+ * in EdDSA verification takes at most eight values whatever the message, so
41
+ * anyone can solve `S·B = R + h·A` for a signature over a claim tree they chose;
42
+ * at the identity it is immediate. Publishing such a key would hand every
43
+ * stranger the member's issuing power. Mirrors the same rejection in
44
+ * `ApprovedIssuerSet.sol`.
45
+ *
46
+ * @param publicKey The signing key as `[x, y]`.
47
+ * @returns The key with both coordinates as bigints.
48
+ */
49
+ export function toPublicKey(publicKey: IssuerPublicKey): [bigint, bigint] {
50
+ const point: [bigint, bigint] = [toFieldElement(publicKey[0]), toFieldElement(publicKey[1])]
51
+
52
+ if (!inCurve(point)) {
53
+ throw new Error("ApprovedIssuerSet: the public key is not a point on Baby Jubjub")
54
+ }
55
+
56
+ // A point has low order exactly when eight times it is the identity, the curve's cofactor being
57
+ // eight. That is the same set of eight points the contract rejects by x coordinate.
58
+ const [x, y] = mulPointEscalar(point, COFACTOR)
59
+
60
+ if (x === 0n && y === 1n) {
61
+ throw new Error("ApprovedIssuerSet: the public key has low order")
62
+ }
63
+
64
+ return point
65
+ }
@@ -0,0 +1,204 @@
1
+ /** Anything that can be coerced to a field element with `BigInt()`. */
2
+ type BigNumberish = bigint | number | string;
3
+ /** A Baby Jubjub public key in affine form, `[x, y]`. */
4
+ type IssuerPublicKey = [bigint, bigint];
5
+ /**
6
+ * A membership proof for one signing key: a Merkle proof of a single leaf.
7
+ * Structurally identical to `packages/credential`'s type of the same name, so
8
+ * a re-export from there type-checks unchanged. `leafIndex` is the leaf's
9
+ * physical slot; `pathIndex` packs the direction bits of the levels actually
10
+ * present in `siblings` (promoted levels have no sibling and are absent), and
11
+ * is what the proof opens against `root` with.
12
+ */
13
+ interface IssuerMembershipProof {
14
+ root: bigint;
15
+ leafIndex: number;
16
+ pathIndex: number;
17
+ siblings: bigint[];
18
+ }
19
+ /**
20
+ * `ApprovedIssuerSet.generateProof`'s actual return shape: a membership proof
21
+ * plus the per-level direction bits and the leaf itself, together enough to
22
+ * fold to `root` with no other context. `indices` covers only the levels
23
+ * `siblings` includes — `indices[i]` is bit `i` of `pathIndex` — the circuit's
24
+ * `binary_merkle_root` witness.
25
+ */
26
+ interface IssuerMembershipWitness extends IssuerMembershipProof {
27
+ indices: boolean[];
28
+ leaf: bigint;
29
+ }
30
+ /**
31
+ * The structural interface `packages/credential` consumes: a root and a way
32
+ * to prove membership against it. `ApprovedIssuerSet` widens `generateProof`'s
33
+ * return type to {@link IssuerMembershipWitness}, which still satisfies this.
34
+ */
35
+ interface IssuerSet {
36
+ root: bigint;
37
+ generateProof(publicKey: IssuerPublicKey): IssuerMembershipProof;
38
+ }
39
+ /**
40
+ * The minimal read surface {@link ApprovedIssuerSet.fromContract} needs from
41
+ * a deployed `ApprovedIssuerSet.sol`. Structural, not a viem `Contract`
42
+ * instance, so this package carries no on-chain dependency.
43
+ */
44
+ interface ContractLeafReader {
45
+ getLeafCount(): Promise<bigint | number>;
46
+ getLeaves(start: number, end: number): Promise<(bigint | string | number)[]>;
47
+ }
48
+
49
+ /**
50
+ * The approved-issuer set: a LeanIMT accumulator, capped at depth
51
+ * {@link ISSUER_SET_MAX_DEPTH}, over network members' credential signing keys
52
+ * spec 03 defines, mirroring `ApprovedIssuerSet.sol`.
53
+ *
54
+ * A retired key's slot is tombstoned to `0` rather than removed, the same
55
+ * dense, append-only storage the contract keeps: `leafCount` never shrinks, so
56
+ * a slot's Merkle position never changes once assigned. A node with no right
57
+ * sibling is promoted unchanged rather than hashed against a zero, so the
58
+ * tree's depth tracks `ceil(log2(leafCount))` instead of staying fixed.
59
+ */
60
+ declare class ApprovedIssuerSet implements IssuerSet {
61
+ private tree;
62
+ /**
63
+ * @param publicKeys The set's current signing keys, in order. Not
64
+ * curve-validated: this bulk-load path mirrors an already-validated
65
+ * on-chain state, unlike {@link publish}.
66
+ */
67
+ constructor(publicKeys: IssuerPublicKey[]);
68
+ /** The current root. Never `undefined`: both constructors require at least one slot. */
69
+ get root(): bigint;
70
+ /** The number of physical leaf slots, including tombstoned ones. Never decreases. */
71
+ get leafCount(): number;
72
+ /** The number of currently approved (non-tombstoned) keys. */
73
+ get size(): number;
74
+ /** `ceil(log2(leafCount))`, 0 for a one-slot set. Grows as slots are added; never shrinks. */
75
+ get depth(): number;
76
+ /** Every physical slot, in order, `0` for a tombstone. A copy: mutate through {@link publish}/{@link retire}. */
77
+ get leafSlots(): bigint[];
78
+ /** The leaf a key would occupy, without publishing it. */
79
+ static leafOf(publicKey: IssuerPublicKey): bigint;
80
+ /**
81
+ * Rebuilds a set from the physical slot table a holder reads with
82
+ * `ApprovedIssuerSet.getLeaves` — the private path that never asks the
83
+ * chain which leaf it holds a witness for (see the contract's
84
+ * `siblingsOf` natspec).
85
+ *
86
+ * @param leaves Every slot, in physical order, `0` for a tombstone.
87
+ * @throws if `leaves` is empty. A drained set (every slot tombstoned) is
88
+ * a valid, nonzero-length input; the on-chain analogue is `leafCount`
89
+ * being monotonic, so a set that has ever published a key is never truly
90
+ * empty again.
91
+ */
92
+ static fromLeaves(leaves: BigNumberish[]): ApprovedIssuerSet;
93
+ /**
94
+ * Rebuilds a set from a deployed `ApprovedIssuerSet.sol`, paging through
95
+ * `getLeaves` so a large set never needs one unbounded call. Pages are
96
+ * fetched concurrently — the reader isn't block-pinned, so this is no
97
+ * weaker a read than the sequential form.
98
+ */
99
+ static fromContract(reader: ContractLeafReader): Promise<ApprovedIssuerSet>;
100
+ /** Whether a witness is internally consistent with the root it carries, independent of any set. */
101
+ static verifyProof(proof: IssuerMembershipWitness): boolean;
102
+ /** Whether a signing key is currently approved. */
103
+ isApproved(publicKey: IssuerPublicKey): boolean;
104
+ /** The physical index of a currently approved key, or `-1`. */
105
+ indexOf(publicKey: IssuerPublicKey): number;
106
+ /**
107
+ * Publishes a signing key, appended at the next free slot.
108
+ * @returns The leaf commitment inserted.
109
+ * @throws if the key is off-curve, low-order, or already approved.
110
+ */
111
+ publish(publicKey: IssuerPublicKey): bigint;
112
+ /**
113
+ * {@link publish}, for several keys in order. Each is checked against the
114
+ * ones already in this batch too, and the set is rebuilt once at the end
115
+ * rather than after every key.
116
+ * @throws if any key is invalid or a duplicate, leaving the set unchanged.
117
+ */
118
+ publishMany(publicKeys: IssuerPublicKey[]): bigint[];
119
+ /**
120
+ * Tombstones a key's slot to `0`, ending its ability to verify. The slot
121
+ * stays allocated; a republished key gets a new one.
122
+ * @throws if the key is off-curve, low-order, or not currently approved.
123
+ */
124
+ retire(publicKey: IssuerPublicKey): void;
125
+ /**
126
+ * Generates the membership witness the credential circuit consumes.
127
+ * @throws if the key is off-curve, low-order, or not currently approved.
128
+ */
129
+ generateProof(publicKey: IssuerPublicKey): IssuerMembershipWitness;
130
+ /** Instance form of {@link ApprovedIssuerSet.verifyProof}, additionally bound to this set's current root. */
131
+ verifyProof(proof: IssuerMembershipWitness): boolean;
132
+ }
133
+
134
+ /**
135
+ * The BN254 (alt_bn128) scalar field order. Every leaf and internal-node hash,
136
+ * and every signing-key coordinate, is an element of this field.
137
+ */
138
+ declare const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
139
+ /**
140
+ * Baby Jubjub's cofactor. A point has low order exactly when multiplying it by
141
+ * this gives the identity, which is what disqualifies a signing key: see
142
+ * `toPublicKey`.
143
+ */
144
+ declare const COFACTOR = 8n;
145
+ /**
146
+ * The tree's capacity bound: spec 03's construction and the circuit's
147
+ * `binary_merkle_root`. The tree's actual depth is dynamic; see
148
+ * `ApprovedIssuerSet.depth`.
149
+ */
150
+ declare const ISSUER_SET_MAX_DEPTH = 20;
151
+ /**
152
+ * Domain separator mixed into every leaf commitment, matching
153
+ * `IssuerSetHashes.ISSUER_LEAF_DOMAIN` in Solidity and `LEAF_DOMAIN` in the
154
+ * circuit.
155
+ */
156
+ declare const ISSUER_LEAF_DOMAIN = 185404551131306766954100762329350135288498146007932686448214248919067924295n;
157
+
158
+ /**
159
+ * Commits a Baby Jubjub public key to the leaf value the tree stores for it.
160
+ * Matches `IssuerSetHashes.issuerKeyLeaf` in Solidity (`PoseidonT4`) and the
161
+ * leaf hash in `packages/circuits/src/issuer.nr`; all three must agree or a
162
+ * proof built against one never verifies against a root from another.
163
+ */
164
+ declare function issuerKeyLeaf(publicKey: IssuerPublicKey): bigint;
165
+ /** Alias for the name `packages/credential`'s re-export carries forward. */
166
+ declare const issuerLeaf: typeof issuerKeyLeaf;
167
+ /** Combines two child nodes into their parent. Matches `IssuerSetHashes.internalNode` (`PoseidonT3`). */
168
+ declare function internalNode(left: bigint, right: bigint): bigint;
169
+
170
+ /**
171
+ * Coerces a value to a BN254 field element, rejecting anything outside the
172
+ * field.
173
+ *
174
+ * Every leaf and every hash the set produces must be a field element: outside
175
+ * that range two distinct values reduce to the same element, so one key's
176
+ * membership proof would open against another's leaf.
177
+ *
178
+ * Zero is accepted, because a tombstoned or never-written slot holds it.
179
+ *
180
+ * @param value The value, as a bigint, number or decimal string.
181
+ * @returns The value as a bigint.
182
+ */
183
+ declare function toFieldElement(value: BigNumberish): bigint;
184
+ /**
185
+ * Coerces a pair to a Baby Jubjub public key of full order, rejecting a point
186
+ * that is not on the curve or that lies in the small-order subgroup.
187
+ *
188
+ * The set would happily commit to any two field elements, so without the curve
189
+ * check a caller could publish a commitment to a point that does not exist.
190
+ *
191
+ * The order check matters more. Under a low-order public key `A` the term `h·A`
192
+ * in EdDSA verification takes at most eight values whatever the message, so
193
+ * anyone can solve `S·B = R + h·A` for a signature over a claim tree they chose;
194
+ * at the identity it is immediate. Publishing such a key would hand every
195
+ * stranger the member's issuing power. Mirrors the same rejection in
196
+ * `ApprovedIssuerSet.sol`.
197
+ *
198
+ * @param publicKey The signing key as `[x, y]`.
199
+ * @returns The key with both coordinates as bigints.
200
+ */
201
+ declare function toPublicKey(publicKey: IssuerPublicKey): [bigint, bigint];
202
+
203
+ export { ApprovedIssuerSet, COFACTOR, ISSUER_LEAF_DOMAIN, ISSUER_SET_MAX_DEPTH, SNARK_SCALAR_FIELD, internalNode, issuerKeyLeaf, issuerLeaf, toFieldElement, toPublicKey };
204
+ export type { BigNumberish, ContractLeafReader, IssuerMembershipProof, IssuerMembershipWitness, IssuerPublicKey, IssuerSet };