@coordination-network/contracts 0.0.2 → 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.
@@ -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.
@@ -361,6 +363,11 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
361
363
  }
362
364
 
363
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.
364
371
  /// @param newRootDuration The new window, in seconds.
365
372
  function updateRootDuration(uint256 newRootDuration) external {
366
373
  if (msg.sender != NetworkMemberRegistry(memberRegistry).owner()) revert UnauthorizedCaller();
@@ -392,9 +399,11 @@ contract ApprovedIssuerSet is IApprovedIssuerSet {
392
399
 
393
400
  // Strict, so a `rootDuration` of zero means no grace at all rather than a one-timestamp
394
401
  // window. The window runs from the moment the root stopped being current, so every root gets
395
- // 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`.
396
405
  // forge-lint: disable-next-line(block-timestamp)
397
- return block.timestamp < supersededAt + rootDuration;
406
+ return block.timestamp - supersededAt < rootDuration;
398
407
  }
399
408
 
400
409
  /// @notice The live signing keys of a member.
@@ -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 caller can lower it;
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 memberNetworkId The member to admit.
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
- bytes32 memberNetworkId,
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 = _checkMemberCount(expectedMemberCount);
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, (memberNetworkId, semaphoreCommitment, governanceSigner)
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(memberNetworkId, governanceSigner, threshold);
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
- _checkMemberCount(expectedMemberCount);
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. {exitNetwork} needs no such check: it carries no signatures to go stale.
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 _checkMemberCount(uint256 expectedMemberCount) internal view returns (uint256 memberCount) {
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
- /// The onboarding procedure is in `docs/network-member-onboarding.md`.
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
- /// @notice The address that maintains a network member's record. Initialised from the networkId
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, uint256 semaphoreCommitment, address governanceSigner, string metadataURI
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 Asks the approved network members to admit `memberNetworkId`. Permissionless, and
96
- /// callable by anyone holding the key behind the networkId.
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 networkId encodes, so nobody can post a request on another member's behalf.
101
- /// @param memberNetworkId The networkId asking to join.
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
- bytes32 memberNetworkId,
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 = address(uint160(uint256(memberNetworkId)));
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(memberNetworkId, semaphoreCommitment, governanceSigner, metadataURI);
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. Auth address is initialised from the networkId. The companion
128
- /// calls in the same Safe transaction are `NetworkMemberGroup.addNetworkMember` and the Safe's own
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 memberNetworkId The network member to approve.
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(bytes32 memberNetworkId, uint256 semaphoreCommitment, address governanceSigner)
140
- external
141
- onlyOwner
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 = address(uint160(uint256(memberNetworkId)));
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
- Deploy all three with the governance Safe as their owner, then enable the orchestrator as a Safe module
30
- with a quorum:
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
- GOVERNANCE_SAFE=0x<safe> SEMAPHORE=0x<semaphore> \
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
- ## The first revocation
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`. Creating that group and administering it are governance concerns, done on the Semaphore deployment itself and tracked separately. Everything about the proof lifecycle is likewise Semaphore's:
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, as the group admin, maintains the set on the Semaphore contract: `addMember`, `addMembers`, `removeMember`, `updateMember`.
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.
@@ -113,14 +113,16 @@ contract SemaphorePaymaster is BasePaymaster, Semaphore {
113
113
  // A proof could have used an old Merkle tree root.
114
114
  // https://github.com/semaphore-protocol/semaphore/issues/98
115
115
  if (proof.merkleTreeRoot != currentMerkleTreeRoot) {
116
- uint256 merkleRootCreationDate = groups[GROUP_ID].merkleRootCreationDates[proof.merkleTreeRoot];
116
+ uint256 merkleRootSupersededDate = groups[GROUP_ID].merkleRootSupersededDates[proof.merkleTreeRoot];
117
117
  uint256 merkleTreeDuration = groups[GROUP_ID].merkleTreeDuration;
118
118
 
119
- if (merkleRootCreationDate == 0) {
119
+ // A zero duration would otherwise make `validUntil` 0, which ERC-4337 reads as "never expires"
120
+ if (merkleRootSupersededDate == 0 || merkleTreeDuration == 0) {
120
121
  return ("", _packValidationData(true, 0, 0));
121
122
  }
122
123
 
123
- validUntil = SafeCast.toUint48(merkleRootCreationDate + merkleTreeDuration);
124
+ // Semaphore's window end is exclusive, `validUntil` is inclusive
125
+ validUntil = SafeCast.toUint48(merkleRootSupersededDate + merkleTreeDuration - 1);
124
126
  }
125
127
 
126
128
  // Call `verifier.verifyProof` directly. `this.verifyProof` reads `block.timestamp` when the
@@ -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 and administered on the Semaphore
15
- /// deployment itself, by governance, and this contract is pointed at it by id at construction.
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.2",
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.3",
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"
@@ -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 < supersededAt + registries[registryId].rootDuration;
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;