@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,203 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.34;
3
+
4
+ import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
5
+ import { IRevocation } from "./revocation/interfaces/IRevocation.sol";
6
+ import { IRevocationRegistries } from "./revocation/interfaces/IRevocationRegistries.sol";
7
+
8
+ import { NetworkMemberGate } from "./base/NetworkMemberGate.sol";
9
+
10
+ /// @title CredentialRevocationRegistry
11
+ /// @notice The network's one revocation registry. A single tree holds every revoked credential,
12
+ /// whoever issued it, and every write is authorised by an anonymous membership proof from an
13
+ /// approved network member.
14
+ /// @dev One registry rather than one per issuer is what keeps issuer hiding intact. The circuit hides
15
+ /// which approved issuer signed a credential and outputs the root it proved non-membership against;
16
+ /// picking which registry to ask about that root would name the issuer. So {isValidRoot} takes the
17
+ /// root alone, and with one tree, which tree a holder syncs says nothing about its issuer either.
18
+ ///
19
+ /// The tree, its root history and its LeanIMT+ stay in the {IRevocation} deployment. This contract
20
+ /// adds only who may write and a registry-free read surface.
21
+ ///
22
+ /// The registry's admin is this contract, and no write path reads `msg.sender`, so a member can
23
+ /// submit from a fresh stealth address with a paymaster covering the gas. The cost is that any
24
+ /// approved member can revoke any credential unattributably; {unrevoke} makes a wrong one repairable.
25
+ ///
26
+ /// The tree reads a holder needs are public on {REVOCATION} under {REGISTRY_ID}, not mirrored here.
27
+ contract CredentialRevocationRegistry is NetworkMemberGate {
28
+ /// @notice The mutation a member proof authorises.
29
+ enum RevocationAction {
30
+ Revoke,
31
+ RevokeMany,
32
+ Unrevoke,
33
+ UpdateCommitment
34
+ }
35
+
36
+ /// @dev Domain of the message a proof commits to. Clients derive the message identically.
37
+ string constant REVOCATION_MESSAGE_DOMAIN = "coordination-network.credential-revocation-registry.revocation.v1";
38
+
39
+ /// @notice The constant half of a revocation message, hashed once at deployment.
40
+ bytes32 public immutable REVOCATION_DOMAIN_SEPARATOR;
41
+
42
+ /// @notice The revocation service holding this registry's tree and root history.
43
+ IRevocation public immutable REVOCATION;
44
+
45
+ /// @notice This network's registry on {REVOCATION}, created at deployment and administered by
46
+ /// this contract.
47
+ /// @dev Public because a holder needs it to read the tree off {REVOCATION}. A verifier does not.
48
+ uint256 public immutable REGISTRY_ID;
49
+
50
+ /// @notice Thrown when the deployment is given no revocation service.
51
+ error ZeroRevocationService();
52
+
53
+ /// @param semaphore The Semaphore deployment holding the approved-member group.
54
+ /// @param networkMemberGroupId The approved-member group to gate writes on.
55
+ /// @param revocation The revocation service to create this network's registry on.
56
+ constructor(ISemaphore semaphore, uint256 networkMemberGroupId, IRevocation revocation)
57
+ NetworkMemberGate(semaphore, networkMemberGroupId)
58
+ {
59
+ if (address(revocation) == address(0)) revert ZeroRevocationService();
60
+
61
+ REVOCATION = revocation;
62
+
63
+ // This contract is the admin, so the gated functions below are the only way in. The tree
64
+ // starts empty, and the circuit canonicalises an empty-registry witness to a zero root, so
65
+ // presentations verify from the first block.
66
+ REGISTRY_ID = revocation.createRegistry(address(this));
67
+
68
+ REVOCATION_DOMAIN_SEPARATOR = keccak256(abi.encode(REVOCATION_MESSAGE_DOMAIN, address(this)));
69
+ }
70
+
71
+ /// @notice Revokes a credential, on an approved member's authority.
72
+ /// @param revocationCommitment The credential's revocation commitment.
73
+ /// @param lowLeafIndex The commitment's predecessor slot, computed off chain against the tree
74
+ /// state this transaction executes in. If another write lands first this reverts; recompute and
75
+ /// resubmit the same proof.
76
+ /// @param proof Membership proof bound to this exact revocation.
77
+ function revoke(uint256 revocationCommitment, uint256 lowLeafIndex, NetworkMemberProof calldata proof)
78
+ external
79
+ onlyNetworkMember(_revocationMessage(RevocationAction.Revoke, revocationCommitment), proof)
80
+ {
81
+ REVOCATION.revoke(REGISTRY_ID, revocationCommitment, lowLeafIndex);
82
+ }
83
+
84
+ /// @notice Revokes several credentials in one call, on an approved member's authority.
85
+ /// @dev Cheaper than one {revoke} per commitment, and the answer to contention on a shared tree:
86
+ /// one proof and one transaction for the batch.
87
+ /// @param revocationCommitments The commitments to revoke, in insertion order.
88
+ /// @param lowLeafIndices Each commitment's predecessor index, in the tree state after the
89
+ /// earlier commitments in the batch have been inserted.
90
+ /// @param proof Membership proof bound to this exact batch.
91
+ function revokeMany(
92
+ uint256[] calldata revocationCommitments,
93
+ uint256[] calldata lowLeafIndices,
94
+ NetworkMemberProof calldata proof
95
+ ) external onlyNetworkMember(_revocationBatchMessage(revocationCommitments), proof) {
96
+ REVOCATION.revokeMany(REGISTRY_ID, revocationCommitments, lowLeafIndices);
97
+ }
98
+
99
+ /// @notice Undoes a revocation, on an approved member's authority.
100
+ /// @dev The credential becomes valid again everywhere, including for verifiers still on an older
101
+ /// root. Here because an anonymous revocation cannot be pinned on anyone, so a wrong one has to
102
+ /// be repairable.
103
+ /// @param revocationCommitment The revoked commitment.
104
+ /// @param predecessorIndex The physical index of the leaf whose `nextValue` is the commitment.
105
+ /// @param proof Membership proof bound to this exact un-revocation.
106
+ function unrevoke(uint256 revocationCommitment, uint256 predecessorIndex, NetworkMemberProof calldata proof)
107
+ external
108
+ onlyNetworkMember(_revocationMessage(RevocationAction.Unrevoke, revocationCommitment), proof)
109
+ {
110
+ REVOCATION.unrevoke(REGISTRY_ID, revocationCommitment, predecessorIndex);
111
+ }
112
+
113
+ /// @notice Replaces a revoked commitment with another one, reusing its slot, on an approved
114
+ /// member's authority.
115
+ /// @param oldRevocationCommitment The revoked commitment to replace.
116
+ /// @param newRevocationCommitment The commitment to put in its place.
117
+ /// @param oldPredecessorIndex The physical index of the old commitment's predecessor.
118
+ /// @param newPredecessorIndex The physical index of the new commitment's predecessor, in the
119
+ /// tree state after the old commitment has been unlinked.
120
+ /// @param proof Membership proof bound to this exact replacement.
121
+ function updateRevocationCommitment(
122
+ uint256 oldRevocationCommitment,
123
+ uint256 newRevocationCommitment,
124
+ uint256 oldPredecessorIndex,
125
+ uint256 newPredecessorIndex,
126
+ NetworkMemberProof calldata proof
127
+ ) external onlyNetworkMember(_revocationUpdateMessage(oldRevocationCommitment, newRevocationCommitment), proof) {
128
+ REVOCATION.updateRevocationCommitment(
129
+ REGISTRY_ID, oldRevocationCommitment, newRevocationCommitment, oldPredecessorIndex, newPredecessorIndex
130
+ );
131
+ }
132
+
133
+ /// @notice Whether a root may back a revocation proof right now.
134
+ /// @dev The verifier's only on-chain read, and it takes the root alone, so a verification has
135
+ /// nothing issuer-specific to pass. Accepts the current root, plus any root superseded less than
136
+ /// the registry's window ago, so a revocation landing mid-presentation spares honest proofs.
137
+ /// @param revocationRoot The root the proof was built against.
138
+ /// @return True if the root is currently accepted.
139
+ function isValidRoot(uint256 revocationRoot) external view returns (bool) {
140
+ return REVOCATION.isValidRoot(REGISTRY_ID, revocationRoot);
141
+ }
142
+
143
+ /// @notice The registry's current revocation root.
144
+ /// @dev Mirrored here because building a non-membership proof starts with it. Zero while the tree
145
+ /// is empty, matching the circuit's empty-registry witness. The cast is because tree reads live on
146
+ /// {IRevocationRegistries}, which the same deployment implements.
147
+ /// @return The root.
148
+ function getRevocationRoot() external view returns (uint256) {
149
+ return IRevocationRegistries(address(REVOCATION)).getRevocationRoot(REGISTRY_ID);
150
+ }
151
+
152
+ /// @dev Binds a proof to one single-commitment mutation: the action and the commitment, at this
153
+ /// registry on this chain. The separator and chain id matter because this contract does not own
154
+ /// its group: two deployments can share one, and one deployment can sit at the same address on two
155
+ /// chains with the same root.
156
+ ///
157
+ /// The predecessor index is not bound. It is a witness the LeanIMT+ validates, not a privilege, so
158
+ /// leaving it out lets a member who lost a race recompute it and resubmit the same proof; a revert
159
+ /// does not burn the nullifier.
160
+ /// @param action Which mutation the proof authorises.
161
+ /// @param revocationCommitment The commitment being written.
162
+ function _revocationMessage(RevocationAction action, uint256 revocationCommitment) private view returns (uint256) {
163
+ return uint256(
164
+ keccak256(abi.encodePacked(REVOCATION_DOMAIN_SEPARATOR, block.chainid, action, revocationCommitment))
165
+ );
166
+ }
167
+
168
+ /// @dev Binds a proof to one batch. The commitments come last and are the only variable-length
169
+ /// field, so the encoding is unambiguous. The action differs from {RevocationAction.Revoke}
170
+ /// because a one-commitment batch would otherwise pack identically to a single revocation.
171
+ /// @param revocationCommitments The commitments being revoked, in insertion order.
172
+ function _revocationBatchMessage(uint256[] calldata revocationCommitments) private view returns (uint256) {
173
+ return uint256(
174
+ keccak256(
175
+ abi.encodePacked(
176
+ REVOCATION_DOMAIN_SEPARATOR, block.chainid, RevocationAction.RevokeMany, revocationCommitments
177
+ )
178
+ )
179
+ );
180
+ }
181
+
182
+ /// @dev Binds a proof to one replacement. Both commitments are fixed width, so binding both fixes
183
+ /// which replacement is authorised.
184
+ /// @param oldRevocationCommitment The commitment being replaced.
185
+ /// @param newRevocationCommitment The commitment replacing it.
186
+ function _revocationUpdateMessage(uint256 oldRevocationCommitment, uint256 newRevocationCommitment)
187
+ private
188
+ view
189
+ returns (uint256)
190
+ {
191
+ return uint256(
192
+ keccak256(
193
+ abi.encodePacked(
194
+ REVOCATION_DOMAIN_SEPARATOR,
195
+ block.chainid,
196
+ RevocationAction.UpdateCommitment,
197
+ oldRevocationCommitment,
198
+ newRevocationCommitment
199
+ )
200
+ )
201
+ );
202
+ }
203
+ }
@@ -0,0 +1,17 @@
1
+ // https://github.com/ScopeLift/stealth-address-erc-contracts/blob/main/src/ERC5564Announcer.sol
2
+ // SPDX-License-Identifier: CC0-1.0
3
+ pragma solidity ^0.8.34;
4
+
5
+ import { IERC5564Announcer } from "./interfaces/IERC5564Announcer.sol";
6
+
7
+ /// @notice `ERC5564Announcer` contract to emit an `Announcement` event to broadcast information
8
+ /// about a transaction involving a stealth address. See
9
+ /// [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) to learn more.
10
+ contract ERC5564Announcer is IERC5564Announcer {
11
+ /// @inheritdoc IERC5564Announcer
12
+ function announce(uint256 schemeId, address stealthAddress, bytes memory ephemeralPubKey, bytes memory metadata)
13
+ external
14
+ {
15
+ emit Announcement(schemeId, stealthAddress, msg.sender, ephemeralPubKey, metadata);
16
+ }
17
+ }
@@ -0,0 +1,121 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.34;
3
+
4
+ import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
5
+
6
+ import { NetworkMemberGate } from "./base/NetworkMemberGate.sol";
7
+
8
+ /// @title IdentityRegistry
9
+ /// @notice Resolves a networkId to its meta-stealth address. Reads are public and ungated. Every
10
+ /// write is authorised by an anonymous Semaphore membership proof against the approved-member
11
+ /// group, so the registry learns that an approved network member made the change and not which one.
12
+ /// @dev No write path looks at `msg.sender`, which is what lets a member submit from a fresh stealth
13
+ /// address with a paymaster covering the gas. The proof is bound to the exact update it carries: see
14
+ /// NetworkMemberGate for the binding, and Semaphore for membership, the recent-root window and the
15
+ /// nullifier set that stops a proof being submitted twice.
16
+ contract IdentityRegistry is NetworkMemberGate {
17
+ /// @notice The update a member proof authorises.
18
+ enum UpdateAction {
19
+ Register,
20
+ Update,
21
+ Remove
22
+ }
23
+
24
+ /// @dev Domain of the message a proof commits to. Clients derive the message identically.
25
+ string constant UPDATE_MESSAGE_DOMAIN = "coordination-network.identity-registry.update.v1";
26
+
27
+ uint256 public constant META_STEALTH_ADDRESS_LENGTH = 66;
28
+
29
+ /// @notice The constant half of an update message, hashed once at deployment.
30
+ bytes32 public immutable UPDATE_DOMAIN_SEPARATOR;
31
+
32
+ /// @notice The networkId => meta-stealth address mapping
33
+ mapping(bytes32 networkId => bytes metaStealthAddress) public identity;
34
+
35
+ event IdentityRegistered(bytes32 indexed networkId, bytes metaStealthAddress);
36
+ event IdentityUpdated(bytes32 indexed networkId, bytes metaStealthAddress);
37
+ event IdentityRemoved(bytes32 indexed networkId);
38
+
39
+ error InvalidMetaStealthAddressLength();
40
+ error AlreadyRegistered(bytes32 networkId);
41
+ error NotRegistered(bytes32 networkId);
42
+
43
+ /// @param semaphore The Semaphore deployment holding the approved-member group.
44
+ /// @param networkMemberGroupId The approved-member group to gate writes on.
45
+ constructor(ISemaphore semaphore, uint256 networkMemberGroupId) NetworkMemberGate(semaphore, networkMemberGroupId) {
46
+ UPDATE_DOMAIN_SEPARATOR = keccak256(abi.encode(UPDATE_MESSAGE_DOMAIN, address(this)));
47
+ }
48
+
49
+ /// @notice Registers a networkId alongside its meta-stealth address, on an approved member's
50
+ /// authority.
51
+ /// @param networkId The identity being registered.
52
+ /// @param metaStealthAddress The ERC-6538 meta-stealth address to resolve to.
53
+ /// @param proof Membership proof bound to this exact registration.
54
+ function registerIdentity(bytes32 networkId, bytes calldata metaStealthAddress, NetworkMemberProof calldata proof)
55
+ external
56
+ onlyNetworkMember(_updateMessage(UpdateAction.Register, networkId, metaStealthAddress), proof)
57
+ {
58
+ if (identity[networkId].length != 0) revert AlreadyRegistered(networkId);
59
+
60
+ identity[networkId] = metaStealthAddress;
61
+ emit IdentityRegistered(networkId, metaStealthAddress);
62
+ }
63
+
64
+ /// @notice Repoints an existing networkId at a new meta-stealth address, on an approved member's
65
+ /// authority.
66
+ /// @param networkId The identity being updated.
67
+ /// @param newMetaStealthAddress The ERC-6538 meta-stealth address to resolve to.
68
+ /// @param proof Membership proof bound to this exact update.
69
+ function updateIdentity(bytes32 networkId, bytes calldata newMetaStealthAddress, NetworkMemberProof calldata proof)
70
+ external
71
+ onlyNetworkMember(_updateMessage(UpdateAction.Update, networkId, newMetaStealthAddress), proof)
72
+ {
73
+ if (identity[networkId].length == 0) revert NotRegistered(networkId);
74
+
75
+ identity[networkId] = newMetaStealthAddress;
76
+ emit IdentityUpdated(networkId, newMetaStealthAddress);
77
+ }
78
+
79
+ /// @notice Clears a networkId entry, on an approved member's authority.
80
+ /// @param networkId The identity being removed.
81
+ /// @param proof Membership proof bound to this exact removal.
82
+ function removeIdentity(bytes32 networkId, NetworkMemberProof calldata proof)
83
+ external
84
+ onlyNetworkMember(_updateMessage(UpdateAction.Remove, networkId, ""), proof)
85
+ {
86
+ if (identity[networkId].length == 0) revert NotRegistered(networkId);
87
+
88
+ delete identity[networkId];
89
+ emit IdentityRemoved(networkId);
90
+ }
91
+
92
+ /// @dev The Semaphore message binding a proof to one update of this registry: the payload, plus
93
+ /// the chain id.
94
+ ///
95
+ /// The payload is what stops a proof authorising a different update. The domain separator and
96
+ /// the chain id place that update at one registry on one chain. Both are load-bearing, because
97
+ /// this contract does not own its group: two registries can be pointed at the same group id, and
98
+ /// the same registry can be deployed to the same address on two chains with the same members
99
+ /// admitted in the same order, which gives the same root. Without the separator a proof meant
100
+ /// for one of them would authorise the other.
101
+ ///
102
+ /// The meta-stealth address is the only variable-length field and it comes last, so the packed
103
+ /// encoding is unambiguous. Rejects a malformed meta-stealth address here, before the proof is
104
+ /// verified, so a length mistake does not read as a bad proof.
105
+ /// @param action Which mutation the proof authorises.
106
+ /// @param networkId The identity being written.
107
+ /// @param metaStealthAddress The meta-stealth address being written, empty for a removal.
108
+ function _updateMessage(UpdateAction action, bytes32 networkId, bytes memory metaStealthAddress)
109
+ private
110
+ view
111
+ returns (uint256)
112
+ {
113
+ if (action != UpdateAction.Remove && metaStealthAddress.length != META_STEALTH_ADDRESS_LENGTH) {
114
+ revert InvalidMetaStealthAddressLength();
115
+ }
116
+
117
+ return uint256(
118
+ keccak256(abi.encodePacked(UPDATE_DOMAIN_SEPARATOR, block.chainid, action, networkId, metaStealthAddress))
119
+ );
120
+ }
121
+ }
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.
@@ -0,0 +1,264 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.34;
3
+
4
+ import { ISafeGovernance, ISafeOwnerManager } from "./interfaces/ISafeGovernance.sol";
5
+ import { NetworkMemberGroup } from "./NetworkMemberGroup.sol";
6
+ import { NetworkMemberRegistry } from "./NetworkMemberRegistry.sol";
7
+
8
+ /// @title NetworkMemberGovernance
9
+ /// @notice The governance operations of the network, each as one call: admit a member, remove one, and
10
+ /// leave voluntarily.
11
+ /// @dev Admitting a member changes state in three places: the record in the NetworkMemberRegistry,
12
+ /// the leaf in the Semaphore group NetworkMemberGroup administers, and the Safe's own owner set, which
13
+ /// is what makes the new member an approver of the next candidate. Without this contract those are
14
+ /// three calls someone has to pack into a MultiSend batch, with the threshold in the third written by
15
+ /// whoever built it. Every approver would then have to decode that batch and recompute the threshold to
16
+ /// know what it is approving.
17
+ ///
18
+ /// Here they are one call whose behaviour is fixed in code:
19
+ ///
20
+ /// - the threshold is not an input. {governanceThreshold} computes it from the Safe's live owner
21
+ /// count, so no caller can lower it;
22
+ /// - removal reads the member's commitment and signer from the registry, so it cannot be pointed at
23
+ /// the wrong leaf or the wrong owner;
24
+ /// - nothing takes arbitrary calldata, so a quorum approving `admitMember` is approving an admission
25
+ /// and nothing else;
26
+ /// - each operation pins the member count it was built against. A Safe evaluates a quorum at execution
27
+ /// time, and normally relies on its nonce advancing to invalidate transactions signed against an
28
+ /// older owner set. A module call does not advance the nonce, so {exitNetwork} can shrink the set
29
+ /// and the threshold with it while approvals are still accumulating. Without the pin, three
30
+ /// approvals recorded when four were required would execute once a departure made three a
31
+ /// majority. With it, any change to the member set invalidates every transaction built against the
32
+ /// old one.
33
+ ///
34
+ /// What an approver has to check shrinks to the arguments of a single call.
35
+ ///
36
+ /// Authority. Admission and removal are the Safe's alone, so both carry a majority of the members'
37
+ /// signatures. To reach the Safe's owner-set functions, which only the Safe may call, this contract
38
+ /// must be enabled as a Safe module: it makes the Safe call itself. That module power is why the
39
+ /// {onlySafe} check is the most security-critical line here.
40
+ ///
41
+ /// {exitNetwork} is the one door that does not go through the Safe, because departure should not need
42
+ /// the consent of the members being left. It is confined by a different invariant, and the second most
43
+ /// important line here: a caller may only ever act on the membership whose governance signer it is.
44
+ /// The worst a stolen governance signer achieves through it is destroying that one member's own
45
+ /// membership, which is also the worst that member could do to itself.
46
+ ///
47
+ /// The registry and the group stay owned by the Safe, not by this contract, so the Safe keeps its own
48
+ /// direct path to those contracts and this contract is purely additive.
49
+ ///
50
+ /// All three addresses are immutable, so moving governance to a new Safe means deploying a new
51
+ /// orchestrator against it, transferring the registry and the group, and enabling the new module.
52
+ contract NetworkMemberGovernance {
53
+ /// @notice The smallest member set the network will shrink to. One, so the network is symmetric:
54
+ /// it can be founded with a single member and it can shrink back to one, rather than trapping a
55
+ /// shrinking set at a size it can never leave.
56
+ /// @dev Zero is refused because an ownerless Safe is unrecoverable: no key could ever admit a
57
+ /// member again, and the registry and the group would be frozen with no path back. A single
58
+ /// member is a bootstrap state with no fault tolerance and no check on that member, documented
59
+ /// in the runbook, not a size to operate at.
60
+ uint256 public constant MIN_MEMBERS = 1;
61
+
62
+ /// @notice The governance Safe. Its owner set is the approver set.
63
+ ISafeGovernance public immutable SAFE;
64
+
65
+ /// @notice The registry holding member records.
66
+ NetworkMemberRegistry public immutable REGISTRY;
67
+
68
+ /// @notice The contract administering the approved-member Semaphore group.
69
+ NetworkMemberGroup public immutable GROUP;
70
+
71
+ event NetworkMemberAdmitted(bytes32 indexed memberNetworkId, address governanceSigner, uint256 threshold);
72
+ event NetworkMemberRemoved(bytes32 indexed memberNetworkId, address governanceSigner, uint256 threshold);
73
+ event NetworkMemberExited(bytes32 indexed memberNetworkId, address governanceSigner, uint256 threshold);
74
+
75
+ error OnlyGovernanceSafe();
76
+ error OnlyGovernanceSigner(bytes32 memberNetworkId);
77
+ error ZeroAddress();
78
+ error NotApproved(bytes32 memberNetworkId);
79
+ error MemberSetTooSmall(uint256 memberCountAfter);
80
+ error MemberCountChanged(uint256 expected, uint256 actual);
81
+ error NotASafeOwner(address account);
82
+ error SafeCallFailed(bytes returnData);
83
+
84
+ /// @dev Restricts an operation to the governance Safe, so it always carries a majority of the
85
+ /// members' signatures. This contract is a Safe module, which means it can make the Safe act; this
86
+ /// check is what confines that power to the two operations it guards, {admitMember} and
87
+ /// {removeMember}. {exitNetwork} is guarded by its own invariant instead.
88
+ modifier onlySafe() {
89
+ if (msg.sender != address(SAFE)) revert OnlyGovernanceSafe();
90
+ _;
91
+ }
92
+
93
+ /// @param safe The governance Safe.
94
+ /// @param registry The member registry, owned by that Safe.
95
+ /// @param group The member group, owned by that Safe.
96
+ constructor(ISafeGovernance safe, NetworkMemberRegistry registry, NetworkMemberGroup group) {
97
+ if (address(safe) == address(0) || address(registry) == address(0) || address(group) == address(0)) {
98
+ revert ZeroAddress();
99
+ }
100
+
101
+ SAFE = safe;
102
+ REGISTRY = registry;
103
+ GROUP = group;
104
+ }
105
+
106
+ /// @notice The signatures a Safe of `memberCount` network members must require: a majority, one
107
+ /// more than half. Five members need three, eight need five.
108
+ /// @dev The rule, on chain and computed rather than supplied. Every operation here sets the
109
+ /// threshold from this function, so no caller can move it anywhere else.
110
+ /// @param memberCount How many members the Safe has owners for, after the operation.
111
+ /// @return The required number of signatures.
112
+ function governanceThreshold(uint256 memberCount) public pure returns (uint256) {
113
+ return memberCount / 2 + 1;
114
+ }
115
+
116
+ /// @notice The threshold the Safe will require once one more member is admitted.
117
+ /// @return The threshold after an admission.
118
+ function thresholdAfterAdmission() external view returns (uint256) {
119
+ return governanceThreshold(SAFE.getOwners().length + 1);
120
+ }
121
+
122
+ /// @notice Admits a network member: its record, its group leaf, and its seat at the Safe.
123
+ /// @dev One call, so a quorum cannot be talked into signing a differently shaped batch, and the
124
+ /// threshold comes from {governanceThreshold} rather than from the calldata.
125
+ /// @param memberNetworkId The member to admit.
126
+ /// @param semaphoreCommitment Its Semaphore identity commitment.
127
+ /// @param governanceSigner The address that becomes its seat at the Safe.
128
+ /// @param expectedMemberCount The member count this transaction was built against. Not a policy
129
+ /// input: the threshold is still computed, and this only makes the transaction stale the moment the
130
+ /// member set changes, so signatures cannot carry over to a set their signers never saw.
131
+ function admitMember(
132
+ bytes32 memberNetworkId,
133
+ uint256 semaphoreCommitment,
134
+ address governanceSigner,
135
+ uint256 expectedMemberCount
136
+ ) external onlySafe {
137
+ uint256 memberCount = _checkMemberCount(expectedMemberCount);
138
+ uint256 threshold = governanceThreshold(memberCount + 1);
139
+
140
+ _execFromSafe(
141
+ address(REGISTRY),
142
+ abi.encodeCall(
143
+ NetworkMemberRegistry.approveNetworkMember, (memberNetworkId, semaphoreCommitment, governanceSigner)
144
+ )
145
+ );
146
+ _execFromSafe(address(GROUP), abi.encodeCall(NetworkMemberGroup.addNetworkMember, (semaphoreCommitment)));
147
+ _execFromSafe(
148
+ address(SAFE), abi.encodeCall(ISafeOwnerManager.addOwnerWithThreshold, (governanceSigner, threshold))
149
+ );
150
+
151
+ emit NetworkMemberAdmitted(memberNetworkId, governanceSigner, threshold);
152
+ }
153
+
154
+ /// @notice Removes a network member from all three places.
155
+ /// @dev The commitment and the signer are read from the registry rather than passed in, so a
156
+ /// removal cannot be pointed at another member's leaf or another member's seat.
157
+ /// @param memberNetworkId The member to remove.
158
+ /// @param merkleProofSiblings The membership proof for its leaf, computed off chain against the
159
+ /// group state this transaction will execute in.
160
+ /// @param expectedMemberCount The member count this transaction was built against, for the reason
161
+ /// given on {admitMember}.
162
+ function removeMember(bytes32 memberNetworkId, uint256[] calldata merkleProofSiblings, uint256 expectedMemberCount)
163
+ external
164
+ onlySafe
165
+ {
166
+ _checkMemberCount(expectedMemberCount);
167
+
168
+ address governanceSigner = REGISTRY.governanceSignerOf(memberNetworkId);
169
+ if (governanceSigner == address(0)) revert NotApproved(memberNetworkId);
170
+
171
+ uint256 threshold = _withdrawMember(memberNetworkId, governanceSigner, merkleProofSiblings);
172
+
173
+ emit NetworkMemberRemoved(memberNetworkId, governanceSigner, threshold);
174
+ }
175
+
176
+ /// @notice Leaves the network, giving up this member's record, its group leaf and its seat.
177
+ /// @dev Callable by the member's own governance signer and nobody else, so a member can leave
178
+ /// without asking the members it is leaving. It is also how a member that is winding down avoids
179
+ /// leaving a dead seat behind: a seat nobody can sign for still counts toward the owner count, and
180
+ /// therefore toward the threshold everyone else has to reach.
181
+ ///
182
+ /// Departure is irreversible in one respect worth knowing before calling it: rejoining means a new
183
+ /// request and a new place in the Semaphore group, and every credential this member issued stops
184
+ /// verifying as soon as its record is gone.
185
+ /// @param memberNetworkId The member leaving, whose governance signer must be the caller.
186
+ /// @param merkleProofSiblings The membership proof for its leaf, computed off chain against the
187
+ /// group state this transaction will execute in.
188
+ function exitNetwork(bytes32 memberNetworkId, uint256[] calldata merkleProofSiblings) external {
189
+ address governanceSigner = REGISTRY.governanceSignerOf(memberNetworkId);
190
+ if (governanceSigner == address(0)) revert NotApproved(memberNetworkId);
191
+ // The invariant that confines this door: the caller acts on its own membership, never another's.
192
+ if (msg.sender != governanceSigner) revert OnlyGovernanceSigner(memberNetworkId);
193
+
194
+ uint256 threshold = _withdrawMember(memberNetworkId, governanceSigner, merkleProofSiblings);
195
+
196
+ emit NetworkMemberExited(memberNetworkId, governanceSigner, threshold);
197
+ }
198
+
199
+ /// @dev Reverts unless the Safe's owner set is still the size the caller's transaction was built
200
+ /// against. {exitNetwork} needs no such check: it carries no signatures to go stale.
201
+ /// @param expectedMemberCount The member count the transaction assumed.
202
+ /// @return memberCount The live member count, equal to `expectedMemberCount`.
203
+ function _checkMemberCount(uint256 expectedMemberCount) internal view returns (uint256 memberCount) {
204
+ memberCount = SAFE.getOwners().length;
205
+
206
+ if (memberCount != expectedMemberCount) revert MemberCountChanged(expectedMemberCount, memberCount);
207
+ }
208
+
209
+ /// @dev Takes a member out of the registry, the group and the Safe's owner set, and moves the
210
+ /// threshold down with the member count. Shared by {removeMember} and {exitNetwork}, which differ
211
+ /// only in who may ask.
212
+ /// @param memberNetworkId The member being withdrawn.
213
+ /// @param governanceSigner Its governance signer, already read from the registry.
214
+ /// @param merkleProofSiblings The membership proof for its leaf.
215
+ /// @return threshold The threshold the Safe now requires.
216
+ function _withdrawMember(bytes32 memberNetworkId, address governanceSigner, uint256[] calldata merkleProofSiblings)
217
+ internal
218
+ returns (uint256 threshold)
219
+ {
220
+ // Ownership first: a member whose seat is already gone should say so, rather than report
221
+ // whatever the floor check happens to conclude about a set it is not part of.
222
+ address previousOwner = _previousOwner(governanceSigner);
223
+
224
+ uint256 memberCountAfter = SAFE.getOwners().length - 1;
225
+ if (memberCountAfter < MIN_MEMBERS) revert MemberSetTooSmall(memberCountAfter);
226
+
227
+ uint256 semaphoreCommitment = REGISTRY.semaphoreCommitmentOf(memberNetworkId);
228
+ threshold = governanceThreshold(memberCountAfter);
229
+
230
+ _execFromSafe(address(REGISTRY), abi.encodeCall(NetworkMemberRegistry.removeNetworkMember, (memberNetworkId)));
231
+ _execFromSafe(
232
+ address(GROUP),
233
+ abi.encodeCall(NetworkMemberGroup.removeNetworkMember, (semaphoreCommitment, merkleProofSiblings))
234
+ );
235
+ _execFromSafe(
236
+ address(SAFE), abi.encodeCall(ISafeOwnerManager.removeOwner, (previousOwner, governanceSigner, threshold))
237
+ );
238
+ }
239
+
240
+ /// @dev The owner preceding `owner` in the Safe's owner linked list, which `removeOwner` takes.
241
+ /// Walked on chain so no caller has to supply it. The sentinel address precedes the first
242
+ /// owner.
243
+ /// @param owner The owner being removed.
244
+ function _previousOwner(address owner) internal view returns (address) {
245
+ address[] memory owners = SAFE.getOwners();
246
+
247
+ for (uint256 i = 0; i < owners.length; ++i) {
248
+ if (owners[i] == owner) return i == 0 ? address(0x1) : owners[i - 1];
249
+ }
250
+
251
+ revert NotASafeOwner(owner);
252
+ }
253
+
254
+ /// @dev Makes the Safe perform `data` against `to`, as an enabled module. The Safe returns a
255
+ /// failure flag instead of bubbling an inner revert, so the reason is bubbled here to keep a
256
+ /// failed governance operation diagnosable.
257
+ /// @param to The contract the Safe should call.
258
+ /// @param data The calldata.
259
+ function _execFromSafe(address to, bytes memory data) internal {
260
+ (bool success, bytes memory returnData) = SAFE.execTransactionFromModuleReturnData(to, 0, data, 0);
261
+
262
+ if (!success) revert SafeCallFailed(returnData);
263
+ }
264
+ }