@coordination-network/contracts 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.
@@ -0,0 +1,630 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.34;
3
+
4
+ import { SNARK_SCALAR_FIELD } from "@zk-kit/lean-imt.sol/Constants.sol";
5
+ import { IssuerSetHashes } from "./libraries/IssuerSetHashes.sol";
6
+ import { IApprovedIssuerSet } from "./interfaces/IApprovedIssuerSet.sol";
7
+ import { NetworkMemberRegistry } from "./NetworkMemberRegistry.sol";
8
+
9
+ /// @title ApprovedIssuerSet
10
+ /// @notice Accumulator over approved network members' credential signing keys.
11
+ /// @dev A verifier reads only the root, and the circuit proves in zero knowledge that the key which
12
+ /// signed a credential is in the set. That is what lets a verifier learn an approved member issued a
13
+ /// credential without learning which one, so this contract deliberately offers no way to answer
14
+ /// "whose key signed this" as part of a verification.
15
+ ///
16
+ /// The tree is a LeanIMT: `nodes[level][index]` holds every node this contract has ever written,
17
+ /// level 0 the leaves and level `depth()` the current root. Pairing is keyed on level width, never on
18
+ /// zero-ness — the last node of an odd-width level promotes to its parent unchanged instead of hashing
19
+ /// against a zero pad. {MAX_DEPTH} only bounds capacity; the live root's level tracks
20
+ /// `ceil(log2(leafCount))`.
21
+ ///
22
+ /// `leafCount` never decreases, so a pairing never reverts to a promotion, and every node a write
23
+ /// changes is an ancestor of a leaf slot the write touched. That is what keeps writes witness-free:
24
+ /// {_recomputePaths} only ever walks up the dirty leaves' own ancestors, never a full tree or a zero
25
+ /// cascade.
26
+ contract ApprovedIssuerSet is IApprovedIssuerSet {
27
+ /// @notice The tree's maximum depth. A capacity bound only — the live root's actual depth is
28
+ /// {depth}.
29
+ uint256 public constant MAX_DEPTH = 20;
30
+
31
+ /// @notice The tree's leaf capacity, `2^MAX_DEPTH`.
32
+ uint256 public constant MAX_LEAVES = 2 ** MAX_DEPTH;
33
+
34
+ /// @notice The accepted-root window the set gets unless governance changes it.
35
+ uint256 public constant DEFAULT_ROOT_DURATION = 1 hours;
36
+
37
+ /// @notice How many live signing keys one member may hold.
38
+ /// @dev Bounds how many retirements {retireAllIssuerKeys} performs in one call, so a member
39
+ /// cannot make its own removal unaffordable by publishing without limit. Removal cost here is
40
+ /// `MAX_ISSUER_KEYS_PER_MEMBER` batched tree writes, not a function of the set's total size —
41
+ /// unlike the predecessor-scanning tree this contract replaced, this cap is the whole removal-cost
42
+ /// story.
43
+ uint256 public constant MAX_ISSUER_KEYS_PER_MEMBER = 8;
44
+
45
+ /// @dev Baby Jubjub in twisted Edwards form: `a·x² + y² = 1 + d·x²·y²` over the BN254 scalar field.
46
+ uint256 private constant CURVE_A = 168_700;
47
+ uint256 private constant CURVE_D = 168_696;
48
+
49
+ /// @dev The x coordinates of Baby Jubjub's eight low-order points. The curve has cofactor 8, and
50
+ /// for each x below every y satisfying the curve equation gives a low-order point: two for x = 0,
51
+ /// two each for the order-8 pair, and the single y = 0 for the other two. So rejecting these five
52
+ /// x coordinates rejects exactly those eight points and no key of full order.
53
+ uint256 private constant LOW_ORDER_X_0 = 0;
54
+ uint256 private constant LOW_ORDER_X_1 =
55
+ 2_957_874_849_018_779_266_517_920_829_765_869_116_077_630_550_401_372_566_248_359_756_137_677_864_698;
56
+ uint256 private constant LOW_ORDER_X_2 =
57
+ 4_342_719_913_949_491_028_786_768_530_115_087_822_524_712_248_835_451_589_697_801_404_893_164_183_326;
58
+ uint256 private constant LOW_ORDER_X_3 =
59
+ 17_545_522_957_889_784_193_459_637_215_142_187_266_023_652_151_580_582_754_000_402_781_682_644_312_291;
60
+ uint256 private constant LOW_ORDER_X_4 =
61
+ 18_930_368_022_820_495_955_728_484_915_491_405_972_470_733_850_014_661_777_449_844_430_438_130_630_919;
62
+
63
+ /// @inheritdoc IApprovedIssuerSet
64
+ address public immutable override memberRegistry;
65
+
66
+ /// @dev The accumulator. `nodes[0]` are leaves, `nodes[depth()][0]` is the root. A leaf slot with
67
+ /// no entry reads 0, meaning "never written" or "retired". Above level 0, 0 means "never written"
68
+ /// or a promoted tombstone: retiring a promoted leaf zeros every promoted copy above it, and the
69
+ /// copy where that chain pairs is a live zero sibling. Pairing is keyed on level width, never on a
70
+ /// node being zero.
71
+ mapping(uint256 level => mapping(uint256 index => uint256 node)) internal nodes;
72
+
73
+ /// @notice The number of leaf slots ever written, including retired ones. Never decreases.
74
+ /// @dev A retirement zeros a leaf's tree slot but leaves its index allocated, so a physical index
75
+ /// is never reused. `leafCount` is therefore both the next free index and the upper bound for
76
+ /// {getLeaves}.
77
+ uint256 public leafCount;
78
+
79
+ /// @notice The number of currently live (non-retired) keys in the set.
80
+ uint256 public liveCount;
81
+
82
+ /// @notice A leaf's physical slot, plus one so that 0 means "not currently live". Cleared on
83
+ /// retirement, which is what lets a retirement find its slot and reject a double-retire without a
84
+ /// witness.
85
+ mapping(uint256 leaf => uint256 indexPlusOne) internal leafSlot;
86
+
87
+ /// @dev The live keys of each member, so a removal can find them without an off-chain index. Order
88
+ /// is not preserved: retirement removes an entry by swap-pop.
89
+ mapping(bytes32 memberNetworkId => uint256[] leaves) internal issuerKeys;
90
+
91
+ /// @notice When a root stopped being current. Zero for a root that never was, or for the current
92
+ /// root — see {_claimRoot}.
93
+ mapping(uint256 issuerSetRoot => uint256 supersededAt) public rootSupersededAt;
94
+
95
+ /// @notice How long a superseded root stays acceptable, in seconds.
96
+ uint256 public rootDuration;
97
+
98
+ /// @notice The instant at or before which no superseded root is ever acceptable again.
99
+ /// @dev Bumped by {retireAllIssuerKeys} on every removal of a member that ever held a key, so
100
+ /// removing one member closes off every root superseded in the window before removal, not just the
101
+ /// one root the removal directly replaces.
102
+ uint256 public rootsInvalidatedAt;
103
+
104
+ /// @dev Whether a member has ever published a live key. Set once, never cleared, so
105
+ /// {retireAllIssuerKeys} can tell a member that genuinely never held signing power from one whose
106
+ /// keys were all retired before removal — only the latter's removal needs to invalidate history.
107
+ mapping(bytes32 memberNetworkId => bool) internal hasEverPublished;
108
+
109
+ event IssuerKeyPublished(bytes32 indexed memberNetworkId, uint256 leaf, uint256 index, uint256 issuerSetRoot);
110
+ event IssuerKeyRetired(bytes32 indexed memberNetworkId, uint256 leaf, uint256 issuerSetRoot);
111
+ event RootDurationUpdated(uint256 oldRootDuration, uint256 newRootDuration);
112
+
113
+ error UnauthorizedCaller();
114
+ error NotApproved(bytes32 memberNetworkId);
115
+ error CallerIsNotTheMemberRegistry();
116
+ error ZeroMemberRegistry();
117
+ error TooManyIssuerKeys(bytes32 memberNetworkId);
118
+ error IssuerSetFull();
119
+ error LeafCannotBeZero();
120
+ error IssuerKeyAlreadyPublished(uint256 leaf);
121
+ error IssuerKeyNotPublishedByMember(bytes32 memberNetworkId, uint256 leaf);
122
+ error PublicKeyNotInField();
123
+ error PublicKeyNotOnCurve();
124
+ error PublicKeyHasLowOrder();
125
+ error LeafIndexOutOfRange(uint256 index);
126
+
127
+ /// @dev Restricts a write to the member's current auth address for rotation, or governance for
128
+ /// recovery, mirroring how the registry gates a member's own record.
129
+ /// @param memberNetworkId The member whose keys are being written.
130
+ modifier onlyAuthAddressOrGovernance(bytes32 memberNetworkId) {
131
+ _onlyAuthAddressOrGovernance(memberNetworkId);
132
+ _;
133
+ }
134
+
135
+ function _onlyAuthAddressOrGovernance(bytes32 memberNetworkId) internal view {
136
+ NetworkMemberRegistry registry = NetworkMemberRegistry(memberRegistry);
137
+ address authAddress = registry.networkMemberAuthAddress(memberNetworkId);
138
+
139
+ if (msg.sender != authAddress && msg.sender != registry.owner()) revert UnauthorizedCaller();
140
+ if (authAddress == address(0)) revert NotApproved(memberNetworkId);
141
+ }
142
+
143
+ /// @param memberRegistry_ The NetworkMemberRegistry this set resolves authorization against.
144
+ constructor(address memberRegistry_) {
145
+ if (memberRegistry_ == address(0)) revert ZeroMemberRegistry();
146
+
147
+ memberRegistry = memberRegistry_;
148
+ rootDuration = DEFAULT_ROOT_DURATION;
149
+ }
150
+
151
+ /// @notice Publishes a member's signing key into the set.
152
+ /// @dev Superseded keys stay in the set, so a rotation does not invalidate credentials the old key
153
+ /// already signed. Retire the old key explicitly to end its life.
154
+ /// @param memberNetworkId The publishing member.
155
+ /// @param publicKeyX The Baby Jubjub public key's x coordinate.
156
+ /// @param publicKeyY The Baby Jubjub public key's y coordinate.
157
+ /// @return leaf The commitment inserted into the set.
158
+ function publishIssuerKey(bytes32 memberNetworkId, uint256 publicKeyX, uint256 publicKeyY)
159
+ external
160
+ onlyAuthAddressOrGovernance(memberNetworkId)
161
+ returns (uint256 leaf)
162
+ {
163
+ uint256[] storage memberKeys = issuerKeys[memberNetworkId];
164
+ if (memberKeys.length >= MAX_ISSUER_KEYS_PER_MEMBER) revert TooManyIssuerKeys(memberNetworkId);
165
+
166
+ _assertOnCurve(publicKeyX, publicKeyY);
167
+
168
+ leaf = IssuerSetHashes.issuerKeyLeaf(publicKeyX, publicKeyY);
169
+ // Unreachable for a genuine Poseidon output; guards the invariant that 0 means "empty or
170
+ // retired" everywhere else in this contract.
171
+ if (leaf == 0) revert LeafCannotBeZero();
172
+ if (leafSlot[leaf] != 0) revert IssuerKeyAlreadyPublished(leaf);
173
+ if (leafCount == MAX_LEAVES) revert IssuerSetFull();
174
+
175
+ uint256 index = leafCount;
176
+ uint256 oldRoot = nodes[_depthFor(leafCount)][0];
177
+
178
+ nodes[0][index] = leaf;
179
+ leafCount = index + 1;
180
+ ++liveCount;
181
+ leafSlot[leaf] = index + 1;
182
+ memberKeys.push(leaf);
183
+ hasEverPublished[memberNetworkId] = true;
184
+
185
+ _recomputePath(index);
186
+
187
+ uint256 newRoot = nodes[_depthFor(leafCount)][0];
188
+ _supersedeRoot(oldRoot);
189
+ _claimRoot(newRoot);
190
+
191
+ emit IssuerKeyPublished(memberNetworkId, leaf, index, newRoot);
192
+ }
193
+
194
+ /// @notice Publishes several of a member's signing keys in one call.
195
+ /// @dev All keys are validated before any tree write, and they share one {_recomputePaths} pass
196
+ /// instead of one per key. Reverts on the first invalid or duplicate key, so a batch either
197
+ /// publishes in full or not at all.
198
+ /// @param memberNetworkId The publishing member.
199
+ /// @param publicKeys The Baby Jubjub public keys to publish, each `[x, y]`.
200
+ /// @return leaves The commitments inserted into the set, in the same order as `publicKeys`.
201
+ function publishIssuerKeys(bytes32 memberNetworkId, uint256[2][] calldata publicKeys)
202
+ external
203
+ onlyAuthAddressOrGovernance(memberNetworkId)
204
+ returns (uint256[] memory leaves)
205
+ {
206
+ uint256 count = publicKeys.length;
207
+ uint256[] storage memberKeys = issuerKeys[memberNetworkId];
208
+ if (memberKeys.length + count > MAX_ISSUER_KEYS_PER_MEMBER) revert TooManyIssuerKeys(memberNetworkId);
209
+
210
+ uint256 startIndex = leafCount;
211
+ if (startIndex + count > MAX_LEAVES) revert IssuerSetFull();
212
+
213
+ // Read before the loop below writes nodes[0][startIndex]: on an empty set the depth-0 root
214
+ // slot is that first leaf, so reading after the loop would stamp the leaf as a bogus old root.
215
+ uint256 oldRoot = nodes[_depthFor(leafCount)][0];
216
+
217
+ leaves = new uint256[](count);
218
+ uint256[] memory dirty = new uint256[](count);
219
+
220
+ for (uint256 i; i < count; ++i) {
221
+ uint256 publicKeyX = publicKeys[i][0];
222
+ uint256 publicKeyY = publicKeys[i][1];
223
+ _assertOnCurve(publicKeyX, publicKeyY);
224
+
225
+ uint256 leaf = IssuerSetHashes.issuerKeyLeaf(publicKeyX, publicKeyY);
226
+ if (leaf == 0) revert LeafCannotBeZero();
227
+ if (leafSlot[leaf] != 0) revert IssuerKeyAlreadyPublished(leaf);
228
+
229
+ uint256 index = startIndex + i;
230
+ nodes[0][index] = leaf;
231
+ leafSlot[leaf] = index + 1;
232
+ memberKeys.push(leaf);
233
+
234
+ leaves[i] = leaf;
235
+ // startIndex + i is already ascending and duplicate-free, exactly what
236
+ // {_recomputePaths} requires.
237
+ dirty[i] = index;
238
+ }
239
+
240
+ leafCount = startIndex + count;
241
+ liveCount += count;
242
+ // Guarded: an empty batch published nothing, so it must not count as publishing history.
243
+ if (count != 0) hasEverPublished[memberNetworkId] = true;
244
+
245
+ _recomputePaths(dirty, count);
246
+
247
+ uint256 newRoot = nodes[_depthFor(leafCount)][0];
248
+ _supersedeRoot(oldRoot);
249
+ _claimRoot(newRoot);
250
+
251
+ for (uint256 i; i < count; ++i) {
252
+ emit IssuerKeyPublished(memberNetworkId, leaves[i], startIndex + i, newRoot);
253
+ }
254
+ }
255
+
256
+ /// @notice Retires one of a member's signing keys, ending its ability to verify.
257
+ /// @param memberNetworkId The member the key belongs to.
258
+ /// @param leaf The commitment to retire.
259
+ function retireIssuerKey(bytes32 memberNetworkId, uint256 leaf)
260
+ external
261
+ onlyAuthAddressOrGovernance(memberNetworkId)
262
+ {
263
+ _detachFromMember(memberNetworkId, leaf);
264
+
265
+ // `_detachFromMember` already proved this leaf was live and this member's, so its slot exists.
266
+ uint256 index = leafSlot[leaf] - 1;
267
+ uint256 oldRoot = nodes[_depthFor(leafCount)][0];
268
+
269
+ nodes[0][index] = 0;
270
+ delete leafSlot[leaf];
271
+ --liveCount;
272
+
273
+ _recomputePath(index);
274
+
275
+ uint256 newRoot = nodes[_depthFor(leafCount)][0];
276
+ _supersedeRoot(oldRoot);
277
+ _claimRoot(newRoot);
278
+
279
+ emit IssuerKeyRetired(memberNetworkId, leaf, newRoot);
280
+ }
281
+
282
+ /// @inheritdoc IApprovedIssuerSet
283
+ /// @dev Callable only by the registry, which invokes it while removing a member so that removal
284
+ /// and the loss of signing power are one transaction. Two transactions would leave a window in
285
+ /// which a removed member's key still signs credentials that verify.
286
+ ///
287
+ /// Superseding only the directly-replaced root is not enough: the removed member still holds the
288
+ /// key and the path behind every root superseded in the `rootDuration` window before this call, so
289
+ /// each of those would otherwise keep validating for up to a full window too, unattributably.
290
+ /// {rootsInvalidatedAt} closes every one of them in a single write instead of walking history: once
291
+ /// bumped to `block.timestamp`, {isValidIssuerSetRoot} refuses any non-current root superseded at
292
+ /// or before this instant — the current root's own fast path returns first and is never subject to
293
+ /// the watermark. Gated on {hasEverPublished} so a member that never held a key
294
+ /// bumps nothing — its removal stays a true no-op, and every other member's still-live grace
295
+ /// window survives it. The directly-replaced root's stamp is also deleted outright rather than
296
+ /// left to outlive the watermark, on the same belt-and-suspenders basis as {_claimRoot}.
297
+ /// The cost is that proofs other members had in flight against an invalidated root fail and have to
298
+ /// be rebuilt, which is the side of the trade a kill switch belongs on.
299
+ ///
300
+ /// Leaves the tree untouched for a member with no live keys, but still bumps the watermark if that
301
+ /// member ever published: the registry calls this on every removal, key-bearing history or not.
302
+ function retireAllIssuerKeys(bytes32 memberNetworkId) external {
303
+ if (msg.sender != memberRegistry) revert CallerIsNotTheMemberRegistry();
304
+
305
+ if (hasEverPublished[memberNetworkId]) rootsInvalidatedAt = block.timestamp;
306
+
307
+ uint256[] storage memberKeys = issuerKeys[memberNetworkId];
308
+ uint256 count = memberKeys.length;
309
+ if (count == 0) return;
310
+
311
+ uint256 oldRoot = nodes[_depthFor(leafCount)][0];
312
+
313
+ uint256[] memory leaves = new uint256[](count);
314
+ uint256[] memory dirty = new uint256[](count);
315
+
316
+ for (uint256 i; i < count; ++i) {
317
+ uint256 leaf = memberKeys[i];
318
+ uint256 index = leafSlot[leaf] - 1;
319
+
320
+ leaves[i] = leaf;
321
+
322
+ // The member's key list is not index-ordered (retirement swap-pops it), so insertion-sort
323
+ // each index into `dirty` as it is found — cheap at up to MAX_ISSUER_KEYS_PER_MEMBER
324
+ // entries, and it is what lets {_recomputePaths} receive the sorted, deduplicated input it
325
+ // requires.
326
+ uint256 j = i;
327
+ while (j > 0 && dirty[j - 1] > index) {
328
+ dirty[j] = dirty[j - 1];
329
+ --j;
330
+ }
331
+ dirty[j] = index;
332
+
333
+ nodes[0][index] = 0;
334
+ delete leafSlot[leaf];
335
+ }
336
+
337
+ liveCount -= count;
338
+ delete issuerKeys[memberNetworkId];
339
+
340
+ _recomputePaths(dirty, count);
341
+
342
+ uint256 newRoot = nodes[_depthFor(leafCount)][0];
343
+ delete rootSupersededAt[oldRoot];
344
+ _claimRoot(newRoot);
345
+
346
+ // One event per retired key, all carrying the same final root: batching means no intermediate
347
+ // root ever existed for a key retired mid-batch to carry instead.
348
+ for (uint256 i; i < count; ++i) {
349
+ emit IssuerKeyRetired(memberNetworkId, leaves[i], newRoot);
350
+ }
351
+ }
352
+
353
+ /// @notice Sets how long a superseded root stays acceptable.
354
+ /// @param newRootDuration The new window, in seconds.
355
+ function updateRootDuration(uint256 newRootDuration) external {
356
+ if (msg.sender != NetworkMemberRegistry(memberRegistry).owner()) revert UnauthorizedCaller();
357
+
358
+ uint256 oldRootDuration = rootDuration;
359
+
360
+ rootDuration = newRootDuration;
361
+ emit RootDurationUpdated(oldRootDuration, newRootDuration);
362
+ }
363
+
364
+ /// @notice The current root of the set. Zero while the set has never been written, or after a
365
+ /// one-slot set's only key is retired — there is no sibling to hash the tombstone against.
366
+ function issuerSetRoot() public view returns (uint256) {
367
+ return nodes[_depthFor(leafCount)][0];
368
+ }
369
+
370
+ /// @notice Whether a verifier should accept a proof built against a root.
371
+ /// @param root The root the proof output.
372
+ function isValidIssuerSetRoot(uint256 root) external view returns (bool) {
373
+ if (root == 0) return false;
374
+ if (root == nodes[_depthFor(leafCount)][0]) return true;
375
+
376
+ uint256 supersededAt = rootSupersededAt[root];
377
+
378
+ if (supersededAt == 0) return false;
379
+ // A watermark bumped on a later removal outranks any stamp from before it, so a root that
380
+ // would otherwise still be inside its window stops validating the instant that removal lands.
381
+ if (supersededAt <= rootsInvalidatedAt) return false;
382
+
383
+ // Strict, so a `rootDuration` of zero means no grace at all rather than a one-timestamp
384
+ // window. The window runs from the moment the root stopped being current, so every root gets
385
+ // the same full window however long it was current.
386
+ // forge-lint: disable-next-line(block-timestamp)
387
+ return block.timestamp < supersededAt + rootDuration;
388
+ }
389
+
390
+ /// @notice The live signing keys of a member.
391
+ /// @dev Public because publication is already a public transaction and a holder has to enumerate
392
+ /// leaves to rebuild the tree anyway. What the design hides is which key signed a given
393
+ /// credential, which no read here reveals.
394
+ /// @param memberNetworkId The member to look up.
395
+ function issuerKeysOf(bytes32 memberNetworkId) external view returns (uint256[] memory) {
396
+ return issuerKeys[memberNetworkId];
397
+ }
398
+
399
+ /// @notice Whether a commitment is currently in the set.
400
+ /// @param leaf The commitment to check.
401
+ function isApprovedIssuerKey(uint256 leaf) external view returns (bool) {
402
+ return leafSlot[leaf] != 0;
403
+ }
404
+
405
+ /// @notice The leaf value at a physical index. Zero for a retired leaf or an index never written.
406
+ /// @param index The physical slot, in `[0, getLeafCount())`.
407
+ function getLeaf(uint256 index) external view returns (uint256) {
408
+ return nodes[0][index];
409
+ }
410
+
411
+ /// @notice The leaf values in `[start, end)`.
412
+ /// @dev The private rebuild path for a holder: reading `[0, getLeafCount())` this way and
413
+ /// replaying the same level loop this contract uses reconstructs the whole tree off chain, so a
414
+ /// holder never has to call {siblingsOf} and reveal to its RPC provider which leaf it holds a
415
+ /// witness for.
416
+ /// @param start The inclusive start index.
417
+ /// @param end The exclusive end index.
418
+ function getLeaves(uint256 start, uint256 end) external view returns (uint256[] memory leaves) {
419
+ leaves = new uint256[](end - start);
420
+
421
+ for (uint256 i = start; i < end; ++i) {
422
+ leaves[i - start] = nodes[0][i];
423
+ }
424
+ }
425
+
426
+ /// @notice A node at an arbitrary level and index.
427
+ /// @param level The level, 0 for leaves up to `depth()` for the current root; {MAX_DEPTH} bounds
428
+ /// it.
429
+ /// @param index The index within that level.
430
+ function getNode(uint256 level, uint256 index) external view returns (uint256) {
431
+ return nodes[level][index];
432
+ }
433
+
434
+ /// @notice The path from `index` to the root: siblings at levels where one exists, and the packed
435
+ /// direction bits for exactly those levels, in the order the circuit's `binary_merkle_root`
436
+ /// consumes them.
437
+ /// @dev A convenience fast path, not the path a holder's normal traffic should use: calling this
438
+ /// per presentation tells the RPC provider, request after request, which physical leaf that holder
439
+ /// holds a witness for. {getLeaves} avoids that by letting a holder rebuild the tree, and every
440
+ /// sibling with it, without ever asking the chain "what is index's path".
441
+ ///
442
+ /// A promoted level has no sibling and is skipped, so `siblings.length` can be less than `depth()`
443
+ /// and `pathIndex` is not `index` itself — it packs only the included levels' direction bits, bit
444
+ /// `i` set when `index` is the right child at the `i`th included level.
445
+ /// @param index The physical leaf index to build a path for.
446
+ function siblingsOf(uint256 index) external view returns (uint256[] memory siblings, uint256 pathIndex) {
447
+ if (index >= leafCount) revert LeafIndexOutOfRange(index);
448
+
449
+ uint256 width = leafCount;
450
+ uint256 pathDepth = _depthFor(width);
451
+ uint256[MAX_DEPTH] memory buffer;
452
+ uint256 count;
453
+
454
+ for (uint256 level; level < pathDepth; ++level) {
455
+ if ((index ^ 1) < width) {
456
+ buffer[count] = nodes[level][index ^ 1];
457
+ if (index & 1 == 1) pathIndex |= 2 ** count;
458
+ ++count;
459
+ }
460
+
461
+ index >>= 1;
462
+ width = (width + 1) >> 1;
463
+ }
464
+
465
+ siblings = new uint256[](count);
466
+ for (uint256 i; i < count; ++i) {
467
+ siblings[i] = buffer[i];
468
+ }
469
+ }
470
+
471
+ /// @notice The number of physical leaf slots, including retired ones.
472
+ function getLeafCount() external view returns (uint256) {
473
+ return leafCount;
474
+ }
475
+
476
+ /// @notice The current depth of the tree: `ceil(log2(leafCount))`, 0 for 0 or 1 slots.
477
+ function depth() external view returns (uint256) {
478
+ return _depthFor(leafCount);
479
+ }
480
+
481
+ /// @notice The number of live keys in the set.
482
+ function getSize() external view returns (uint256) {
483
+ return liveCount;
484
+ }
485
+
486
+ /// @dev Removes one leaf from a member's key list by swap-pop. Reverts if the leaf is not that
487
+ /// member's. Leaves the tree and {leafSlot} untouched — the caller does that once it knows the
488
+ /// leaf really is the member's.
489
+ /// @param memberNetworkId The member the key should belong to.
490
+ /// @param leaf The commitment to detach.
491
+ function _detachFromMember(bytes32 memberNetworkId, uint256 leaf) private {
492
+ uint256[] storage memberKeys = issuerKeys[memberNetworkId];
493
+ uint256 keyCount = memberKeys.length;
494
+ uint256 position = keyCount;
495
+
496
+ for (uint256 i; i < keyCount; ++i) {
497
+ if (memberKeys[i] != leaf) continue;
498
+
499
+ position = i;
500
+ break;
501
+ }
502
+
503
+ if (position == keyCount) revert IssuerKeyNotPublishedByMember(memberNetworkId, leaf);
504
+
505
+ memberKeys[position] = memberKeys[keyCount - 1];
506
+ memberKeys.pop();
507
+ }
508
+
509
+ /// @dev Starts the expiry clock of the root a write just replaced. The empty-tree root is skipped
510
+ /// because no proof can be built against it.
511
+ /// @param oldRoot The root the write replaced.
512
+ function _supersedeRoot(uint256 oldRoot) private {
513
+ if (oldRoot == 0) return;
514
+
515
+ rootSupersededAt[oldRoot] = block.timestamp;
516
+ }
517
+
518
+ /// @dev Clears any supersession stamp on a root the instant it becomes current. With LeanIMT
519
+ /// promotion, a root can no longer recur the way a zero-padded root once could — every slot is
520
+ /// written at most once and never reused — so this is now a safety net against a hash collision
521
+ /// rather than a fix for a known revival case. Call this on every write that moves the root.
522
+ /// @param newRoot The root that just became current.
523
+ function _claimRoot(uint256 newRoot) private {
524
+ delete rootSupersededAt[newRoot];
525
+ }
526
+
527
+ /// @dev Recomputes every node from `dirty`'s leaf indices up to the root, given that each entry's
528
+ /// new leaf value is already written into `nodes[0]` and `leafCount` already reflects the write.
529
+ ///
530
+ /// `dirty[0..length)` must arrive sorted ascending and free of duplicates. Two dirty entries that
531
+ /// share a parent collapse to one recompute of it: sorted order turns that collapse into a single
532
+ /// compare against the previous parent instead of a set lookup. The same array carries each
533
+ /// level's deduplicated parent indices up to the next level, so no level allocates memory of its
534
+ /// own. `width` halves with each level, same as {_recomputeNode} needs to decide whether a
535
+ /// parent's right child exists.
536
+ /// @param dirty The leaf indices to recompute from. Overwritten level by level with that level's
537
+ /// deduplicated parent indices.
538
+ /// @param length The number of valid entries in `dirty`.
539
+ function _recomputePaths(uint256[] memory dirty, uint256 length) private {
540
+ uint256 width = leafCount;
541
+ uint256 pathDepth = _depthFor(width);
542
+
543
+ for (uint256 level; level < pathDepth; ++level) {
544
+ uint256 nextLength;
545
+ uint256 previousParent = type(uint256).max;
546
+
547
+ for (uint256 i; i < length; ++i) {
548
+ uint256 parent = dirty[i] >> 1;
549
+ if (parent == previousParent) continue;
550
+
551
+ _recomputeNode(level, parent, width);
552
+
553
+ dirty[nextLength] = parent;
554
+ ++nextLength;
555
+ previousParent = parent;
556
+ }
557
+
558
+ length = nextLength;
559
+ width = (width + 1) >> 1;
560
+ }
561
+ }
562
+
563
+ /// @dev {_recomputePaths} for the single-leaf case.
564
+ /// @param leafIndex The touched leaf index.
565
+ function _recomputePath(uint256 leafIndex) private {
566
+ uint256[] memory dirty = new uint256[](1);
567
+ dirty[0] = leafIndex;
568
+ _recomputePaths(dirty, 1);
569
+ }
570
+
571
+ /// @dev Recomputes one parent from its children, promoting the left child unchanged when the
572
+ /// level's width leaves it unpaired — the LeanIMT rule in place of a zero-padded hash. Reads the
573
+ /// left child once since both branches need it, and split out of {_recomputePaths} to keep that
574
+ /// loop's stack frame small.
575
+ /// @param level The children's level.
576
+ /// @param parent The parent index to write, at `level + 1`.
577
+ /// @param width The live width of `level`, deciding whether `parent`'s right child exists.
578
+ function _recomputeNode(uint256 level, uint256 parent, uint256 width) private {
579
+ uint256 leftIndex = parent << 1;
580
+ uint256 rightIndex = leftIndex | 1;
581
+ uint256 left = nodes[level][leftIndex];
582
+
583
+ nodes[level + 1][parent] =
584
+ rightIndex < width ? IssuerSetHashes.internalNode(left, nodes[level][rightIndex]) : left;
585
+ }
586
+
587
+ /// @dev The smallest `d` with `2^d >= n`; 0 for `n` of 0 or 1.
588
+ /// @param n The width to size a depth for.
589
+ function _depthFor(uint256 n) private pure returns (uint256 d) {
590
+ uint256 capacity = 1;
591
+ while (capacity < n) {
592
+ capacity <<= 1;
593
+ ++d;
594
+ }
595
+ }
596
+
597
+ /// @dev Rejects a public key that is not a Baby Jubjub point of full order.
598
+ ///
599
+ /// The tree would accept any field element as a leaf, so without the curve check the set could
600
+ /// hold a commitment to a point that does not exist.
601
+ ///
602
+ /// The order check is what stops a member handing everyone else its issuing power. Under a
603
+ /// low-order public key `A`, the term `h·A` in EdDSA verification takes at most eight values
604
+ /// whatever the message, so anyone can solve `S·B = R + h·A` for a signature that verifies over a
605
+ /// claim tree they chose. At the identity it is immediate: `h·A` vanishes, so any `S` with
606
+ /// `R = S·B` passes. A member publishing such a key would let any stranger mint credentials that
607
+ /// verify as issued by an approved member, and issuer anonymity means no verifier could say whose
608
+ /// key to retire.
609
+ ///
610
+ /// @param publicKeyX The x coordinate.
611
+ /// @param publicKeyY The y coordinate.
612
+ function _assertOnCurve(uint256 publicKeyX, uint256 publicKeyY) private pure {
613
+ if (publicKeyX >= SNARK_SCALAR_FIELD || publicKeyY >= SNARK_SCALAR_FIELD) revert PublicKeyNotInField();
614
+
615
+ if (
616
+ publicKeyX == LOW_ORDER_X_0 || publicKeyX == LOW_ORDER_X_1 || publicKeyX == LOW_ORDER_X_2
617
+ || publicKeyX == LOW_ORDER_X_3 || publicKeyX == LOW_ORDER_X_4
618
+ ) revert PublicKeyHasLowOrder();
619
+
620
+ uint256 xSquared = mulmod(publicKeyX, publicKeyX, SNARK_SCALAR_FIELD);
621
+ uint256 ySquared = mulmod(publicKeyY, publicKeyY, SNARK_SCALAR_FIELD);
622
+
623
+ uint256 left = addmod(mulmod(CURVE_A, xSquared, SNARK_SCALAR_FIELD), ySquared, SNARK_SCALAR_FIELD);
624
+ uint256 right = addmod(
625
+ 1, mulmod(CURVE_D, mulmod(xSquared, ySquared, SNARK_SCALAR_FIELD), SNARK_SCALAR_FIELD), SNARK_SCALAR_FIELD
626
+ );
627
+
628
+ if (left != right) revert PublicKeyNotOnCurve();
629
+ }
630
+ }