@coordination-network/network-member-proof 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/network-member-proof
2
+
3
+ Generates the zero-knowledge proof that authorises an Identity Registry write as an approved network member.
4
+
5
+ The registry keeps no allowlist of member addresses. Authorisation is a Semaphore membership proof against the approved-member group, and every mutating function takes one: `registerIdentity`, `updateIdentity`, `removeIdentity`. What the chain learns is "an approved member made this change". It does not learn which one, and because no write path reads `msg.sender`, it learns nothing from the sending account either: submit from a fresh stealth address, with a paymaster paying, and the write is unlinkable to you.
6
+
7
+ **Scope.** This package does one thing: turn an identity, a group and an intended update into a proof the registry accepts. Creating a member identity, admitting members and mirroring the group from chain state are onboarding and governance concerns, handled separately. `generateSemaphoreProof` takes Semaphore's own `Identity` and `Group` as given.
8
+
9
+ ## What the proof commits to
10
+
11
+ - **The message is the update itself.** `updateMessage()` hashes the action (register, update, remove), the `networkId`, the meta-stealth address, the registry address and the chain id. You do not submit it: the proof struct the registry takes has no message field, and the registry fills it in with the message it derives from the payload you passed. So a proof for "register A" submitted with the "register B" payload fails verification, and the registry address and chain id keep it from being usable at another deployment, including one sharing the same Semaphore group. `generateSemaphoreProof` handles the derivation; `updateMessage` is exported for anyone verifying a proof off chain, which does need it.
12
+ - **The scope is random, once per proof.** A Semaphore nullifier is `poseidon(secret, scope)`, so a fresh scope means a fresh nullifier: your updates do not link to each other, and there is no per-member rate limit. Semaphore still records every nullifier it has seen for the group, which is what makes resubmitting the same proof revert. Leave `scope` alone unless you have a reason not to; `randomScope()` is what the default uses.
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { Group, Identity } from "@semaphore-protocol/core"
18
+ import { generateSemaphoreProof, UpdateAction } from "@coordination-network/network-member-proof"
19
+
20
+ const identity = Identity.import(process.env.MEMBER_IDENTITY!)
21
+
22
+ // The approved-member group as the registry's Semaphore group currently holds it, rebuilt from its
23
+ // on-chain membership events.
24
+ const group = new Group([123n, identity.commitment])
25
+
26
+ const proof = await generateSemaphoreProof({
27
+ identity,
28
+ group,
29
+ registry: { chainId: 11155111n, address: "0x..." },
30
+ update: {
31
+ action: UpdateAction.Register,
32
+ networkId: "0x...",
33
+ metaStealthAddress: "0x..." // 66 bytes, ERC-6538 scheme 1
34
+ }
35
+ })
36
+
37
+ // registerIdentity(networkId, metaStealthAddress, proof)
38
+ ```
39
+
40
+ Submit it from wherever you like. The proof is the authorisation; the sender is not.
41
+
42
+ Two things to get right before submitting:
43
+
44
+ - **The group must match the chain.** A stale mirror produces a proof against an old root, which Semaphore accepts only inside the recent-root window below. Note that a removal leaves a zero leaf in place rather than compacting the tree, so a mirror has to replay the membership history rather than collect the current commitments.
45
+ - **The payload must match the proof.** Submit the exact `networkId` and meta-stealth address you passed to `generateSemaphoreProof`. Anything else reverts `Semaphore__InvalidProof`, because the registry will be verifying against the message for the payload you actually sent.
46
+
47
+ ## The recent-root window
48
+
49
+ Membership changes move the group root, and a proof generated a moment earlier is against the old one. Semaphore accepts a superseded root for the group's Merkle tree duration, one hour by default, so in-flight proofs are not lost every time governance admits a member.
50
+
51
+ The cost is the mirror image: a removed member's already-generated proof stays usable until that root expires. Governance removing a member for cause should treat the removal as effective at `removal time + one hour`. After that the ex-member has no live root left to prove against and cannot build a proof against the current one, because their leaf is gone.
52
+
53
+ ## Regenerating the Solidity fixtures
54
+
55
+ The contract tests verify real proofs, produced here and checked in at `packages/contracts/test/fixtures/network-member-proofs.json`. Regenerate them after any change to the message derivation:
56
+
57
+ ```sh
58
+ yarn workspace @coordination-network/network-member-proof generate:fixtures
59
+ ```
60
+
61
+ The fixtures pin the chain id and the registry address the proofs are bound to, and use fixed scopes so they are reproducible; the Solidity test base deploys the registry to that address.
package/dist/index.cjs ADDED
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ var viem = require('viem');
4
+ var proof = require('@semaphore-protocol/proof');
5
+
6
+ /** Which mutation of the IdentityRegistry a proof authorises. Matches the on-chain enum. */
7
+ exports.UpdateAction = void 0;
8
+ (function (UpdateAction) {
9
+ UpdateAction[UpdateAction["Register"] = 0] = "Register";
10
+ UpdateAction[UpdateAction["Update"] = 1] = "Update";
11
+ UpdateAction[UpdateAction["Remove"] = 2] = "Remove";
12
+ })(exports.UpdateAction || (exports.UpdateAction = {}));
13
+ /** Which mutation of the CredentialRevocationRegistry a proof authorises. Matches the on-chain enum. */
14
+ exports.RevocationAction = void 0;
15
+ (function (RevocationAction) {
16
+ RevocationAction[RevocationAction["Revoke"] = 0] = "Revoke";
17
+ RevocationAction[RevocationAction["RevokeMany"] = 1] = "RevokeMany";
18
+ RevocationAction[RevocationAction["Unrevoke"] = 2] = "Unrevoke";
19
+ RevocationAction[RevocationAction["UpdateCommitment"] = 3] = "UpdateCommitment";
20
+ })(exports.RevocationAction || (exports.RevocationAction = {}));
21
+
22
+ /** Domain string in the registry's `UPDATE_DOMAIN_SEPARATOR`. */
23
+ const UPDATE_MESSAGE_DOMAIN = "coordination-network.identity-registry.update.v1";
24
+ /** Empty meta-stealth address, the placeholder a removal binds. */
25
+ const NO_META_STEALTH_ADDRESS = "0x";
26
+ /**
27
+ * The constant half of an update message, mirroring the registry's `UPDATE_DOMAIN_SEPARATOR`. The
28
+ * registry hashes it once at deployment, so a client can also read it from there.
29
+ */
30
+ function updateDomainSeparator(registryAddress) {
31
+ return viem.keccak256(viem.encodeAbiParameters([{ type: "string" }, { type: "address" }], [UPDATE_MESSAGE_DOMAIN, registryAddress]));
32
+ }
33
+ /**
34
+ * The Semaphore message binding a proof to one registry update, mirroring the message the registry
35
+ * derives from the arguments it is about to apply: the payload, the registry and the chain.
36
+ *
37
+ * The payload is what stops a proof authorising a different update. The registry address and the
38
+ * chain id place that update at one deployment: a registry does not own its Semaphore group, so two
39
+ * registries can share one, and the same registry can exist at the same address on two chains with
40
+ * the same members and therefore the same root.
41
+ *
42
+ * The registry never accepts a message from the caller, so a proof built for the wrong update does
43
+ * not fail a comparison, it fails verification.
44
+ */
45
+ function updateMessage(registry, update) {
46
+ if (update.action === exports.UpdateAction.Remove && update.metaStealthAddress !== undefined) {
47
+ throw new Error("a removal binds no meta-stealth address");
48
+ }
49
+ // Caught here rather than on chain: a proof over the wrong payload costs a full verification to
50
+ // fail, and the proof took real work to produce.
51
+ if (update.action !== exports.UpdateAction.Remove && update.metaStealthAddress === undefined) {
52
+ throw new Error("a registration or update binds a meta-stealth address");
53
+ }
54
+ const metaStealthAddress = update.metaStealthAddress ?? NO_META_STEALTH_ADDRESS;
55
+ return BigInt(viem.keccak256(viem.encodePacked(["bytes32", "uint256", "uint8", "bytes32", "bytes"], [
56
+ updateDomainSeparator(registry.address),
57
+ registry.chainId,
58
+ update.action,
59
+ update.networkId,
60
+ metaStealthAddress
61
+ ])));
62
+ }
63
+ /**
64
+ * A fresh random scope.
65
+ *
66
+ * A Semaphore nullifier is `poseidon(secret, scope)`, so a random scope gives every proof an
67
+ * unlinkable nullifier: the registry only ever learns that some approved member authorised the
68
+ * update, never that two updates came from the same one. The registry does not constrain the scope;
69
+ * what it relies on is that the nullifier a proof carries can only be spent once.
70
+ */
71
+ function randomScope() {
72
+ // 31 bytes, so the scope is always a field element. Web Crypto and viem only, so this works
73
+ // in a browser as well as in Node.
74
+ const bytes = new Uint8Array(31);
75
+ crypto.getRandomValues(bytes);
76
+ return BigInt(viem.bytesToHex(bytes));
77
+ }
78
+
79
+ /**
80
+ * Proves membership over an already derived message, in the shape a NetworkMemberGate expects.
81
+ *
82
+ * The message is absent from the result: the gated contract derives it from the call's own arguments,
83
+ * so submitting a proof means submitting the payload it was generated for.
84
+ */
85
+ async function proveMessage(identity, group, message, scope) {
86
+ const proof$1 = await proof.generateProof(identity, group, message, scope);
87
+ return {
88
+ merkleTreeDepth: BigInt(proof$1.merkleTreeDepth),
89
+ merkleTreeRoot: BigInt(proof$1.merkleTreeRoot),
90
+ nullifier: BigInt(proof$1.nullifier),
91
+ scope: BigInt(proof$1.scope),
92
+ points: proof$1.points.map(BigInt)
93
+ };
94
+ }
95
+
96
+ /** Domain string in the registry's `REVOCATION_DOMAIN_SEPARATOR`. */
97
+ const REVOCATION_MESSAGE_DOMAIN = "coordination-network.credential-revocation-registry.revocation.v1";
98
+ /**
99
+ * The constant half of a revocation message, mirroring the registry's `REVOCATION_DOMAIN_SEPARATOR`.
100
+ * The registry hashes it once at deployment, so a client can read it from there instead.
101
+ */
102
+ function revocationDomainSeparator(registryAddress) {
103
+ return viem.keccak256(viem.encodeAbiParameters([{ type: "string" }, { type: "address" }], [REVOCATION_MESSAGE_DOMAIN, registryAddress]));
104
+ }
105
+ /**
106
+ * The message binding a proof to one mutation of the credential revocation registry: the action and
107
+ * the commitments, at that registry on that chain. It mirrors what the registry derives from the
108
+ * arguments it applies, so a proof aimed elsewhere fails verification.
109
+ *
110
+ * Predecessor indices are absent here as they are on chain. An index is a witness the LeanIMT+
111
+ * validates, not a privilege, so leaving it unbound lets a member who lost a race recompute it and
112
+ * resubmit the same proof; a revert does not burn the nullifier.
113
+ *
114
+ * `RevokeMany` is a distinct action because a one-commitment batch would otherwise pack identically
115
+ * to a single revocation.
116
+ */
117
+ function revocationMessage(registry, update) {
118
+ switch (update.action) {
119
+ case exports.RevocationAction.Revoke:
120
+ case exports.RevocationAction.Unrevoke:
121
+ return BigInt(viem.keccak256(viem.encodePacked(["bytes32", "uint256", "uint8", "uint256"], [
122
+ revocationDomainSeparator(registry.address),
123
+ registry.chainId,
124
+ update.action,
125
+ update.revocationCommitment
126
+ ])));
127
+ case exports.RevocationAction.RevokeMany:
128
+ if (update.revocationCommitments.length === 0) {
129
+ throw new Error("a batch binds at least one commitment");
130
+ }
131
+ return BigInt(viem.keccak256(viem.encodePacked(["bytes32", "uint256", "uint8", "uint256[]"], [
132
+ revocationDomainSeparator(registry.address),
133
+ registry.chainId,
134
+ update.action,
135
+ [...update.revocationCommitments]
136
+ ])));
137
+ case exports.RevocationAction.UpdateCommitment:
138
+ return BigInt(viem.keccak256(viem.encodePacked(["bytes32", "uint256", "uint8", "uint256", "uint256"], [
139
+ revocationDomainSeparator(registry.address),
140
+ registry.chainId,
141
+ update.action,
142
+ update.oldRevocationCommitment,
143
+ update.newRevocationCommitment
144
+ ])));
145
+ default:
146
+ throw new Error("unknown revocation action");
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Generates the membership proof that authorises one write to the credential revocation registry.
152
+ *
153
+ * The proof states that some leaf of the approved-member group signed off on this exact revocation,
154
+ * and nothing else: not which leaf, and not who submits it. Submit it from any address, including a
155
+ * fresh stealth address with a paymaster covering the gas, since no write path reads `msg.sender`.
156
+ *
157
+ * One tree holds the whole network's revocations, so nothing about the write names the issuer either.
158
+ */
159
+ async function generateRevocationProof({ identity, group, registry, update, scope = randomScope() }) {
160
+ return proveMessage(identity, group, revocationMessage(registry, update), scope);
161
+ }
162
+
163
+ /**
164
+ * Generates the membership proof that authorises one registry update.
165
+ *
166
+ * The proof states that some leaf of the approved-member group signed off on this exact update, and
167
+ * nothing else: not which leaf, and not who submits it. Submit it from any address, since no write
168
+ * path reads `msg.sender`.
169
+ *
170
+ * `identity` and `group` are Semaphore's own types, taken as given here. Creating a member identity
171
+ * and maintaining the group are governance and onboarding concerns, not proof generation.
172
+ *
173
+ * The message binds the proof to this update at this registry on this chain, so it cannot be
174
+ * replayed against another payload, and Semaphore burns the nullifier on the way in, so the proof
175
+ * itself cannot be submitted twice.
176
+ */
177
+ async function generateSemaphoreProof({ identity, group, registry, update, scope = randomScope() }) {
178
+ return proveMessage(identity, group, updateMessage(registry, update), scope);
179
+ }
180
+
181
+ exports.REVOCATION_MESSAGE_DOMAIN = REVOCATION_MESSAGE_DOMAIN;
182
+ exports.UPDATE_MESSAGE_DOMAIN = UPDATE_MESSAGE_DOMAIN;
183
+ exports.generateRevocationProof = generateRevocationProof;
184
+ exports.generateSemaphoreProof = generateSemaphoreProof;
185
+ exports.randomScope = randomScope;
186
+ exports.revocationDomainSeparator = revocationDomainSeparator;
187
+ exports.revocationMessage = revocationMessage;
188
+ exports.updateDomainSeparator = updateDomainSeparator;
189
+ exports.updateMessage = updateMessage;
package/dist/index.js ADDED
@@ -0,0 +1,179 @@
1
+ import { bytesToHex, keccak256, encodeAbiParameters, encodePacked } from 'viem';
2
+ import { generateProof } from '@semaphore-protocol/proof';
3
+
4
+ /** Which mutation of the IdentityRegistry a proof authorises. Matches the on-chain enum. */
5
+ var UpdateAction;
6
+ (function (UpdateAction) {
7
+ UpdateAction[UpdateAction["Register"] = 0] = "Register";
8
+ UpdateAction[UpdateAction["Update"] = 1] = "Update";
9
+ UpdateAction[UpdateAction["Remove"] = 2] = "Remove";
10
+ })(UpdateAction || (UpdateAction = {}));
11
+ /** Which mutation of the CredentialRevocationRegistry a proof authorises. Matches the on-chain enum. */
12
+ var RevocationAction;
13
+ (function (RevocationAction) {
14
+ RevocationAction[RevocationAction["Revoke"] = 0] = "Revoke";
15
+ RevocationAction[RevocationAction["RevokeMany"] = 1] = "RevokeMany";
16
+ RevocationAction[RevocationAction["Unrevoke"] = 2] = "Unrevoke";
17
+ RevocationAction[RevocationAction["UpdateCommitment"] = 3] = "UpdateCommitment";
18
+ })(RevocationAction || (RevocationAction = {}));
19
+
20
+ /** Domain string in the registry's `UPDATE_DOMAIN_SEPARATOR`. */
21
+ const UPDATE_MESSAGE_DOMAIN = "coordination-network.identity-registry.update.v1";
22
+ /** Empty meta-stealth address, the placeholder a removal binds. */
23
+ const NO_META_STEALTH_ADDRESS = "0x";
24
+ /**
25
+ * The constant half of an update message, mirroring the registry's `UPDATE_DOMAIN_SEPARATOR`. The
26
+ * registry hashes it once at deployment, so a client can also read it from there.
27
+ */
28
+ function updateDomainSeparator(registryAddress) {
29
+ return keccak256(encodeAbiParameters([{ type: "string" }, { type: "address" }], [UPDATE_MESSAGE_DOMAIN, registryAddress]));
30
+ }
31
+ /**
32
+ * The Semaphore message binding a proof to one registry update, mirroring the message the registry
33
+ * derives from the arguments it is about to apply: the payload, the registry and the chain.
34
+ *
35
+ * The payload is what stops a proof authorising a different update. The registry address and the
36
+ * chain id place that update at one deployment: a registry does not own its Semaphore group, so two
37
+ * registries can share one, and the same registry can exist at the same address on two chains with
38
+ * the same members and therefore the same root.
39
+ *
40
+ * The registry never accepts a message from the caller, so a proof built for the wrong update does
41
+ * not fail a comparison, it fails verification.
42
+ */
43
+ function updateMessage(registry, update) {
44
+ if (update.action === UpdateAction.Remove && update.metaStealthAddress !== undefined) {
45
+ throw new Error("a removal binds no meta-stealth address");
46
+ }
47
+ // Caught here rather than on chain: a proof over the wrong payload costs a full verification to
48
+ // fail, and the proof took real work to produce.
49
+ if (update.action !== UpdateAction.Remove && update.metaStealthAddress === undefined) {
50
+ throw new Error("a registration or update binds a meta-stealth address");
51
+ }
52
+ const metaStealthAddress = update.metaStealthAddress ?? NO_META_STEALTH_ADDRESS;
53
+ return BigInt(keccak256(encodePacked(["bytes32", "uint256", "uint8", "bytes32", "bytes"], [
54
+ updateDomainSeparator(registry.address),
55
+ registry.chainId,
56
+ update.action,
57
+ update.networkId,
58
+ metaStealthAddress
59
+ ])));
60
+ }
61
+ /**
62
+ * A fresh random scope.
63
+ *
64
+ * A Semaphore nullifier is `poseidon(secret, scope)`, so a random scope gives every proof an
65
+ * unlinkable nullifier: the registry only ever learns that some approved member authorised the
66
+ * update, never that two updates came from the same one. The registry does not constrain the scope;
67
+ * what it relies on is that the nullifier a proof carries can only be spent once.
68
+ */
69
+ function randomScope() {
70
+ // 31 bytes, so the scope is always a field element. Web Crypto and viem only, so this works
71
+ // in a browser as well as in Node.
72
+ const bytes = new Uint8Array(31);
73
+ crypto.getRandomValues(bytes);
74
+ return BigInt(bytesToHex(bytes));
75
+ }
76
+
77
+ /**
78
+ * Proves membership over an already derived message, in the shape a NetworkMemberGate expects.
79
+ *
80
+ * The message is absent from the result: the gated contract derives it from the call's own arguments,
81
+ * so submitting a proof means submitting the payload it was generated for.
82
+ */
83
+ async function proveMessage(identity, group, message, scope) {
84
+ const proof = await generateProof(identity, group, message, scope);
85
+ return {
86
+ merkleTreeDepth: BigInt(proof.merkleTreeDepth),
87
+ merkleTreeRoot: BigInt(proof.merkleTreeRoot),
88
+ nullifier: BigInt(proof.nullifier),
89
+ scope: BigInt(proof.scope),
90
+ points: proof.points.map(BigInt)
91
+ };
92
+ }
93
+
94
+ /** Domain string in the registry's `REVOCATION_DOMAIN_SEPARATOR`. */
95
+ const REVOCATION_MESSAGE_DOMAIN = "coordination-network.credential-revocation-registry.revocation.v1";
96
+ /**
97
+ * The constant half of a revocation message, mirroring the registry's `REVOCATION_DOMAIN_SEPARATOR`.
98
+ * The registry hashes it once at deployment, so a client can read it from there instead.
99
+ */
100
+ function revocationDomainSeparator(registryAddress) {
101
+ return keccak256(encodeAbiParameters([{ type: "string" }, { type: "address" }], [REVOCATION_MESSAGE_DOMAIN, registryAddress]));
102
+ }
103
+ /**
104
+ * The message binding a proof to one mutation of the credential revocation registry: the action and
105
+ * the commitments, at that registry on that chain. It mirrors what the registry derives from the
106
+ * arguments it applies, so a proof aimed elsewhere fails verification.
107
+ *
108
+ * Predecessor indices are absent here as they are on chain. An index is a witness the LeanIMT+
109
+ * validates, not a privilege, so leaving it unbound lets a member who lost a race recompute it and
110
+ * resubmit the same proof; a revert does not burn the nullifier.
111
+ *
112
+ * `RevokeMany` is a distinct action because a one-commitment batch would otherwise pack identically
113
+ * to a single revocation.
114
+ */
115
+ function revocationMessage(registry, update) {
116
+ switch (update.action) {
117
+ case RevocationAction.Revoke:
118
+ case RevocationAction.Unrevoke:
119
+ return BigInt(keccak256(encodePacked(["bytes32", "uint256", "uint8", "uint256"], [
120
+ revocationDomainSeparator(registry.address),
121
+ registry.chainId,
122
+ update.action,
123
+ update.revocationCommitment
124
+ ])));
125
+ case RevocationAction.RevokeMany:
126
+ if (update.revocationCommitments.length === 0) {
127
+ throw new Error("a batch binds at least one commitment");
128
+ }
129
+ return BigInt(keccak256(encodePacked(["bytes32", "uint256", "uint8", "uint256[]"], [
130
+ revocationDomainSeparator(registry.address),
131
+ registry.chainId,
132
+ update.action,
133
+ [...update.revocationCommitments]
134
+ ])));
135
+ case RevocationAction.UpdateCommitment:
136
+ return BigInt(keccak256(encodePacked(["bytes32", "uint256", "uint8", "uint256", "uint256"], [
137
+ revocationDomainSeparator(registry.address),
138
+ registry.chainId,
139
+ update.action,
140
+ update.oldRevocationCommitment,
141
+ update.newRevocationCommitment
142
+ ])));
143
+ default:
144
+ throw new Error("unknown revocation action");
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Generates the membership proof that authorises one write to the credential revocation registry.
150
+ *
151
+ * The proof states that some leaf of the approved-member group signed off on this exact revocation,
152
+ * and nothing else: not which leaf, and not who submits it. Submit it from any address, including a
153
+ * fresh stealth address with a paymaster covering the gas, since no write path reads `msg.sender`.
154
+ *
155
+ * One tree holds the whole network's revocations, so nothing about the write names the issuer either.
156
+ */
157
+ async function generateRevocationProof({ identity, group, registry, update, scope = randomScope() }) {
158
+ return proveMessage(identity, group, revocationMessage(registry, update), scope);
159
+ }
160
+
161
+ /**
162
+ * Generates the membership proof that authorises one registry update.
163
+ *
164
+ * The proof states that some leaf of the approved-member group signed off on this exact update, and
165
+ * nothing else: not which leaf, and not who submits it. Submit it from any address, since no write
166
+ * path reads `msg.sender`.
167
+ *
168
+ * `identity` and `group` are Semaphore's own types, taken as given here. Creating a member identity
169
+ * and maintaining the group are governance and onboarding concerns, not proof generation.
170
+ *
171
+ * The message binds the proof to this update at this registry on this chain, so it cannot be
172
+ * replayed against another payload, and Semaphore burns the nullifier on the way in, so the proof
173
+ * itself cannot be submitted twice.
174
+ */
175
+ async function generateSemaphoreProof({ identity, group, registry, update, scope = randomScope() }) {
176
+ return proveMessage(identity, group, updateMessage(registry, update), scope);
177
+ }
178
+
179
+ export { REVOCATION_MESSAGE_DOMAIN, RevocationAction, UPDATE_MESSAGE_DOMAIN, UpdateAction, generateRevocationProof, generateSemaphoreProof, randomScope, revocationDomainSeparator, revocationMessage, updateDomainSeparator, updateMessage };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@coordination-network/network-member-proof",
3
+ "version": "0.0.1",
4
+ "description": "Generates the Semaphore membership proofs that authorise an IdentityRegistry or CredentialRevocationRegistry write as an approved network member, bound to that exact update.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/network-member-proof"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/network-member-proof",
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
+ "generate:fixtures": "tsx tests/generate-fixtures.ts",
38
+ "generate:revocation-fixtures": "tsx tests/generate-revocation-fixtures.ts"
39
+ },
40
+ "dependencies": {
41
+ "@semaphore-protocol/group": "4.14.3",
42
+ "@semaphore-protocol/identity": "4.14.3",
43
+ "@semaphore-protocol/proof": "4.14.3",
44
+ "viem": "^2.40.0"
45
+ },
46
+ "devDependencies": {
47
+ "@rollup/plugin-typescript": "^12.3.0",
48
+ "@types/jest": "^30.0.0",
49
+ "rimraf": "^6.1.3",
50
+ "rollup": "^4.62.2",
51
+ "rollup-plugin-cleanup": "^3.2.1",
52
+ "rollup-plugin-dts": "^6.4.1",
53
+ "tslib": "^2.8.0",
54
+ "tsx": "^4.23.0",
55
+ "typescript": "^6.0.3"
56
+ }
57
+ }
@@ -0,0 +1,42 @@
1
+ import { Group } from "@semaphore-protocol/group"
2
+ import { Identity } from "@semaphore-protocol/identity"
3
+ import { randomScope } from "./message"
4
+ import { proveMessage } from "./prove"
5
+ import { revocationMessage } from "./revocation-message"
6
+ import { NetworkMemberProof, RegistryLocation, RevocationUpdate } from "./types"
7
+
8
+ export type GenerateRevocationProofOptions = {
9
+ /** The network member's Semaphore identity. Its commitment must be a leaf of `group`. */
10
+ identity: Identity
11
+ /**
12
+ * The approved-member group, as the Semaphore contract currently holds it. Rebuild it from the
13
+ * group's on-chain membership events: a proof against a root the group no longer holds is only
14
+ * accepted inside the recent-root window.
15
+ */
16
+ group: Group
17
+ /** Where the credential revocation registry is deployed. */
18
+ registry: RegistryLocation
19
+ /** The revocation being authorised. */
20
+ update: RevocationUpdate
21
+ /** The proof's scope. Defaults to a fresh random one, which is what keeps proofs unlinkable. */
22
+ scope?: bigint
23
+ }
24
+
25
+ /**
26
+ * Generates the membership proof that authorises one write to the credential revocation registry.
27
+ *
28
+ * The proof states that some leaf of the approved-member group signed off on this exact revocation,
29
+ * and nothing else: not which leaf, and not who submits it. Submit it from any address, including a
30
+ * fresh stealth address with a paymaster covering the gas, since no write path reads `msg.sender`.
31
+ *
32
+ * One tree holds the whole network's revocations, so nothing about the write names the issuer either.
33
+ */
34
+ export async function generateRevocationProof({
35
+ identity,
36
+ group,
37
+ registry,
38
+ update,
39
+ scope = randomScope()
40
+ }: GenerateRevocationProofOptions): Promise<NetworkMemberProof> {
41
+ return proveMessage(identity, group, revocationMessage(registry, update), scope)
42
+ }
@@ -0,0 +1,46 @@
1
+ import { Group } from "@semaphore-protocol/group"
2
+ import { Identity } from "@semaphore-protocol/identity"
3
+ import { randomScope, updateMessage } from "./message"
4
+ import { proveMessage } from "./prove"
5
+ import { IdentityUpdate, NetworkMemberProof, RegistryLocation } from "./types"
6
+
7
+ export type GenerateSemaphoreProofOptions = {
8
+ /** The network member's Semaphore identity. Its commitment must be a leaf of `group`. */
9
+ identity: Identity
10
+ /**
11
+ * The approved-member group, as the registry's Semaphore group currently holds it. Rebuild it
12
+ * from the group's on-chain membership events: a proof against a root the group no longer holds
13
+ * is only accepted inside Semaphore's recent-root window.
14
+ */
15
+ group: Group
16
+ /** Where the registry is deployed. */
17
+ registry: RegistryLocation
18
+ /** The update being authorised. */
19
+ update: IdentityUpdate
20
+ /** The proof's scope. Defaults to a fresh random one, which is what keeps proofs unlinkable. */
21
+ scope?: bigint
22
+ }
23
+
24
+ /**
25
+ * Generates the membership proof that authorises one registry update.
26
+ *
27
+ * The proof states that some leaf of the approved-member group signed off on this exact update, and
28
+ * nothing else: not which leaf, and not who submits it. Submit it from any address, since no write
29
+ * path reads `msg.sender`.
30
+ *
31
+ * `identity` and `group` are Semaphore's own types, taken as given here. Creating a member identity
32
+ * and maintaining the group are governance and onboarding concerns, not proof generation.
33
+ *
34
+ * The message binds the proof to this update at this registry on this chain, so it cannot be
35
+ * replayed against another payload, and Semaphore burns the nullifier on the way in, so the proof
36
+ * itself cannot be submitted twice.
37
+ */
38
+ export async function generateSemaphoreProof({
39
+ identity,
40
+ group,
41
+ registry,
42
+ update,
43
+ scope = randomScope()
44
+ }: GenerateSemaphoreProofOptions): Promise<NetworkMemberProof> {
45
+ return proveMessage(identity, group, updateMessage(registry, update), scope)
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { generateRevocationProof } from "./generate-revocation-proof"
2
+ export type { GenerateRevocationProofOptions } from "./generate-revocation-proof"
3
+ export { generateSemaphoreProof } from "./generate-semaphore-proof"
4
+ export type { GenerateSemaphoreProofOptions } from "./generate-semaphore-proof"
5
+ export { UPDATE_MESSAGE_DOMAIN, randomScope, updateDomainSeparator, updateMessage } from "./message"
6
+ export { REVOCATION_MESSAGE_DOMAIN, revocationDomainSeparator, revocationMessage } from "./revocation-message"
7
+ export { RevocationAction, UpdateAction } from "./types"
8
+ export type { IdentityUpdate, NetworkMemberProof, RegistryLocation, RevocationUpdate } from "./types"
package/src/message.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { bytesToHex, encodeAbiParameters, encodePacked, keccak256 } from "viem"
2
+ import { IdentityUpdate, RegistryLocation, UpdateAction } from "./types"
3
+
4
+ /** Domain string in the registry's `UPDATE_DOMAIN_SEPARATOR`. */
5
+ export const UPDATE_MESSAGE_DOMAIN = "coordination-network.identity-registry.update.v1"
6
+
7
+ /** Empty meta-stealth address, the placeholder a removal binds. */
8
+ const NO_META_STEALTH_ADDRESS = "0x" as const
9
+
10
+ /**
11
+ * The constant half of an update message, mirroring the registry's `UPDATE_DOMAIN_SEPARATOR`. The
12
+ * registry hashes it once at deployment, so a client can also read it from there.
13
+ */
14
+ export function updateDomainSeparator(registryAddress: `0x${string}`): `0x${string}` {
15
+ return keccak256(
16
+ encodeAbiParameters([{ type: "string" }, { type: "address" }], [UPDATE_MESSAGE_DOMAIN, registryAddress])
17
+ )
18
+ }
19
+
20
+ /**
21
+ * The Semaphore message binding a proof to one registry update, mirroring the message the registry
22
+ * derives from the arguments it is about to apply: the payload, the registry and the chain.
23
+ *
24
+ * The payload is what stops a proof authorising a different update. The registry address and the
25
+ * chain id place that update at one deployment: a registry does not own its Semaphore group, so two
26
+ * registries can share one, and the same registry can exist at the same address on two chains with
27
+ * the same members and therefore the same root.
28
+ *
29
+ * The registry never accepts a message from the caller, so a proof built for the wrong update does
30
+ * not fail a comparison, it fails verification.
31
+ */
32
+ export function updateMessage(registry: RegistryLocation, update: IdentityUpdate): bigint {
33
+ if (update.action === UpdateAction.Remove && update.metaStealthAddress !== undefined) {
34
+ throw new Error("a removal binds no meta-stealth address")
35
+ }
36
+
37
+ // Caught here rather than on chain: a proof over the wrong payload costs a full verification to
38
+ // fail, and the proof took real work to produce.
39
+ if (update.action !== UpdateAction.Remove && update.metaStealthAddress === undefined) {
40
+ throw new Error("a registration or update binds a meta-stealth address")
41
+ }
42
+
43
+ const metaStealthAddress = update.metaStealthAddress ?? NO_META_STEALTH_ADDRESS
44
+
45
+ return BigInt(
46
+ keccak256(
47
+ encodePacked(
48
+ ["bytes32", "uint256", "uint8", "bytes32", "bytes"],
49
+ [
50
+ updateDomainSeparator(registry.address),
51
+ registry.chainId,
52
+ update.action,
53
+ update.networkId,
54
+ metaStealthAddress
55
+ ]
56
+ )
57
+ )
58
+ )
59
+ }
60
+
61
+ /**
62
+ * A fresh random scope.
63
+ *
64
+ * A Semaphore nullifier is `poseidon(secret, scope)`, so a random scope gives every proof an
65
+ * unlinkable nullifier: the registry only ever learns that some approved member authorised the
66
+ * update, never that two updates came from the same one. The registry does not constrain the scope;
67
+ * what it relies on is that the nullifier a proof carries can only be spent once.
68
+ */
69
+ export function randomScope(): bigint {
70
+ // 31 bytes, so the scope is always a field element. Web Crypto and viem only, so this works
71
+ // in a browser as well as in Node.
72
+ const bytes = new Uint8Array(31)
73
+ crypto.getRandomValues(bytes)
74
+
75
+ return BigInt(bytesToHex(bytes))
76
+ }
package/src/prove.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Group } from "@semaphore-protocol/group"
2
+ import { Identity } from "@semaphore-protocol/identity"
3
+ import { generateProof } from "@semaphore-protocol/proof"
4
+ import { NetworkMemberProof } from "./types"
5
+
6
+ /**
7
+ * Proves membership over an already derived message, in the shape a NetworkMemberGate expects.
8
+ *
9
+ * The message is absent from the result: the gated contract derives it from the call's own arguments,
10
+ * so submitting a proof means submitting the payload it was generated for.
11
+ */
12
+ export async function proveMessage(
13
+ identity: Identity,
14
+ group: Group,
15
+ message: bigint,
16
+ scope: bigint
17
+ ): Promise<NetworkMemberProof> {
18
+ const proof = await generateProof(identity, group, message, scope)
19
+
20
+ return {
21
+ merkleTreeDepth: BigInt(proof.merkleTreeDepth),
22
+ merkleTreeRoot: BigInt(proof.merkleTreeRoot),
23
+ nullifier: BigInt(proof.nullifier),
24
+ scope: BigInt(proof.scope),
25
+ points: proof.points.map(BigInt) as unknown as NetworkMemberProof["points"]
26
+ }
27
+ }
@@ -0,0 +1,82 @@
1
+ import { encodeAbiParameters, encodePacked, keccak256 } from "viem"
2
+ import { RegistryLocation, RevocationAction, RevocationUpdate } from "./types"
3
+
4
+ /** Domain string in the registry's `REVOCATION_DOMAIN_SEPARATOR`. */
5
+ export const REVOCATION_MESSAGE_DOMAIN = "coordination-network.credential-revocation-registry.revocation.v1"
6
+
7
+ /**
8
+ * The constant half of a revocation message, mirroring the registry's `REVOCATION_DOMAIN_SEPARATOR`.
9
+ * The registry hashes it once at deployment, so a client can read it from there instead.
10
+ */
11
+ export function revocationDomainSeparator(registryAddress: `0x${string}`): `0x${string}` {
12
+ return keccak256(
13
+ encodeAbiParameters([{ type: "string" }, { type: "address" }], [REVOCATION_MESSAGE_DOMAIN, registryAddress])
14
+ )
15
+ }
16
+
17
+ /**
18
+ * The message binding a proof to one mutation of the credential revocation registry: the action and
19
+ * the commitments, at that registry on that chain. It mirrors what the registry derives from the
20
+ * arguments it applies, so a proof aimed elsewhere fails verification.
21
+ *
22
+ * Predecessor indices are absent here as they are on chain. An index is a witness the LeanIMT+
23
+ * validates, not a privilege, so leaving it unbound lets a member who lost a race recompute it and
24
+ * resubmit the same proof; a revert does not burn the nullifier.
25
+ *
26
+ * `RevokeMany` is a distinct action because a one-commitment batch would otherwise pack identically
27
+ * to a single revocation.
28
+ */
29
+ export function revocationMessage(registry: RegistryLocation, update: RevocationUpdate): bigint {
30
+ switch (update.action) {
31
+ case RevocationAction.Revoke:
32
+ case RevocationAction.Unrevoke:
33
+ return BigInt(
34
+ keccak256(
35
+ encodePacked(
36
+ ["bytes32", "uint256", "uint8", "uint256"],
37
+ [
38
+ revocationDomainSeparator(registry.address),
39
+ registry.chainId,
40
+ update.action,
41
+ update.revocationCommitment
42
+ ]
43
+ )
44
+ )
45
+ )
46
+ case RevocationAction.RevokeMany:
47
+ if (update.revocationCommitments.length === 0) {
48
+ throw new Error("a batch binds at least one commitment")
49
+ }
50
+
51
+ return BigInt(
52
+ keccak256(
53
+ encodePacked(
54
+ ["bytes32", "uint256", "uint8", "uint256[]"],
55
+ [
56
+ revocationDomainSeparator(registry.address),
57
+ registry.chainId,
58
+ update.action,
59
+ [...update.revocationCommitments]
60
+ ]
61
+ )
62
+ )
63
+ )
64
+ case RevocationAction.UpdateCommitment:
65
+ return BigInt(
66
+ keccak256(
67
+ encodePacked(
68
+ ["bytes32", "uint256", "uint8", "uint256", "uint256"],
69
+ [
70
+ revocationDomainSeparator(registry.address),
71
+ registry.chainId,
72
+ update.action,
73
+ update.oldRevocationCommitment,
74
+ update.newRevocationCommitment
75
+ ]
76
+ )
77
+ )
78
+ )
79
+ default:
80
+ throw new Error("unknown revocation action")
81
+ }
82
+ }
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ /** Which mutation of the IdentityRegistry a proof authorises. Matches the on-chain enum. */
2
+ export enum UpdateAction {
3
+ Register = 0,
4
+ Update = 1,
5
+ Remove = 2
6
+ }
7
+
8
+ /** The update a member is authorising, exactly as it will be submitted. */
9
+ export type IdentityUpdate = {
10
+ action: UpdateAction
11
+ /** The networkId being written. */
12
+ networkId: `0x${string}`
13
+ /** The 66-byte ERC-6538 meta-stealth address being written. Omitted for a removal. */
14
+ metaStealthAddress?: `0x${string}`
15
+ }
16
+
17
+ /** Where the registry is deployed. The message binds an update to this pair. */
18
+ export type RegistryLocation = {
19
+ chainId: bigint
20
+ address: `0x${string}`
21
+ }
22
+
23
+ /**
24
+ * A membership proof in the shape the registry's NetworkMemberProof struct expects: Semaphore's proof
25
+ * without its message. The registry derives the message from the call's own arguments, so it is the
26
+ * one public signal a caller never supplies.
27
+ */
28
+ export type NetworkMemberProof = {
29
+ merkleTreeDepth: bigint
30
+ merkleTreeRoot: bigint
31
+ nullifier: bigint
32
+ scope: bigint
33
+ points: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]
34
+ }
35
+
36
+ /** Which mutation of the CredentialRevocationRegistry a proof authorises. Matches the on-chain enum. */
37
+ export enum RevocationAction {
38
+ Revoke = 0,
39
+ RevokeMany = 1,
40
+ Unrevoke = 2,
41
+ UpdateCommitment = 3
42
+ }
43
+
44
+ /**
45
+ * The revocation the member is authorising, exactly as it will be submitted. Predecessor indices are
46
+ * absent because the message does not bind them, so one proof survives a recomputed index.
47
+ */
48
+ export type RevocationUpdate =
49
+ | { action: RevocationAction.Revoke; revocationCommitment: bigint }
50
+ | { action: RevocationAction.Unrevoke; revocationCommitment: bigint }
51
+ | { action: RevocationAction.RevokeMany; revocationCommitments: readonly bigint[] }
52
+ | {
53
+ action: RevocationAction.UpdateCommitment
54
+ oldRevocationCommitment: bigint
55
+ newRevocationCommitment: bigint
56
+ }
@@ -0,0 +1,172 @@
1
+ import { Group } from '@semaphore-protocol/group';
2
+ import { Identity } from '@semaphore-protocol/identity';
3
+
4
+ /** Which mutation of the IdentityRegistry a proof authorises. Matches the on-chain enum. */
5
+ declare enum UpdateAction {
6
+ Register = 0,
7
+ Update = 1,
8
+ Remove = 2
9
+ }
10
+ /** The update a member is authorising, exactly as it will be submitted. */
11
+ type IdentityUpdate = {
12
+ action: UpdateAction;
13
+ /** The networkId being written. */
14
+ networkId: `0x${string}`;
15
+ /** The 66-byte ERC-6538 meta-stealth address being written. Omitted for a removal. */
16
+ metaStealthAddress?: `0x${string}`;
17
+ };
18
+ /** Where the registry is deployed. The message binds an update to this pair. */
19
+ type RegistryLocation = {
20
+ chainId: bigint;
21
+ address: `0x${string}`;
22
+ };
23
+ /**
24
+ * A membership proof in the shape the registry's NetworkMemberProof struct expects: Semaphore's proof
25
+ * without its message. The registry derives the message from the call's own arguments, so it is the
26
+ * one public signal a caller never supplies.
27
+ */
28
+ type NetworkMemberProof = {
29
+ merkleTreeDepth: bigint;
30
+ merkleTreeRoot: bigint;
31
+ nullifier: bigint;
32
+ scope: bigint;
33
+ points: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint];
34
+ };
35
+ /** Which mutation of the CredentialRevocationRegistry a proof authorises. Matches the on-chain enum. */
36
+ declare enum RevocationAction {
37
+ Revoke = 0,
38
+ RevokeMany = 1,
39
+ Unrevoke = 2,
40
+ UpdateCommitment = 3
41
+ }
42
+ /**
43
+ * The revocation the member is authorising, exactly as it will be submitted. Predecessor indices are
44
+ * absent because the message does not bind them, so one proof survives a recomputed index.
45
+ */
46
+ type RevocationUpdate = {
47
+ action: RevocationAction.Revoke;
48
+ revocationCommitment: bigint;
49
+ } | {
50
+ action: RevocationAction.Unrevoke;
51
+ revocationCommitment: bigint;
52
+ } | {
53
+ action: RevocationAction.RevokeMany;
54
+ revocationCommitments: readonly bigint[];
55
+ } | {
56
+ action: RevocationAction.UpdateCommitment;
57
+ oldRevocationCommitment: bigint;
58
+ newRevocationCommitment: bigint;
59
+ };
60
+
61
+ type GenerateRevocationProofOptions = {
62
+ /** The network member's Semaphore identity. Its commitment must be a leaf of `group`. */
63
+ identity: Identity;
64
+ /**
65
+ * The approved-member group, as the Semaphore contract currently holds it. Rebuild it from the
66
+ * group's on-chain membership events: a proof against a root the group no longer holds is only
67
+ * accepted inside the recent-root window.
68
+ */
69
+ group: Group;
70
+ /** Where the credential revocation registry is deployed. */
71
+ registry: RegistryLocation;
72
+ /** The revocation being authorised. */
73
+ update: RevocationUpdate;
74
+ /** The proof's scope. Defaults to a fresh random one, which is what keeps proofs unlinkable. */
75
+ scope?: bigint;
76
+ };
77
+ /**
78
+ * Generates the membership proof that authorises one write to the credential revocation registry.
79
+ *
80
+ * The proof states that some leaf of the approved-member group signed off on this exact revocation,
81
+ * and nothing else: not which leaf, and not who submits it. Submit it from any address, including a
82
+ * fresh stealth address with a paymaster covering the gas, since no write path reads `msg.sender`.
83
+ *
84
+ * One tree holds the whole network's revocations, so nothing about the write names the issuer either.
85
+ */
86
+ declare function generateRevocationProof({ identity, group, registry, update, scope }: GenerateRevocationProofOptions): Promise<NetworkMemberProof>;
87
+
88
+ type GenerateSemaphoreProofOptions = {
89
+ /** The network member's Semaphore identity. Its commitment must be a leaf of `group`. */
90
+ identity: Identity;
91
+ /**
92
+ * The approved-member group, as the registry's Semaphore group currently holds it. Rebuild it
93
+ * from the group's on-chain membership events: a proof against a root the group no longer holds
94
+ * is only accepted inside Semaphore's recent-root window.
95
+ */
96
+ group: Group;
97
+ /** Where the registry is deployed. */
98
+ registry: RegistryLocation;
99
+ /** The update being authorised. */
100
+ update: IdentityUpdate;
101
+ /** The proof's scope. Defaults to a fresh random one, which is what keeps proofs unlinkable. */
102
+ scope?: bigint;
103
+ };
104
+ /**
105
+ * Generates the membership proof that authorises one registry update.
106
+ *
107
+ * The proof states that some leaf of the approved-member group signed off on this exact update, and
108
+ * nothing else: not which leaf, and not who submits it. Submit it from any address, since no write
109
+ * path reads `msg.sender`.
110
+ *
111
+ * `identity` and `group` are Semaphore's own types, taken as given here. Creating a member identity
112
+ * and maintaining the group are governance and onboarding concerns, not proof generation.
113
+ *
114
+ * The message binds the proof to this update at this registry on this chain, so it cannot be
115
+ * replayed against another payload, and Semaphore burns the nullifier on the way in, so the proof
116
+ * itself cannot be submitted twice.
117
+ */
118
+ declare function generateSemaphoreProof({ identity, group, registry, update, scope }: GenerateSemaphoreProofOptions): Promise<NetworkMemberProof>;
119
+
120
+ /** Domain string in the registry's `UPDATE_DOMAIN_SEPARATOR`. */
121
+ declare const UPDATE_MESSAGE_DOMAIN = "coordination-network.identity-registry.update.v1";
122
+ /**
123
+ * The constant half of an update message, mirroring the registry's `UPDATE_DOMAIN_SEPARATOR`. The
124
+ * registry hashes it once at deployment, so a client can also read it from there.
125
+ */
126
+ declare function updateDomainSeparator(registryAddress: `0x${string}`): `0x${string}`;
127
+ /**
128
+ * The Semaphore message binding a proof to one registry update, mirroring the message the registry
129
+ * derives from the arguments it is about to apply: the payload, the registry and the chain.
130
+ *
131
+ * The payload is what stops a proof authorising a different update. The registry address and the
132
+ * chain id place that update at one deployment: a registry does not own its Semaphore group, so two
133
+ * registries can share one, and the same registry can exist at the same address on two chains with
134
+ * the same members and therefore the same root.
135
+ *
136
+ * The registry never accepts a message from the caller, so a proof built for the wrong update does
137
+ * not fail a comparison, it fails verification.
138
+ */
139
+ declare function updateMessage(registry: RegistryLocation, update: IdentityUpdate): bigint;
140
+ /**
141
+ * A fresh random scope.
142
+ *
143
+ * A Semaphore nullifier is `poseidon(secret, scope)`, so a random scope gives every proof an
144
+ * unlinkable nullifier: the registry only ever learns that some approved member authorised the
145
+ * update, never that two updates came from the same one. The registry does not constrain the scope;
146
+ * what it relies on is that the nullifier a proof carries can only be spent once.
147
+ */
148
+ declare function randomScope(): bigint;
149
+
150
+ /** Domain string in the registry's `REVOCATION_DOMAIN_SEPARATOR`. */
151
+ declare const REVOCATION_MESSAGE_DOMAIN = "coordination-network.credential-revocation-registry.revocation.v1";
152
+ /**
153
+ * The constant half of a revocation message, mirroring the registry's `REVOCATION_DOMAIN_SEPARATOR`.
154
+ * The registry hashes it once at deployment, so a client can read it from there instead.
155
+ */
156
+ declare function revocationDomainSeparator(registryAddress: `0x${string}`): `0x${string}`;
157
+ /**
158
+ * The message binding a proof to one mutation of the credential revocation registry: the action and
159
+ * the commitments, at that registry on that chain. It mirrors what the registry derives from the
160
+ * arguments it applies, so a proof aimed elsewhere fails verification.
161
+ *
162
+ * Predecessor indices are absent here as they are on chain. An index is a witness the LeanIMT+
163
+ * validates, not a privilege, so leaving it unbound lets a member who lost a race recompute it and
164
+ * resubmit the same proof; a revert does not burn the nullifier.
165
+ *
166
+ * `RevokeMany` is a distinct action because a one-commitment batch would otherwise pack identically
167
+ * to a single revocation.
168
+ */
169
+ declare function revocationMessage(registry: RegistryLocation, update: RevocationUpdate): bigint;
170
+
171
+ export { REVOCATION_MESSAGE_DOMAIN, RevocationAction, UPDATE_MESSAGE_DOMAIN, UpdateAction, generateRevocationProof, generateSemaphoreProof, randomScope, revocationDomainSeparator, revocationMessage, updateDomainSeparator, updateMessage };
172
+ export type { GenerateRevocationProofOptions, GenerateSemaphoreProofOptions, IdentityUpdate, NetworkMemberProof, RegistryLocation, RevocationUpdate };