@coordination-network/contracts 0.0.1 → 0.1.0
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 +32 -7
- package/NetworkMemberGovernance.sol +24 -9
- package/NetworkMemberRegistry.sol +67 -19
- package/README.md +9 -29
- package/SemaphorePaymaster.sol +12 -62
- package/base/NetworkMemberGate.sol +3 -2
- package/package.json +2 -2
- package/revocation/README.md +1 -0
- package/revocation/Revocation.sol +4 -5
- package/revocation/interfaces/IRevocation.sol +5 -0
package/ApprovedIssuerSet.sol
CHANGED
|
@@ -96,9 +96,9 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
96
96
|
uint256 public rootDuration;
|
|
97
97
|
|
|
98
98
|
/// @notice The instant at or before which no superseded root is ever acceptable again.
|
|
99
|
-
/// @dev Bumped by {retireAllIssuerKeys} on every removal of a
|
|
100
|
-
///
|
|
101
|
-
/// one root
|
|
99
|
+
/// @dev Bumped by {retireIssuerKeyImmediately}, and by {retireAllIssuerKeys} on every removal of a
|
|
100
|
+
/// member that ever held a key, so either closes off every root superseded in the window before it,
|
|
101
|
+
/// not just the one root it directly replaces.
|
|
102
102
|
uint256 public rootsInvalidatedAt;
|
|
103
103
|
|
|
104
104
|
/// @dev Whether a member has ever published a live key. Set once, never cleared, so
|
|
@@ -151,6 +151,8 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
151
151
|
/// @notice Publishes a member's signing key into the set.
|
|
152
152
|
/// @dev Superseded keys stay in the set, so a rotation does not invalidate credentials the old key
|
|
153
153
|
/// already signed. Retire the old key explicitly to end its life.
|
|
154
|
+
///
|
|
155
|
+
/// Nothing proves the caller holds the key, so one member can publish another's first.
|
|
154
156
|
/// @param memberNetworkId The publishing member.
|
|
155
157
|
/// @param publicKeyX The Baby Jubjub public key's x coordinate.
|
|
156
158
|
/// @param publicKeyY The Baby Jubjub public key's y coordinate.
|
|
@@ -257,7 +259,7 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
257
259
|
/// @param memberNetworkId The member the key belongs to.
|
|
258
260
|
/// @param leaf The commitment to retire.
|
|
259
261
|
function retireIssuerKey(bytes32 memberNetworkId, uint256 leaf)
|
|
260
|
-
|
|
262
|
+
public
|
|
261
263
|
onlyAuthAddressOrGovernance(memberNetworkId)
|
|
262
264
|
{
|
|
263
265
|
_detachFromMember(memberNetworkId, leaf);
|
|
@@ -279,6 +281,16 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
279
281
|
emit IssuerKeyRetired(memberNetworkId, leaf, newRoot);
|
|
280
282
|
}
|
|
281
283
|
|
|
284
|
+
/// @notice Retires a signing key immediately and stops every superseded root being accepted at once.
|
|
285
|
+
/// @dev Useful in cases of key theft so prevents attack using key during grace window. In-flight
|
|
286
|
+
/// proofs against old root must be rebuilt.
|
|
287
|
+
/// @param memberNetworkId The network member the key belongs to.
|
|
288
|
+
/// @param leaf The commitment to retire.
|
|
289
|
+
function retireIssuerKeyImmediately(bytes32 memberNetworkId, uint256 leaf) external {
|
|
290
|
+
rootsInvalidatedAt = block.timestamp;
|
|
291
|
+
retireIssuerKey(memberNetworkId, leaf);
|
|
292
|
+
}
|
|
293
|
+
|
|
282
294
|
/// @inheritdoc IApprovedIssuerSet
|
|
283
295
|
/// @dev Callable only by the registry, which invokes it while removing a member so that removal
|
|
284
296
|
/// and the loss of signing power are one transaction. Two transactions would leave a window in
|
|
@@ -351,6 +363,11 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
351
363
|
}
|
|
352
364
|
|
|
353
365
|
/// @notice Sets how long a superseded root stays acceptable.
|
|
366
|
+
/// @dev The new duration applies to every past root, not only to roots superseded after this
|
|
367
|
+
/// call, so raising it makes older roots valid again and lowering it invalidates proofs that are
|
|
368
|
+
/// already in flight. Roots closed by {rootsInvalidatedAt} or whose stamp was deleted stay
|
|
369
|
+
/// rejected either way. It is also how long a retired key can keep backing proofs with an older
|
|
370
|
+
/// root, so it should be kept as short as verifiers can tolerate.
|
|
354
371
|
/// @param newRootDuration The new window, in seconds.
|
|
355
372
|
function updateRootDuration(uint256 newRootDuration) external {
|
|
356
373
|
if (msg.sender != NetworkMemberRegistry(memberRegistry).owner()) revert UnauthorizedCaller();
|
|
@@ -382,9 +399,11 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
382
399
|
|
|
383
400
|
// Strict, so a `rootDuration` of zero means no grace at all rather than a one-timestamp
|
|
384
401
|
// window. The window runs from the moment the root stopped being current, so every root gets
|
|
385
|
-
// the same full window however long it was current.
|
|
402
|
+
// the same full window however long it was current. `supersededAt` is never in the future,
|
|
403
|
+
// so the subtraction cannot underflow, and unlike `supersededAt + rootDuration` it cannot
|
|
404
|
+
// overflow for any `rootDuration`.
|
|
386
405
|
// forge-lint: disable-next-line(block-timestamp)
|
|
387
|
-
return block.timestamp
|
|
406
|
+
return block.timestamp - supersededAt < rootDuration;
|
|
388
407
|
}
|
|
389
408
|
|
|
390
409
|
/// @notice The live signing keys of a member.
|
|
@@ -594,7 +613,7 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
594
613
|
}
|
|
595
614
|
}
|
|
596
615
|
|
|
597
|
-
/// @dev Rejects a public key that is
|
|
616
|
+
/// @dev Rejects a public key that is outside the field, off the curve, or of low order.
|
|
598
617
|
///
|
|
599
618
|
/// The tree would accept any field element as a leaf, so without the curve check the set could
|
|
600
619
|
/// hold a commitment to a point that does not exist.
|
|
@@ -607,6 +626,12 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
|
|
|
607
626
|
/// verify as issued by an approved member, and issuer anonymity means no verifier could say whose
|
|
608
627
|
/// key to retire.
|
|
609
628
|
///
|
|
629
|
+
/// It does not reject a mixed-order key, a prime-order point plus a nonzero torsion point. The
|
|
630
|
+
/// circuit's EdDSA (noir-lang/eddsa 7e206c95) multiplies the key by 8 before verifying, so only the
|
|
631
|
+
/// holder of its prime-order part's secret can sign under such a key. The only effect is that one
|
|
632
|
+
/// secret can take several leaves, at most eight live ones, one per torsion point, possibly under
|
|
633
|
+
/// different members. The TypeScript `issuer-set` refuses to publish them.
|
|
634
|
+
///
|
|
610
635
|
/// @param publicKeyX The x coordinate.
|
|
611
636
|
/// @param publicKeyY The y coordinate.
|
|
612
637
|
function _assertOnCurve(uint256 publicKeyX, uint256 publicKeyY) private pure {
|
|
@@ -18,7 +18,9 @@ import { NetworkMemberRegistry } from "./NetworkMemberRegistry.sol";
|
|
|
18
18
|
/// Here they are one call whose behaviour is fixed in code:
|
|
19
19
|
///
|
|
20
20
|
/// - the threshold is not an input. {governanceThreshold} computes it from the Safe's live owner
|
|
21
|
-
/// count, so no
|
|
21
|
+
/// count, so no call through this module can lower it. Direct Safe calls can, so admission and
|
|
22
|
+
/// removal also refuse to run while the Safe's threshold is not {governanceThreshold} of its
|
|
23
|
+
/// owners;
|
|
22
24
|
/// - removal reads the member's commitment and signer from the registry, so it cannot be pointed at
|
|
23
25
|
/// the wrong leaf or the wrong owner;
|
|
24
26
|
/// - nothing takes arbitrary calldata, so a quorum approving `admitMember` is approving an admission
|
|
@@ -78,6 +80,7 @@ contract NetworkMemberGovernance {
|
|
|
78
80
|
error NotApproved(bytes32 memberNetworkId);
|
|
79
81
|
error MemberSetTooSmall(uint256 memberCountAfter);
|
|
80
82
|
error MemberCountChanged(uint256 expected, uint256 actual);
|
|
83
|
+
error ThresholdMismatch(uint256 expected, uint256 actual);
|
|
81
84
|
error NotASafeOwner(address account);
|
|
82
85
|
error SafeCallFailed(bytes returnData);
|
|
83
86
|
|
|
@@ -122,25 +125,28 @@ contract NetworkMemberGovernance {
|
|
|
122
125
|
/// @notice Admits a network member: its record, its group leaf, and its seat at the Safe.
|
|
123
126
|
/// @dev One call, so a quorum cannot be talked into signing a differently shaped batch, and the
|
|
124
127
|
/// threshold comes from {governanceThreshold} rather than from the calldata.
|
|
125
|
-
/// @param
|
|
128
|
+
/// @param accountKeyX The X coordinate of the member's account key.
|
|
129
|
+
/// @param accountKeyY The Y coordinate of the member's account key.
|
|
126
130
|
/// @param semaphoreCommitment Its Semaphore identity commitment.
|
|
127
131
|
/// @param governanceSigner The address that becomes its seat at the Safe.
|
|
128
132
|
/// @param expectedMemberCount The member count this transaction was built against. Not a policy
|
|
129
133
|
/// input: the threshold is still computed, and this only makes the transaction stale the moment the
|
|
130
134
|
/// member set changes, so signatures cannot carry over to a set their signers never saw.
|
|
131
135
|
function admitMember(
|
|
132
|
-
|
|
136
|
+
uint256 accountKeyX,
|
|
137
|
+
uint256 accountKeyY,
|
|
133
138
|
uint256 semaphoreCommitment,
|
|
134
139
|
address governanceSigner,
|
|
135
140
|
uint256 expectedMemberCount
|
|
136
141
|
) external onlySafe {
|
|
137
|
-
uint256 memberCount =
|
|
142
|
+
uint256 memberCount = _checkSafeState(expectedMemberCount);
|
|
138
143
|
uint256 threshold = governanceThreshold(memberCount + 1);
|
|
139
144
|
|
|
140
145
|
_execFromSafe(
|
|
141
146
|
address(REGISTRY),
|
|
142
147
|
abi.encodeCall(
|
|
143
|
-
NetworkMemberRegistry.approveNetworkMember,
|
|
148
|
+
NetworkMemberRegistry.approveNetworkMember,
|
|
149
|
+
(accountKeyX, accountKeyY, semaphoreCommitment, governanceSigner)
|
|
144
150
|
)
|
|
145
151
|
);
|
|
146
152
|
_execFromSafe(address(GROUP), abi.encodeCall(NetworkMemberGroup.addNetworkMember, (semaphoreCommitment)));
|
|
@@ -148,7 +154,7 @@ contract NetworkMemberGovernance {
|
|
|
148
154
|
address(SAFE), abi.encodeCall(ISafeOwnerManager.addOwnerWithThreshold, (governanceSigner, threshold))
|
|
149
155
|
);
|
|
150
156
|
|
|
151
|
-
emit NetworkMemberAdmitted(
|
|
157
|
+
emit NetworkMemberAdmitted(REGISTRY.memberNetworkIdOf(accountKeyX, accountKeyY), governanceSigner, threshold);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
/// @notice Removes a network member from all three places.
|
|
@@ -163,7 +169,7 @@ contract NetworkMemberGovernance {
|
|
|
163
169
|
external
|
|
164
170
|
onlySafe
|
|
165
171
|
{
|
|
166
|
-
|
|
172
|
+
_checkSafeState(expectedMemberCount);
|
|
167
173
|
|
|
168
174
|
address governanceSigner = REGISTRY.governanceSignerOf(memberNetworkId);
|
|
169
175
|
if (governanceSigner == address(0)) revert NotApproved(memberNetworkId);
|
|
@@ -197,13 +203,22 @@ contract NetworkMemberGovernance {
|
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
/// @dev Reverts unless the Safe's owner set is still the size the caller's transaction was built
|
|
200
|
-
/// against
|
|
206
|
+
/// against, and its threshold is still {governanceThreshold} of that set. {exitNetwork} needs no
|
|
207
|
+
/// such check: it carries no signatures to go stale, and a member must always be able to leave.
|
|
208
|
+
///
|
|
209
|
+
/// The Safe can change its own threshold directly, outside this contract. This cannot stop that,
|
|
210
|
+
/// but it refuses to act on a Safe that has drifted, so the drift surfaces at the next governance
|
|
211
|
+
/// action instead of persisting silently. A quorum restores it with `changeThreshold`.
|
|
201
212
|
/// @param expectedMemberCount The member count the transaction assumed.
|
|
202
213
|
/// @return memberCount The live member count, equal to `expectedMemberCount`.
|
|
203
|
-
function
|
|
214
|
+
function _checkSafeState(uint256 expectedMemberCount) internal view returns (uint256 memberCount) {
|
|
204
215
|
memberCount = SAFE.getOwners().length;
|
|
205
216
|
|
|
206
217
|
if (memberCount != expectedMemberCount) revert MemberCountChanged(expectedMemberCount, memberCount);
|
|
218
|
+
|
|
219
|
+
uint256 expected = governanceThreshold(memberCount);
|
|
220
|
+
uint256 threshold = SAFE.getThreshold();
|
|
221
|
+
if (threshold != expected) revert ThresholdMismatch(expected, threshold);
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
/// @dev Takes a member out of the registry, the group and the Safe's owner set, and moves the
|
|
@@ -24,11 +24,19 @@ import { IApprovedIssuerSet } from "./interfaces/IApprovedIssuerSet.sol";
|
|
|
24
24
|
/// without being a member. This contract records which keys belong to which member; it holds neither
|
|
25
25
|
/// the group nor the owner set.
|
|
26
26
|
///
|
|
27
|
-
///
|
|
27
|
+
/// A member's id is `keccak256(X || Y)` of its account key, a secp256k1 public key, and the key's
|
|
28
|
+
/// Ethereum address is the member's auth address. Both write paths take the key rather than the id, so
|
|
29
|
+
/// an id always has exactly one key behind it and no id exists that no key derives.
|
|
30
|
+
///
|
|
31
|
+
/// The onboarding procedure is in `docs/configuration/network-member-onboarding.md`.
|
|
28
32
|
contract NetworkMemberRegistry is Ownable2Step {
|
|
29
33
|
uint256 public constant META_STEALTH_ADDRESS_LENGTH = 66;
|
|
30
34
|
|
|
31
|
-
/// @
|
|
35
|
+
/// @dev The secp256k1 field prime.
|
|
36
|
+
uint256 internal constant SECP256K1_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F;
|
|
37
|
+
|
|
38
|
+
/// @notice The address that maintains a network member's record. The Ethereum address of the account key
|
|
39
|
+
/// the member's id is derived from.
|
|
32
40
|
mapping(bytes32 memberNetworkId => address authAddress) public networkMemberAuthAddress;
|
|
33
41
|
|
|
34
42
|
/// @notice The network member networkId => meta-stealth address mapping.
|
|
@@ -48,7 +56,12 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
48
56
|
IApprovedIssuerSet public approvedIssuerSet;
|
|
49
57
|
|
|
50
58
|
event NetworkMemberJoinRequested(
|
|
51
|
-
bytes32 indexed memberNetworkId,
|
|
59
|
+
bytes32 indexed memberNetworkId,
|
|
60
|
+
uint256 accountKeyX,
|
|
61
|
+
uint256 accountKeyY,
|
|
62
|
+
uint256 semaphoreCommitment,
|
|
63
|
+
address governanceSigner,
|
|
64
|
+
string metadataURI
|
|
52
65
|
);
|
|
53
66
|
event NetworkMemberApproved(bytes32 indexed memberNetworkId);
|
|
54
67
|
event NetworkMemberRemoved(bytes32 indexed memberNetworkId);
|
|
@@ -60,6 +73,7 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
60
73
|
error NotApproved(bytes32 memberNetworkId);
|
|
61
74
|
error InvalidMetaStealthAddressLength();
|
|
62
75
|
error UnauthorizedCaller();
|
|
76
|
+
error AccountKeyNotOnCurve();
|
|
63
77
|
error ZeroGovernanceSigner();
|
|
64
78
|
error ZeroSemaphoreCommitment();
|
|
65
79
|
error GovernanceSignerIsAuthAddress();
|
|
@@ -92,13 +106,22 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
92
106
|
return networkMemberAuthAddress[memberNetworkId] != address(0);
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
/// @notice
|
|
96
|
-
///
|
|
109
|
+
/// @notice The networkId of the member whose account key is `(accountKeyX, accountKeyY)`.
|
|
110
|
+
/// @param accountKeyX The X coordinate of the account key.
|
|
111
|
+
/// @param accountKeyY The Y coordinate of the account key.
|
|
112
|
+
/// @return The member's networkId, `keccak256(X || Y)`.
|
|
113
|
+
function memberNetworkIdOf(uint256 accountKeyX, uint256 accountKeyY) public pure returns (bytes32) {
|
|
114
|
+
return keccak256(abi.encodePacked(accountKeyX, accountKeyY));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// @notice Asks the approved network members to admit the member with this account key.
|
|
118
|
+
/// Permissionless, and callable by anyone holding the key.
|
|
97
119
|
/// @dev Stores nothing and confers nothing. It is the announcement half of the join flow: the
|
|
98
120
|
/// approved members watch this event, and admission happens only if enough of them sign the
|
|
99
121
|
/// governance Safe transaction that calls {approveNetworkMember}. The caller must be the auth
|
|
100
|
-
/// address the
|
|
101
|
-
/// @param
|
|
122
|
+
/// address of the account key, so nobody can post a request on another member's behalf.
|
|
123
|
+
/// @param accountKeyX The X coordinate of the requester's account key, a secp256k1 public key.
|
|
124
|
+
/// @param accountKeyY The Y coordinate of the requester's account key.
|
|
102
125
|
/// @param semaphoreCommitment The Semaphore identity commitment the requester wants in the group.
|
|
103
126
|
/// @param governanceSigner The address the requester wants added to the governance Safe, so it can
|
|
104
127
|
/// vote on later candidates. Must differ from the auth address: the auth address is a hot key that
|
|
@@ -106,12 +129,13 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
106
129
|
/// @param metadataURI Where the approvers can fetch this requester's vetting material. Any URI the
|
|
107
130
|
/// network has agreed on, and it may be empty. The contract neither reads nor stores it.
|
|
108
131
|
function requestToJoin(
|
|
109
|
-
|
|
132
|
+
uint256 accountKeyX,
|
|
133
|
+
uint256 accountKeyY,
|
|
110
134
|
uint256 semaphoreCommitment,
|
|
111
135
|
address governanceSigner,
|
|
112
136
|
string calldata metadataURI
|
|
113
137
|
) external {
|
|
114
|
-
address authAddress =
|
|
138
|
+
(bytes32 memberNetworkId, address authAddress) = _memberOf(accountKeyX, accountKeyY);
|
|
115
139
|
|
|
116
140
|
if (msg.sender != authAddress) revert UnauthorizedCaller();
|
|
117
141
|
if (networkMemberAuthAddress[memberNetworkId] != address(0)) revert AlreadyApproved(memberNetworkId);
|
|
@@ -119,30 +143,35 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
119
143
|
if (governanceSigner == address(0)) revert ZeroGovernanceSigner();
|
|
120
144
|
if (governanceSigner == authAddress) revert GovernanceSignerIsAuthAddress();
|
|
121
145
|
|
|
122
|
-
emit NetworkMemberJoinRequested(
|
|
146
|
+
emit NetworkMemberJoinRequested(
|
|
147
|
+
memberNetworkId, accountKeyX, accountKeyY, semaphoreCommitment, governanceSigner, metadataURI
|
|
148
|
+
);
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
/// @notice Admits a network member to the approved set.
|
|
126
152
|
/// @dev Callable only by the governance Safe, so admission always carries the signatures of a
|
|
127
|
-
/// threshold of approved members.
|
|
128
|
-
/// calls in the same Safe transaction are
|
|
129
|
-
/// `addOwnerWithThreshold`.
|
|
153
|
+
/// threshold of approved members. The auth address is the address of the account key, which the Safe's
|
|
154
|
+
/// signers pin by approving its coordinates. The companion calls in the same Safe transaction are
|
|
155
|
+
/// `NetworkMemberGroup.addNetworkMember` and the Safe's own `addOwnerWithThreshold`.
|
|
130
156
|
///
|
|
131
157
|
/// Requires the issuer set to already be wired, for the same reason {removeNetworkMember} does:
|
|
132
158
|
/// without it a member could be admitted into a state {removeNetworkMember} can never remove it
|
|
133
159
|
/// from, since that function refuses to run while the set is unwired.
|
|
134
|
-
/// @param
|
|
160
|
+
/// @param accountKeyX The X coordinate of the member's account key, a secp256k1 public key.
|
|
161
|
+
/// @param accountKeyY The Y coordinate of the member's account key.
|
|
135
162
|
/// @param semaphoreCommitment The member's Semaphore identity commitment. Must be non-zero, which
|
|
136
163
|
/// the Semaphore group requires too, so a record cannot hold a commitment the group would refuse.
|
|
137
164
|
/// @param governanceSigner The member's governance signer, the owner added to the Safe alongside
|
|
138
165
|
/// this call.
|
|
139
|
-
function approveNetworkMember(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
function approveNetworkMember(
|
|
167
|
+
uint256 accountKeyX,
|
|
168
|
+
uint256 accountKeyY,
|
|
169
|
+
uint256 semaphoreCommitment,
|
|
170
|
+
address governanceSigner
|
|
171
|
+
) external onlyOwner {
|
|
143
172
|
if (address(approvedIssuerSet) == address(0)) revert IssuerSetNotWired();
|
|
144
173
|
|
|
145
|
-
address authAddress =
|
|
174
|
+
(bytes32 memberNetworkId, address authAddress) = _memberOf(accountKeyX, accountKeyY);
|
|
146
175
|
|
|
147
176
|
if (networkMemberAuthAddress[memberNetworkId] != address(0)) revert AlreadyApproved(memberNetworkId);
|
|
148
177
|
if (semaphoreCommitment == 0) revert ZeroSemaphoreCommitment();
|
|
@@ -214,4 +243,23 @@ contract NetworkMemberRegistry is Ownable2Step {
|
|
|
214
243
|
identity[memberNetworkId] = newMetaStealthAddress;
|
|
215
244
|
emit NetworkMemberIdentityRegistered(memberNetworkId, newMetaStealthAddress);
|
|
216
245
|
}
|
|
246
|
+
|
|
247
|
+
/// @dev Derives a member's id and auth address from its account key, after checking the key is a
|
|
248
|
+
/// secp256k1 point. Without the check, a pair that is no key could still be admitted as an id.
|
|
249
|
+
function _memberOf(uint256 accountKeyX, uint256 accountKeyY)
|
|
250
|
+
internal
|
|
251
|
+
pure
|
|
252
|
+
returns (bytes32 memberNetworkId, address authAddress)
|
|
253
|
+
{
|
|
254
|
+
if (
|
|
255
|
+
accountKeyX >= SECP256K1_P || accountKeyY >= SECP256K1_P
|
|
256
|
+
|| mulmod(accountKeyY, accountKeyY, SECP256K1_P)
|
|
257
|
+
!= addmod(
|
|
258
|
+
mulmod(mulmod(accountKeyX, accountKeyX, SECP256K1_P), accountKeyX, SECP256K1_P), 7, SECP256K1_P
|
|
259
|
+
)
|
|
260
|
+
) revert AccountKeyNotOnCurve();
|
|
261
|
+
|
|
262
|
+
memberNetworkId = memberNetworkIdOf(accountKeyX, accountKeyY);
|
|
263
|
+
authAddress = address(uint160(uint256(memberNetworkId)));
|
|
264
|
+
}
|
|
217
265
|
}
|
package/README.md
CHANGED
|
@@ -24,14 +24,15 @@ imports them directly.
|
|
|
24
24
|
The governance Safe is the sole writer of the first two, and admission requires a majority of the
|
|
25
25
|
current members: admitting one adds its governance signer to the Safe, so the network governs itself.
|
|
26
26
|
See the
|
|
27
|
-
[network member onboarding and governance runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/network-member-onboarding.md).
|
|
27
|
+
[network member onboarding and governance runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/configuration/network-member-onboarding.md).
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
One command deploys every contract with the governance Safe as owner, enables the orchestrator as a
|
|
30
|
+
Safe module and admits the founding member. The
|
|
31
|
+
[deployment runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/deployment/deploy-and-configure-all.md)
|
|
32
|
+
covers keys and funding:
|
|
31
33
|
|
|
32
34
|
```shell
|
|
33
|
-
|
|
34
|
-
forge script script/DeployNetworkMemberRegistry.s.sol --rpc-url <rpc> --broadcast
|
|
35
|
+
yarn deployAndConfigureAll:sepolia --private-keys "$DEPLOYER_PRIVATE_KEY" --private-keys "$OWNER_PRIVATE_KEY" --sender "$DEPLOYER"
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
## Installation
|
|
@@ -56,37 +57,16 @@ The same holds off chain: with per-issuer registries, which tree a holder syncs
|
|
|
56
57
|
|
|
57
58
|
The one thing to design around is write contention. Every mutation takes the predecessor index of the affected leaf, valid only against the tree state the transaction executes in, so two members revoking in the same block collide and one reverts. In order of preference: batch with `revokeMany`, have the issuer service send revocations one at a time, or recompute the index and resubmit. A resubmit reuses the same proof, since the message does not bind the index and a revert does not burn the nullifier.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
The tree starts empty with root `0`, and the circuit canonicalises an empty-registry witness to `0`, so `isValidRoot(0)` holds and presentations verify from the first block.
|
|
62
|
-
|
|
63
|
-
A replaced root keeps working for a grace window: the service stamps the moment it stopped being current and accepts it for `rootDuration` (one hour) after that, so a proof built moments before a revocation still verifies. The zero root is the exception, because the stamp lives in a mapping where `0` already means "not a past root".
|
|
64
|
-
|
|
65
|
-
So the first revocation invalidates empty-tree proofs with no window at all:
|
|
66
|
-
|
|
67
|
-
| Time | Event | `isValidRoot` |
|
|
68
|
-
| -------- | ------------------------------------------------------------ | ---------------------------- |
|
|
69
|
-
| 10:00:00 | a holder syncs the empty tree and proves against root `0` | `0` accepted, it is current |
|
|
70
|
-
| 10:00:05 | the first revocation lands, root becomes `R1`, `0` unstamped | `0` rejected immediately |
|
|
71
|
-
| 11:00:00 | another holder syncs and proves against `R1` | `R1` accepted, it is current |
|
|
72
|
-
| 11:00:05 | the second revocation lands, root becomes `R2` | `R1` accepted until 12:00:05 |
|
|
73
|
-
|
|
74
|
-
Same race, opposite outcome. It can only bite once, around the transaction that takes the tree from empty to non-empty, and anyone caught re-syncs and re-proves.
|
|
75
|
-
|
|
76
|
-
Revoking one unused commitment at deployment avoids it. A commitment is `Poseidon2(revocationPubX, revocationPubY)`, so a value like `1` cannot collide with a real credential. The tree is then never empty and no proof is ever built against root `0`.
|
|
60
|
+
The empty-tree root `0` gets the same grace window as any other superseded root. After the first revocation, it remains valid for `rootDuration` seconds (one hour by default), then expires. A zero-duration window rejects it immediately.
|
|
77
61
|
|
|
78
62
|
## The approved-member group
|
|
79
63
|
|
|
80
|
-
`NetworkMemberGate` is only the gate. It does not create the group, admit members or hold any membership state: it is pointed at an existing Semaphore group by id at construction, and stores it in `NETWORK_MEMBER_GROUP_ID`.
|
|
64
|
+
`NetworkMemberGate` is only the gate. It does not create the group, admit members or hold any membership state: it is pointed at an existing Semaphore group by id at construction, and stores it in `NETWORK_MEMBER_GROUP_ID`. `NetworkMemberGroup` creates that group on the Semaphore deployment and is its admin, and the governance Safe owns `NetworkMemberGroup`. Everything about the proof lifecycle is likewise Semaphore's:
|
|
81
65
|
|
|
82
|
-
- Governance
|
|
66
|
+
- Governance maintains the set through `NetworkMemberGroup.addNetworkMember` and `removeNetworkMember`, which call Semaphore's `addMember` and `removeMember`.
|
|
83
67
|
- The recent-root window is the group's Merkle tree duration, one hour by default, so a proof built just before a membership change still lands. A removal therefore bites at `removal time + one hour`.
|
|
84
68
|
- `validateProof` records the proof's nullifier, so the same proof cannot be submitted twice. Clients pick a fresh random scope per proof, which makes each nullifier unlinkable from the last and leaves members unrate-limited.
|
|
85
69
|
|
|
86
70
|
What the gate adds is the binding, and it is structural rather than a check: the Semaphore message is never accepted from the caller. `NetworkMemberGate.NetworkMemberProof` is Semaphore's proof minus its message, and the `onlyNetworkMember(message, proof)` modifier fills that field in with the message the gated function derives from its own arguments. A proof repurposed for another payload therefore fails verification outright; there is no message field for an attacker to line up, and no comparison a future gated function could forget.
|
|
87
71
|
|
|
88
72
|
The message is `keccak256(UPDATE_DOMAIN_SEPARATOR, chainId, action, networkId, metaStealthAddress)`, where the separator is `keccak256(domain string, address(this))` hashed once at deployment. The payload is what stops a proof authorising a different update; the separator and the chain id place that update at one deployment. Both matter precisely because the registry does not own its group: two registries can be pointed at the same group id, and one registry can exist at the same address on two chains with the same members and therefore the same root, so without them a proof meant for one would authorise the other.
|
|
89
|
-
|
|
90
|
-
## Tests
|
|
91
|
-
|
|
92
|
-
The gated-path tests verify real Semaphore proofs against a real `Semaphore` deployment. The proofs are checked in at `test/fixtures/network-member-proofs.json` and `test/fixtures/credential-revocation-proofs.json`, regenerated with `yarn workspace @coordination-network/network-member-proof generate:fixtures` and `generate:revocation-fixtures`. Every proof binds the chain id and the contract address, so the tests pin that chain id and deploy to that address.
|
package/SemaphorePaymaster.sol
CHANGED
|
@@ -3,10 +3,9 @@ pragma solidity ^0.8.34;
|
|
|
3
3
|
|
|
4
4
|
import { IEntryPoint } from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
|
|
5
5
|
import { BasePaymaster } from "@account-abstraction/contracts/core/BasePaymaster.sol";
|
|
6
|
-
import { IPaymaster } from "@account-abstraction/contracts/interfaces/IPaymaster.sol";
|
|
7
6
|
import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
|
|
8
7
|
import { _packValidationData } from "@account-abstraction/contracts/core/Helpers.sol";
|
|
9
|
-
import {
|
|
8
|
+
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
|
|
10
9
|
import { Semaphore } from "@semaphore-protocol/contracts/Semaphore.sol";
|
|
11
10
|
import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
|
|
12
11
|
import { ISemaphoreVerifier } from "@semaphore-protocol/contracts/interfaces/ISemaphoreVerifier.sol";
|
|
@@ -17,8 +16,6 @@ import { MIN_DEPTH, MAX_DEPTH } from "@semaphore-protocol/contracts/base/Constan
|
|
|
17
16
|
* @notice A paymaster that validates user operations in a privacy preserving way using a single semaphore group
|
|
18
17
|
*/
|
|
19
18
|
contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
20
|
-
using UserOperationLib for PackedUserOperation;
|
|
21
|
-
|
|
22
19
|
/**
|
|
23
20
|
* The paymaster uses a single Semaphore group
|
|
24
21
|
*/
|
|
@@ -30,46 +27,17 @@ contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
|
30
27
|
uint256 private constant ENCODED_PROOF_LENGTH = 13 * 32;
|
|
31
28
|
|
|
32
29
|
/**
|
|
33
|
-
* Sponsorship stops for the rest of the epoch once spend reaches this cap, bounding what a
|
|
34
|
-
* stolen identity can drain before the owner can revoke or withdraw
|
|
35
|
-
*/
|
|
36
|
-
uint256 public immutable SPEND_CAP_PER_EPOCH;
|
|
37
|
-
uint256 public immutable EPOCH_DURATION;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Smaller than epoch cap, so exhausting the cap takes many userOps rather than one.
|
|
41
30
|
* `preVerificationGas` is self declared and never refunded, so `maxCost` can be used to drain the paymaster
|
|
42
31
|
*/
|
|
43
32
|
uint256 public immutable MAX_COST_PER_OP;
|
|
44
33
|
|
|
45
|
-
/**
|
|
46
|
-
* Enough for `_postOp` to record spend. The EntryPoint charges the paymaster even when `postOp`
|
|
47
|
-
* runs out of gas, so validation requires the limit up front
|
|
48
|
-
*/
|
|
49
|
-
uint256 public constant MIN_POSTOP_GAS = 40_000;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Validation cannot read `block.timestamp` (banned by ERC-7562), so the epoch lives in
|
|
53
|
-
* storage and is advanced by `updateEpoch`
|
|
54
|
-
*/
|
|
55
|
-
uint256 public currentEpoch;
|
|
56
|
-
|
|
57
|
-
mapping(uint256 epoch => uint256 spent) public spentInEpoch;
|
|
58
|
-
|
|
59
34
|
error ZeroOwner();
|
|
60
35
|
|
|
61
|
-
constructor(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
uint256 _spendCapPerEpoch,
|
|
66
|
-
uint256 _epochDuration,
|
|
67
|
-
uint256 _maxCostPerOp
|
|
68
|
-
) BasePaymaster(IEntryPoint(_entryPoint)) Semaphore(ISemaphoreVerifier(_verifier)) {
|
|
69
|
-
SPEND_CAP_PER_EPOCH = _spendCapPerEpoch;
|
|
70
|
-
EPOCH_DURATION = _epochDuration;
|
|
36
|
+
constructor(address _entryPoint, address _verifier, address _owner, uint256 _maxCostPerOp)
|
|
37
|
+
BasePaymaster(IEntryPoint(_entryPoint))
|
|
38
|
+
Semaphore(ISemaphoreVerifier(_verifier))
|
|
39
|
+
{
|
|
71
40
|
MAX_COST_PER_OP = _maxCostPerOp;
|
|
72
|
-
currentEpoch = block.timestamp / _epochDuration;
|
|
73
41
|
|
|
74
42
|
// _transferOwnership skips zero-address guard
|
|
75
43
|
if (_owner == address(0)) revert ZeroOwner();
|
|
@@ -101,11 +69,7 @@ contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
|
101
69
|
override
|
|
102
70
|
returns (bytes memory context, uint256 validationData)
|
|
103
71
|
{
|
|
104
|
-
if (maxCost > MAX_COST_PER_OP
|
|
105
|
-
return ("", _packValidationData(true, 0, 0));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (userOp.unpackPostOpGasLimit() < MIN_POSTOP_GAS) {
|
|
72
|
+
if (maxCost > MAX_COST_PER_OP) {
|
|
109
73
|
return ("", _packValidationData(true, 0, 0));
|
|
110
74
|
}
|
|
111
75
|
|
|
@@ -149,17 +113,16 @@ contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
|
149
113
|
// A proof could have used an old Merkle tree root.
|
|
150
114
|
// https://github.com/semaphore-protocol/semaphore/issues/98
|
|
151
115
|
if (proof.merkleTreeRoot != currentMerkleTreeRoot) {
|
|
152
|
-
uint256
|
|
116
|
+
uint256 merkleRootSupersededDate = groups[GROUP_ID].merkleRootSupersededDates[proof.merkleTreeRoot];
|
|
153
117
|
uint256 merkleTreeDuration = groups[GROUP_ID].merkleTreeDuration;
|
|
154
118
|
|
|
155
|
-
|
|
119
|
+
// A zero duration would otherwise make `validUntil` 0, which ERC-4337 reads as "never expires"
|
|
120
|
+
if (merkleRootSupersededDate == 0 || merkleTreeDuration == 0) {
|
|
156
121
|
return ("", _packValidationData(true, 0, 0));
|
|
157
122
|
}
|
|
158
123
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
162
|
-
validUntil = uint48(merkleRootCreationDate + merkleTreeDuration);
|
|
124
|
+
// Semaphore's window end is exclusive, `validUntil` is inclusive
|
|
125
|
+
validUntil = SafeCast.toUint48(merkleRootSupersededDate + merkleTreeDuration - 1);
|
|
163
126
|
}
|
|
164
127
|
|
|
165
128
|
// Call `verifier.verifyProof` directly. `this.verifyProof` reads `block.timestamp` when the
|
|
@@ -175,20 +138,7 @@ contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
|
175
138
|
return ("", _packValidationData(true, 0, 0));
|
|
176
139
|
}
|
|
177
140
|
|
|
178
|
-
|
|
179
|
-
return (hex"01", _packValidationData(false, validUntil, 0));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function _postOp(IPaymaster.PostOpMode, bytes calldata, uint256 actualGasCost, uint256) internal override {
|
|
183
|
-
updateEpoch();
|
|
184
|
-
spentInEpoch[currentEpoch] += actualGasCost;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Advance to the current epoch, freeing the spend cap again.
|
|
189
|
-
*/
|
|
190
|
-
function updateEpoch() public {
|
|
191
|
-
currentEpoch = block.timestamp / EPOCH_DURATION;
|
|
141
|
+
return ("", _packValidationData(false, validUntil, 0));
|
|
192
142
|
}
|
|
193
143
|
|
|
194
144
|
/**
|
|
@@ -11,8 +11,9 @@ import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.
|
|
|
11
11
|
/// paymaster.
|
|
12
12
|
///
|
|
13
13
|
/// This contract is only the gate. It does not create the approved-member group, admit members,
|
|
14
|
-
/// remove them or hold any membership state: the group is created
|
|
15
|
-
///
|
|
14
|
+
/// remove them or hold any membership state: the group is created on the Semaphore deployment by
|
|
15
|
+
/// `NetworkMemberGroup` and administered by governance, and this contract is pointed at it by id at
|
|
16
|
+
/// construction.
|
|
16
17
|
/// Everything about the proof lifecycle is likewise Semaphore's: membership, the recent-root window
|
|
17
18
|
/// (the group's Merkle tree duration) and the nullifier set that stops a proof being submitted
|
|
18
19
|
/// twice.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coordination-network/contracts",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The coordination network's Solidity contracts: the network member registry and its governance, the identity registry, the approved-issuer set, the revocation registries and the Semaphore paymaster.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@account-abstraction/contracts": "^0.8.0",
|
|
37
37
|
"@openzeppelin/contracts": "^5.6.1",
|
|
38
|
-
"@semaphore-protocol/contracts": "^4.14.
|
|
38
|
+
"@semaphore-protocol/contracts": "^4.14.4",
|
|
39
39
|
"@zk-kit/lean-imt-plus.sol": "0.1.1",
|
|
40
40
|
"@zk-kit/lean-imt.sol": "^2.0.1",
|
|
41
41
|
"poseidon-solidity": "^0.0.5"
|
package/revocation/README.md
CHANGED
|
@@ -83,4 +83,5 @@ yarn test:contracts
|
|
|
83
83
|
- There is deliberately no on-chain Merkle proof verifier. Verifying a plaintext proof would reveal the commitment, and a caller willing to do that can just call `isRevoked`, which is cheaper and answers the same question. The private path is a zero-knowledge proof verified elsewhere, whose root is checked with `isValidRoot`.
|
|
84
84
|
- Registries are isolated. One deployment serves many issuers, and an admin can only write to its own registry.
|
|
85
85
|
- Admin transfers are two-step, so a typo cannot strand a registry.
|
|
86
|
+
- `unrevoke` and `updateRevocationCommitment` are for correcting mistakes, such as a commitment revoked in error or an invalid value written to the tree. Like every write here, they are visible to anyone reading the chain. `unrevoke` shows that a commitment is no longer revoked. `updateRevocationCommitment` shows the old and new commitments together, so an observer can tell one replaced the other. Whether that link reveals anything depends on how the commitments are generated and handled in your use case. If two commitments should not appear related, use `unrevoke` and a separate `revoke` instead, spaced apart in time, since two writes landing back to back can be linked by their timing alone.
|
|
86
87
|
- `test/integration/CrossLanguageRoot.t.sol` pins the contract's roots against the TypeScript package. Any drift in the hashing or the insertion rules fails a test on both sides.
|
|
@@ -138,8 +138,11 @@ contract Revocation is IRevocation, RevocationRegistries {
|
|
|
138
138
|
// hours, so the seconds of drift a validator can introduce cannot meaningfully lengthen or
|
|
139
139
|
// shorten it. The worst case is a proof accepted or rejected a few seconds either side of
|
|
140
140
|
// its deadline, which is what the window exists to tolerate in the first place.
|
|
141
|
+
//
|
|
142
|
+
// `supersededAt` is never in the future, so the subtraction cannot underflow, and unlike
|
|
143
|
+
// `supersededAt + rootDuration` it cannot overflow for any `rootDuration`.
|
|
141
144
|
// forge-lint: disable-next-line(block-timestamp)
|
|
142
|
-
return block.timestamp
|
|
145
|
+
return block.timestamp - supersededAt < registries[registryId].rootDuration;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
/// @inheritdoc IRevocation
|
|
@@ -175,13 +178,9 @@ contract Revocation is IRevocation, RevocationRegistries {
|
|
|
175
178
|
/// revoked credential is the one that credential's own revocation replaced, so it stops being
|
|
176
179
|
/// accepted `rootDuration` seconds after that revocation.
|
|
177
180
|
///
|
|
178
|
-
/// The empty-tree root is skipped: it is not a root anybody can build a proof against.
|
|
179
|
-
///
|
|
180
181
|
/// @param registryId The id of the registry.
|
|
181
182
|
/// @param oldRoot The root the mutation replaced.
|
|
182
183
|
function _supersedeRoot(uint256 registryId, uint256 oldRoot) private {
|
|
183
|
-
if (oldRoot == 0) return;
|
|
184
|
-
|
|
185
184
|
registries[registryId].rootSupersededAt[oldRoot] = block.timestamp;
|
|
186
185
|
}
|
|
187
186
|
}
|
|
@@ -59,6 +59,11 @@ interface IRevocation {
|
|
|
59
59
|
/// proof in flight when a revocation lands is invalidated. That is the strictest setting and the
|
|
60
60
|
/// one a registry with adversarial timing concerns wants, at the cost of failed presentations
|
|
61
61
|
/// that an honest holder cannot avoid.
|
|
62
|
+
///
|
|
63
|
+
/// The new duration applies to every past root of the registry, not only to roots superseded
|
|
64
|
+
/// after this call, so raising it makes older roots valid again and lowering it invalidates
|
|
65
|
+
/// proofs that are already in flight. It is also how long a revoked credential can keep
|
|
66
|
+
/// verifying with an older root, so it should be kept as short as the registry can tolerate.
|
|
62
67
|
/// @param registryId The id of the registry.
|
|
63
68
|
/// @param newRootDuration The new window, in seconds. Zero is allowed.
|
|
64
69
|
function updateRegistryRootDuration(uint256 registryId, uint256 newRootDuration) external;
|