@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.
- package/ApprovedIssuerSet.sol +630 -0
- package/CredentialRevocationRegistry.sol +203 -0
- package/ERC5564Announcer.sol +17 -0
- package/IdentityRegistry.sol +121 -0
- package/LICENSE +21 -0
- package/NetworkMemberGovernance.sol +264 -0
- package/NetworkMemberGroup.sol +74 -0
- package/NetworkMemberRegistry.sol +217 -0
- package/README.md +92 -0
- package/SemaphorePaymaster.sol +200 -0
- package/base/NetworkMemberGate.sol +77 -0
- package/interfaces/IApprovedIssuerSet.sol +16 -0
- package/interfaces/IERC5564Announcer.sol +38 -0
- package/interfaces/ISafeGovernance.sol +34 -0
- package/libraries/IssuerSetHashes.sol +35 -0
- package/package.json +43 -0
- package/revocation/README.md +86 -0
- package/revocation/Revocation.sol +187 -0
- package/revocation/base/RevocationRegistries.sol +230 -0
- package/revocation/interfaces/IRevocation.sol +125 -0
- package/revocation/interfaces/IRevocationRegistries.sol +122 -0
|
@@ -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
|
+
}
|