@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,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.34;
|
|
3
|
+
|
|
4
|
+
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
|
|
5
|
+
import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol";
|
|
6
|
+
import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
|
|
7
|
+
import { ISemaphoreGroups } from "@semaphore-protocol/contracts/interfaces/ISemaphoreGroups.sol";
|
|
8
|
+
|
|
9
|
+
/// @title NetworkMemberGroup
|
|
10
|
+
/// @notice The Semaphore group of approved network members. Owns nothing but the membership: who a
|
|
11
|
+
/// member is lives in the NetworkMemberRegistry, and the group is created here at deployment.
|
|
12
|
+
/// @dev The group is created on the Semaphore deployment in this contract's constructor, with this
|
|
13
|
+
/// contract as its admin, so membership can only ever change through the two governance-gated calls
|
|
14
|
+
/// below. The owner is the governance Safe, which makes a quorum of approved network members the
|
|
15
|
+
/// only party that can add or remove a member.
|
|
16
|
+
///
|
|
17
|
+
/// Everything about the group beyond membership belongs to Semaphore: the tree, the root history and
|
|
18
|
+
/// its duration, proof verification, and the nullifier set. Consumers that gate on membership read
|
|
19
|
+
/// the group by id from the Semaphore deployment and never need this contract, which is why {GROUP_ID}
|
|
20
|
+
/// and {SEMAPHORE} are public.
|
|
21
|
+
contract NetworkMemberGroup is Ownable2Step {
|
|
22
|
+
/// @notice The Semaphore deployment holding the group.
|
|
23
|
+
ISemaphore public immutable SEMAPHORE;
|
|
24
|
+
|
|
25
|
+
/// @notice The approved-network-member group, created at deployment.
|
|
26
|
+
uint256 public immutable GROUP_ID;
|
|
27
|
+
|
|
28
|
+
event NetworkMemberAdded(uint256 identityCommitment);
|
|
29
|
+
event NetworkMemberRemoved(uint256 identityCommitment);
|
|
30
|
+
|
|
31
|
+
/// @param semaphore The Semaphore deployment to create the group on.
|
|
32
|
+
/// @param governance The governance Safe. It becomes the owner, and the sole writer.
|
|
33
|
+
constructor(ISemaphore semaphore, address governance) Ownable(governance) {
|
|
34
|
+
SEMAPHORE = semaphore;
|
|
35
|
+
GROUP_ID = semaphore.createGroup(address(this));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// @notice Adds an approved network member's Semaphore identity commitment to the group.
|
|
39
|
+
/// @dev Callable only by the governance Safe, so a member joins the group under the same quorum
|
|
40
|
+
/// that admitted it to the registry. Semaphore rejects a zero commitment, one outside the SNARK
|
|
41
|
+
/// scalar field, and a duplicate.
|
|
42
|
+
/// @param identityCommitment The member's Semaphore identity commitment.
|
|
43
|
+
function addNetworkMember(uint256 identityCommitment) external onlyOwner {
|
|
44
|
+
SEMAPHORE.addMember(GROUP_ID, identityCommitment);
|
|
45
|
+
|
|
46
|
+
emit NetworkMemberAdded(identityCommitment);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// @notice Removes a network member's commitment from the group.
|
|
50
|
+
/// @param identityCommitment The commitment to remove.
|
|
51
|
+
/// @param merkleProofSiblings The sibling nodes proving the commitment is in the group, computed
|
|
52
|
+
/// off chain against the group state this transaction will execute in.
|
|
53
|
+
function removeNetworkMember(uint256 identityCommitment, uint256[] calldata merkleProofSiblings)
|
|
54
|
+
external
|
|
55
|
+
onlyOwner
|
|
56
|
+
{
|
|
57
|
+
SEMAPHORE.removeMember(GROUP_ID, identityCommitment, merkleProofSiblings);
|
|
58
|
+
|
|
59
|
+
emit NetworkMemberRemoved(identityCommitment);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// @notice Whether a commitment is currently in the group.
|
|
63
|
+
/// @param identityCommitment The commitment to look up.
|
|
64
|
+
/// @return True if the commitment is a member.
|
|
65
|
+
function hasNetworkMember(uint256 identityCommitment) external view returns (bool) {
|
|
66
|
+
return ISemaphoreGroups(address(SEMAPHORE)).hasMember(GROUP_ID, identityCommitment);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// @notice The group's current Merkle root, the value a membership proof is checked against.
|
|
70
|
+
/// @return The current root.
|
|
71
|
+
function merkleTreeRoot() external view returns (uint256) {
|
|
72
|
+
return ISemaphoreGroups(address(SEMAPHORE)).getMerkleTreeRoot(GROUP_ID);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.34;
|
|
3
|
+
|
|
4
|
+
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
|
|
5
|
+
import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol";
|
|
6
|
+
import { IApprovedIssuerSet } from "./interfaces/IApprovedIssuerSet.sol";
|
|
7
|
+
|
|
8
|
+
/// @title NetworkMemberRegistry
|
|
9
|
+
/// @notice Holds network member state: who is approved, and each member's keys.
|
|
10
|
+
/// @dev Governance. The owner is the governance Safe, a multisig of the governing members, and it
|
|
11
|
+
/// is the only writer of the approved-network-member set: nothing here lets a single operator admit
|
|
12
|
+
/// or remove a member. Joining is permissionless in the request direction and
|
|
13
|
+
/// governed in the admission direction:
|
|
14
|
+
///
|
|
15
|
+
/// 1. a prospective member calls {requestToJoin} itself, which stores nothing and only emits
|
|
16
|
+
/// {NetworkMemberJoinRequested}. Already-approved members watch that event off chain;
|
|
17
|
+
/// 2. enough of them sign a Safe transaction calling {approveNetworkMember} to meet the Safe's
|
|
18
|
+
/// threshold, and the Safe executes it.
|
|
19
|
+
///
|
|
20
|
+
/// Admission is three calls the Safe batches into one transaction, because the state that defines an
|
|
21
|
+
/// approved network member lives in three places: the record here, the member's leaf in the Semaphore
|
|
22
|
+
/// group NetworkMemberGroup administers, and the member's governance signer in the Safe's own owner
|
|
23
|
+
/// set. Batching them means a member is never approved without its group leaf, and never an approver
|
|
24
|
+
/// without being a member. This contract records which keys belong to which member; it holds neither
|
|
25
|
+
/// the group nor the owner set.
|
|
26
|
+
///
|
|
27
|
+
/// The onboarding procedure is in `docs/network-member-onboarding.md`.
|
|
28
|
+
contract NetworkMemberRegistry is Ownable2Step {
|
|
29
|
+
uint256 public constant META_STEALTH_ADDRESS_LENGTH = 66;
|
|
30
|
+
|
|
31
|
+
/// @notice The address that maintains a network member's record. Initialised from the networkId
|
|
32
|
+
mapping(bytes32 memberNetworkId => address authAddress) public networkMemberAuthAddress;
|
|
33
|
+
|
|
34
|
+
/// @notice The network member networkId => meta-stealth address mapping.
|
|
35
|
+
/// Network members resolve here, not in the IdentityRegistry.
|
|
36
|
+
mapping(bytes32 memberNetworkId => bytes metaStealthAddress) public identity;
|
|
37
|
+
|
|
38
|
+
/// @notice The network member's Semaphore identity commitment, its leaf in the approved-member
|
|
39
|
+
/// group NetworkMemberGroup administers.
|
|
40
|
+
mapping(bytes32 memberNetworkId => uint256 semaphoreCommitment) public semaphoreCommitmentOf;
|
|
41
|
+
|
|
42
|
+
/// @notice The network member's governance signer: the address that votes for it as an owner of
|
|
43
|
+
/// the governance Safe. Recorded here so a removal can find the owner to drop without replaying
|
|
44
|
+
/// the member's admission history.
|
|
45
|
+
mapping(bytes32 memberNetworkId => address governanceSigner) public governanceSignerOf;
|
|
46
|
+
|
|
47
|
+
/// @notice The set holding members' credential signing keys. Wired once, after deployment.
|
|
48
|
+
IApprovedIssuerSet public approvedIssuerSet;
|
|
49
|
+
|
|
50
|
+
event NetworkMemberJoinRequested(
|
|
51
|
+
bytes32 indexed memberNetworkId, uint256 semaphoreCommitment, address governanceSigner, string metadataURI
|
|
52
|
+
);
|
|
53
|
+
event NetworkMemberApproved(bytes32 indexed memberNetworkId);
|
|
54
|
+
event NetworkMemberRemoved(bytes32 indexed memberNetworkId);
|
|
55
|
+
event NetworkMemberIdentityRegistered(bytes32 indexed memberNetworkId, bytes metaStealthAddress);
|
|
56
|
+
event GovernanceSignerPublished(bytes32 indexed memberNetworkId, address indexed governanceSigner);
|
|
57
|
+
event ApprovedIssuerSetWired(address indexed approvedIssuerSet);
|
|
58
|
+
|
|
59
|
+
error AlreadyApproved(bytes32 memberNetworkId);
|
|
60
|
+
error NotApproved(bytes32 memberNetworkId);
|
|
61
|
+
error InvalidMetaStealthAddressLength();
|
|
62
|
+
error UnauthorizedCaller();
|
|
63
|
+
error ZeroGovernanceSigner();
|
|
64
|
+
error ZeroSemaphoreCommitment();
|
|
65
|
+
error GovernanceSignerIsAuthAddress();
|
|
66
|
+
error ZeroApprovedIssuerSet();
|
|
67
|
+
error ApprovedIssuerSetAlreadyWired();
|
|
68
|
+
error ApprovedIssuerSetNotBoundToThisRegistry();
|
|
69
|
+
error IssuerSetNotWired();
|
|
70
|
+
|
|
71
|
+
/// @dev Restricts a network member record write to the network member's current auth address,
|
|
72
|
+
/// or to governance.
|
|
73
|
+
/// @param memberNetworkId The network member whose record is being written.
|
|
74
|
+
modifier onlyAuthAddressOrGovernance(bytes32 memberNetworkId) {
|
|
75
|
+
_onlyAuthAddressOrGovernance(memberNetworkId);
|
|
76
|
+
_;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _onlyAuthAddressOrGovernance(bytes32 memberNetworkId) internal view {
|
|
80
|
+
if (msg.sender != networkMemberAuthAddress[memberNetworkId] && msg.sender != owner()) {
|
|
81
|
+
revert UnauthorizedCaller();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// @param governance The governance Safe. It becomes the owner, and the sole approver.
|
|
86
|
+
constructor(address governance) Ownable(governance) { }
|
|
87
|
+
|
|
88
|
+
/// @notice Whether a network member is currently approved.
|
|
89
|
+
/// @param memberNetworkId The network member's networkId.
|
|
90
|
+
/// @return True if the network member is approved.
|
|
91
|
+
function isApproved(bytes32 memberNetworkId) external view returns (bool) {
|
|
92
|
+
return networkMemberAuthAddress[memberNetworkId] != address(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// @notice Asks the approved network members to admit `memberNetworkId`. Permissionless, and
|
|
96
|
+
/// callable by anyone holding the key behind the networkId.
|
|
97
|
+
/// @dev Stores nothing and confers nothing. It is the announcement half of the join flow: the
|
|
98
|
+
/// approved members watch this event, and admission happens only if enough of them sign the
|
|
99
|
+
/// 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.
|
|
102
|
+
/// @param semaphoreCommitment The Semaphore identity commitment the requester wants in the group.
|
|
103
|
+
/// @param governanceSigner The address the requester wants added to the governance Safe, so it can
|
|
104
|
+
/// vote on later candidates. Must differ from the auth address: the auth address is a hot key that
|
|
105
|
+
/// signs routine record writes, and Safe ownership should not sit on a key used that way.
|
|
106
|
+
/// @param metadataURI Where the approvers can fetch this requester's vetting material. Any URI the
|
|
107
|
+
/// network has agreed on, and it may be empty. The contract neither reads nor stores it.
|
|
108
|
+
function requestToJoin(
|
|
109
|
+
bytes32 memberNetworkId,
|
|
110
|
+
uint256 semaphoreCommitment,
|
|
111
|
+
address governanceSigner,
|
|
112
|
+
string calldata metadataURI
|
|
113
|
+
) external {
|
|
114
|
+
address authAddress = address(uint160(uint256(memberNetworkId)));
|
|
115
|
+
|
|
116
|
+
if (msg.sender != authAddress) revert UnauthorizedCaller();
|
|
117
|
+
if (networkMemberAuthAddress[memberNetworkId] != address(0)) revert AlreadyApproved(memberNetworkId);
|
|
118
|
+
if (semaphoreCommitment == 0) revert ZeroSemaphoreCommitment();
|
|
119
|
+
if (governanceSigner == address(0)) revert ZeroGovernanceSigner();
|
|
120
|
+
if (governanceSigner == authAddress) revert GovernanceSignerIsAuthAddress();
|
|
121
|
+
|
|
122
|
+
emit NetworkMemberJoinRequested(memberNetworkId, semaphoreCommitment, governanceSigner, metadataURI);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// @notice Admits a network member to the approved set.
|
|
126
|
+
/// @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`.
|
|
130
|
+
///
|
|
131
|
+
/// Requires the issuer set to already be wired, for the same reason {removeNetworkMember} does:
|
|
132
|
+
/// without it a member could be admitted into a state {removeNetworkMember} can never remove it
|
|
133
|
+
/// from, since that function refuses to run while the set is unwired.
|
|
134
|
+
/// @param memberNetworkId The network member to approve.
|
|
135
|
+
/// @param semaphoreCommitment The member's Semaphore identity commitment. Must be non-zero, which
|
|
136
|
+
/// the Semaphore group requires too, so a record cannot hold a commitment the group would refuse.
|
|
137
|
+
/// @param governanceSigner The member's governance signer, the owner added to the Safe alongside
|
|
138
|
+
/// this call.
|
|
139
|
+
function approveNetworkMember(bytes32 memberNetworkId, uint256 semaphoreCommitment, address governanceSigner)
|
|
140
|
+
external
|
|
141
|
+
onlyOwner
|
|
142
|
+
{
|
|
143
|
+
if (address(approvedIssuerSet) == address(0)) revert IssuerSetNotWired();
|
|
144
|
+
|
|
145
|
+
address authAddress = address(uint160(uint256(memberNetworkId)));
|
|
146
|
+
|
|
147
|
+
if (networkMemberAuthAddress[memberNetworkId] != address(0)) revert AlreadyApproved(memberNetworkId);
|
|
148
|
+
if (semaphoreCommitment == 0) revert ZeroSemaphoreCommitment();
|
|
149
|
+
if (governanceSigner == address(0)) revert ZeroGovernanceSigner();
|
|
150
|
+
if (governanceSigner == authAddress) revert GovernanceSignerIsAuthAddress();
|
|
151
|
+
|
|
152
|
+
networkMemberAuthAddress[memberNetworkId] = authAddress;
|
|
153
|
+
semaphoreCommitmentOf[memberNetworkId] = semaphoreCommitment;
|
|
154
|
+
governanceSignerOf[memberNetworkId] = governanceSigner;
|
|
155
|
+
|
|
156
|
+
emit NetworkMemberApproved(memberNetworkId);
|
|
157
|
+
emit GovernanceSignerPublished(memberNetworkId, governanceSigner);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// @notice Wires the approved-issuer set. Callable once.
|
|
161
|
+
/// @dev Deliberately not a constructor argument: the set needs this registry's address, so the
|
|
162
|
+
/// registry has to exist first.
|
|
163
|
+
/// @param newApprovedIssuerSet The set this registry retires keys in when it removes a member.
|
|
164
|
+
function setApprovedIssuerSet(address newApprovedIssuerSet) external onlyOwner {
|
|
165
|
+
if (address(approvedIssuerSet) != address(0)) revert ApprovedIssuerSetAlreadyWired();
|
|
166
|
+
if (newApprovedIssuerSet == address(0)) revert ZeroApprovedIssuerSet();
|
|
167
|
+
|
|
168
|
+
// The set only answers its own registry, and this write cannot be redone. Wiring one built
|
|
169
|
+
// against a different registry would make every removal revert, with no way to rewire.
|
|
170
|
+
if (IApprovedIssuerSet(newApprovedIssuerSet).memberRegistry() != address(this)) {
|
|
171
|
+
revert ApprovedIssuerSetNotBoundToThisRegistry();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
approvedIssuerSet = IApprovedIssuerSet(newApprovedIssuerSet);
|
|
175
|
+
emit ApprovedIssuerSetWired(newApprovedIssuerSet);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// @notice Removes a network member and retires every signing key it published.
|
|
179
|
+
/// @dev The companion calls in the same Safe transaction are
|
|
180
|
+
/// `NetworkMemberGroup.removeNetworkMember` and the Safe's own `removeOwner`.
|
|
181
|
+
///
|
|
182
|
+
/// Both halves happen here so they cannot come apart. Removing a member while leaving its key in
|
|
183
|
+
/// the set would let it keep signing credentials that verify, and nothing detects that: a
|
|
184
|
+
/// verifier cannot attribute a credential to a member. Reverting while the set is unwired is the
|
|
185
|
+
/// safe direction, since the alternative is removing members without disarming their keys.
|
|
186
|
+
/// @param memberNetworkId The network member to remove.
|
|
187
|
+
function removeNetworkMember(bytes32 memberNetworkId) external onlyOwner {
|
|
188
|
+
if (networkMemberAuthAddress[memberNetworkId] == address(0)) revert NotApproved(memberNetworkId);
|
|
189
|
+
|
|
190
|
+
IApprovedIssuerSet issuerSet = approvedIssuerSet;
|
|
191
|
+
if (address(issuerSet) == address(0)) revert IssuerSetNotWired();
|
|
192
|
+
|
|
193
|
+
delete networkMemberAuthAddress[memberNetworkId];
|
|
194
|
+
delete identity[memberNetworkId];
|
|
195
|
+
delete semaphoreCommitmentOf[memberNetworkId];
|
|
196
|
+
delete governanceSignerOf[memberNetworkId];
|
|
197
|
+
|
|
198
|
+
emit NetworkMemberRemoved(memberNetworkId);
|
|
199
|
+
|
|
200
|
+
issuerSet.retireAllIssuerKeys(memberNetworkId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// @notice Sets a network member's meta-stealth address. Callable by the network member's auth
|
|
204
|
+
/// address or by governance.
|
|
205
|
+
/// @param memberNetworkId The network member whose identity is being set.
|
|
206
|
+
/// @param newMetaStealthAddress The ERC-6538 meta-stealth address to store. Must be 66 bytes.
|
|
207
|
+
function registerNetworkMemberIdentity(bytes32 memberNetworkId, bytes calldata newMetaStealthAddress)
|
|
208
|
+
external
|
|
209
|
+
onlyAuthAddressOrGovernance(memberNetworkId)
|
|
210
|
+
{
|
|
211
|
+
if (networkMemberAuthAddress[memberNetworkId] == address(0)) revert NotApproved(memberNetworkId);
|
|
212
|
+
if (newMetaStealthAddress.length != META_STEALTH_ADDRESS_LENGTH) revert InvalidMetaStealthAddressLength();
|
|
213
|
+
|
|
214
|
+
identity[memberNetworkId] = newMetaStealthAddress;
|
|
215
|
+
emit NetworkMemberIdentityRegistered(memberNetworkId, newMetaStealthAddress);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @coordination-network/contracts
|
|
2
|
+
|
|
3
|
+
The coordination network's core contracts.
|
|
4
|
+
|
|
5
|
+
- `NetworkMemberRegistry`: the approved-network-member set, and each member's identity, Semaphore
|
|
6
|
+
commitment, credential signing key and governance signer. Member information only.
|
|
7
|
+
- `NetworkMemberGroup`: the Semaphore group of approved members, created on the Semaphore deployment
|
|
8
|
+
at construction and administered by this contract.
|
|
9
|
+
- `NetworkMemberGovernance`: the orchestrator. One call per governance operation (admit, remove, exit),
|
|
10
|
+
callable only by the Safe and enabled as a Safe module. It computes the majority threshold itself, so
|
|
11
|
+
no caller can carry a wrong one, and it refuses to shrink the member set to nobody.
|
|
12
|
+
- `IdentityRegistry`: resolves a self-sovereign user's networkId to its meta-stealth address.
|
|
13
|
+
- `CredentialRevocationRegistry`: the network's one revocation registry. A single tree for every
|
|
14
|
+
revoked credential, whoever issued it, held on a standalone [`Revocation`](revocation/README.md)
|
|
15
|
+
deployment.
|
|
16
|
+
- `ApprovedIssuerSet`: the issuer set the credential circuit proves against.
|
|
17
|
+
- `SemaphorePaymaster`: an ERC-4337 paymaster that sponsors a user operation against a Semaphore
|
|
18
|
+
membership proof.
|
|
19
|
+
- `ERC5564Announcer`: the stealth-address announcement contract.
|
|
20
|
+
|
|
21
|
+
The [revocation registries](revocation/README.md) ship in this package too: `CredentialRevocationRegistry`
|
|
22
|
+
imports them directly.
|
|
23
|
+
|
|
24
|
+
The governance Safe is the sole writer of the first two, and admission requires a majority of the
|
|
25
|
+
current members: admitting one adds its governance signer to the Safe, so the network governs itself.
|
|
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).
|
|
28
|
+
|
|
29
|
+
Deploy all three with the governance Safe as their owner, then enable the orchestrator as a Safe module
|
|
30
|
+
with a quorum:
|
|
31
|
+
|
|
32
|
+
```shell
|
|
33
|
+
GOVERNANCE_SAFE=0x<safe> SEMAPHORE=0x<semaphore> \
|
|
34
|
+
forge script script/DeployNetworkMemberRegistry.s.sol --rpc-url <rpc> --broadcast
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
yarn add @coordination-network/contracts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```solidity
|
|
44
|
+
import { IdentityRegistry } from "@coordination-network/contracts/IdentityRegistry.sol";
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Who can write to the IdentityRegistry
|
|
48
|
+
|
|
49
|
+
Reads are public and ungated. Every write, `registerIdentity`, `updateIdentity` and `removeIdentity`, is authorised by a Semaphore membership proof against the approved-member group, bound to the exact update it carries. No address-based write path remains: nothing reads `msg.sender`, there is no owner or allowlist path into the identity mapping, and there is no emergency override. Fixing a bad row means a member submitting a corrective `updateIdentity` or `removeIdentity`, which is itself anonymous and auditable. Governance's only power over the registry is over group membership, and while the group is empty no write can succeed.
|
|
50
|
+
|
|
51
|
+
## Why revocation is one registry
|
|
52
|
+
|
|
53
|
+
The credential circuit hides which approved issuer signed a credential and outputs the root it proved non-membership against. A verifier then asks a registry whether that root is current, so picking which registry to ask would name the issuer. `CredentialRevocationRegistry.isValidRoot(root)` takes the root alone.
|
|
54
|
+
|
|
55
|
+
The same holds off chain: with per-issuer registries, which tree a holder syncs leaks its issuer to whoever serves the snapshot.
|
|
56
|
+
|
|
57
|
+
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
|
+
## 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`.
|
|
77
|
+
|
|
78
|
+
## The approved-member group
|
|
79
|
+
|
|
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:
|
|
81
|
+
|
|
82
|
+
- Governance, as the group admin, maintains the set on the Semaphore contract: `addMember`, `addMembers`, `removeMember`, `updateMember`.
|
|
83
|
+
- 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
|
+
- `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
|
+
|
|
86
|
+
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
|
+
|
|
88
|
+
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.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.34;
|
|
3
|
+
|
|
4
|
+
import { IEntryPoint } from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
|
|
5
|
+
import { BasePaymaster } from "@account-abstraction/contracts/core/BasePaymaster.sol";
|
|
6
|
+
import { IPaymaster } from "@account-abstraction/contracts/interfaces/IPaymaster.sol";
|
|
7
|
+
import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol";
|
|
8
|
+
import { _packValidationData } from "@account-abstraction/contracts/core/Helpers.sol";
|
|
9
|
+
import { UserOperationLib } from "@account-abstraction/contracts/core/UserOperationLib.sol";
|
|
10
|
+
import { Semaphore } from "@semaphore-protocol/contracts/Semaphore.sol";
|
|
11
|
+
import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
|
|
12
|
+
import { ISemaphoreVerifier } from "@semaphore-protocol/contracts/interfaces/ISemaphoreVerifier.sol";
|
|
13
|
+
import { MIN_DEPTH, MAX_DEPTH } from "@semaphore-protocol/contracts/base/Constants.sol";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @title SemaphorePaymaster
|
|
17
|
+
* @notice A paymaster that validates user operations in a privacy preserving way using a single semaphore group
|
|
18
|
+
*/
|
|
19
|
+
contract SemaphorePaymaster is BasePaymaster, Semaphore {
|
|
20
|
+
using UserOperationLib for PackedUserOperation;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The paymaster uses a single Semaphore group
|
|
24
|
+
*/
|
|
25
|
+
uint256 public immutable GROUP_ID;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `ISemaphore.SemaphoreProof` has a fixed length: five initial fields & exactly eight points
|
|
29
|
+
*/
|
|
30
|
+
uint256 private constant ENCODED_PROOF_LENGTH = 13 * 32;
|
|
31
|
+
|
|
32
|
+
/**
|
|
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
|
+
* `preVerificationGas` is self declared and never refunded, so `maxCost` can be used to drain the paymaster
|
|
42
|
+
*/
|
|
43
|
+
uint256 public immutable MAX_COST_PER_OP;
|
|
44
|
+
|
|
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
|
+
error ZeroOwner();
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
address _entryPoint,
|
|
63
|
+
address _verifier,
|
|
64
|
+
address _owner,
|
|
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;
|
|
71
|
+
MAX_COST_PER_OP = _maxCostPerOp;
|
|
72
|
+
currentEpoch = block.timestamp / _epochDuration;
|
|
73
|
+
|
|
74
|
+
// _transferOwnership skips zero-address guard
|
|
75
|
+
if (_owner == address(0)) revert ZeroOwner();
|
|
76
|
+
|
|
77
|
+
// BasePaymaster calls Ownable(msg.sender) — we want to set our own owner.
|
|
78
|
+
_transferOwnership(_owner);
|
|
79
|
+
|
|
80
|
+
// same logic as Semaphore.sol function `function createGroup(address) external;`
|
|
81
|
+
GROUP_ID = groupCounter++;
|
|
82
|
+
_createGroup(GROUP_ID, _owner);
|
|
83
|
+
groups[GROUP_ID].merkleTreeDuration = 1 hours;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a user operation by verifying a Semaphore proof.
|
|
88
|
+
* @dev Call `verifier.verifyProof` directly to avoid banned `block.timestamp` opcode
|
|
89
|
+
* @param userOp - The user operation.
|
|
90
|
+
* @param // userOpHash - The hash of the user operation.
|
|
91
|
+
* @param maxCost - The maximum cost of the user operation.
|
|
92
|
+
*/
|
|
93
|
+
function _validatePaymasterUserOp(
|
|
94
|
+
PackedUserOperation calldata userOp,
|
|
95
|
+
bytes32,
|
|
96
|
+
/* userOpHash */
|
|
97
|
+
uint256 maxCost
|
|
98
|
+
)
|
|
99
|
+
internal
|
|
100
|
+
view
|
|
101
|
+
override
|
|
102
|
+
returns (bytes memory context, uint256 validationData)
|
|
103
|
+
{
|
|
104
|
+
if (maxCost > MAX_COST_PER_OP || spentInEpoch[currentEpoch] + maxCost > SPEND_CAP_PER_EPOCH) {
|
|
105
|
+
return ("", _packValidationData(true, 0, 0));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (userOp.unpackPostOpGasLimit() < MIN_POSTOP_GAS) {
|
|
109
|
+
return ("", _packValidationData(true, 0, 0));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
bytes calldata encodedProof = userOp.paymasterAndData[PAYMASTER_DATA_OFFSET:];
|
|
113
|
+
if (encodedProof.length != ENCODED_PROOF_LENGTH) {
|
|
114
|
+
return ("", _packValidationData(true, 0, 0));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ISemaphore.SemaphoreProof memory proof = abi.decode(encodedProof, (ISemaphore.SemaphoreProof));
|
|
118
|
+
|
|
119
|
+
// message & scope must be the sender address. message binds the proof to the account
|
|
120
|
+
// being sponsored. scope does the same, and ensures Semaphore nullifiers are unique per
|
|
121
|
+
// account, since nullifier = hash(scope, secret) — a stable scope would link
|
|
122
|
+
// multiple stealth accounts to a single semaphore identity.
|
|
123
|
+
uint256 expectedSender = uint256(uint160(userOp.sender));
|
|
124
|
+
if (proof.message != expectedSender || proof.scope != expectedSender) {
|
|
125
|
+
return ("", _packValidationData(true, 0, 0));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Validation will fail if the Merkle tree depth is not supported.
|
|
129
|
+
if (proof.merkleTreeDepth < MIN_DEPTH || proof.merkleTreeDepth > MAX_DEPTH) {
|
|
130
|
+
return ("", _packValidationData(true, 0, 0));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Gets the number of leaves in the Incremental Merkle Tree that represents the group
|
|
134
|
+
// with id groupId which is the same as the number of members in the group groupId.
|
|
135
|
+
uint256 merkleTreeSize = getMerkleTreeSize(GROUP_ID);
|
|
136
|
+
|
|
137
|
+
// Validation will fail if there are no members in the group.
|
|
138
|
+
if (merkleTreeSize == 0) {
|
|
139
|
+
return ("", _packValidationData(true, 0, 0));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A superseded root is valid until its `merkleTreeDuration` window ends, so this becomes
|
|
143
|
+
// userOp validUntil.
|
|
144
|
+
uint48 validUntil = 0;
|
|
145
|
+
|
|
146
|
+
// Gets the Merkle root of the Incremental Merkle Tree that represents the group with id groupId.
|
|
147
|
+
uint256 currentMerkleTreeRoot = getMerkleTreeRoot(GROUP_ID);
|
|
148
|
+
|
|
149
|
+
// A proof could have used an old Merkle tree root.
|
|
150
|
+
// https://github.com/semaphore-protocol/semaphore/issues/98
|
|
151
|
+
if (proof.merkleTreeRoot != currentMerkleTreeRoot) {
|
|
152
|
+
uint256 merkleRootCreationDate = groups[GROUP_ID].merkleRootCreationDates[proof.merkleTreeRoot];
|
|
153
|
+
uint256 merkleTreeDuration = groups[GROUP_ID].merkleTreeDuration;
|
|
154
|
+
|
|
155
|
+
if (merkleRootCreationDate == 0) {
|
|
156
|
+
return ("", _packValidationData(true, 0, 0));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// merkleRootCreationDate is block.timestamp. Overflow would require owner
|
|
160
|
+
// setting very high merkleTreeDuration
|
|
161
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
162
|
+
validUntil = uint48(merkleRootCreationDate + merkleTreeDuration);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Call `verifier.verifyProof` directly. `this.verifyProof` reads `block.timestamp` when the
|
|
166
|
+
// proof `merkleTreeRoot` does not match `currentMerkleTreeRoot`, which ERC-7562 bans.
|
|
167
|
+
bool verified = verifier.verifyProof(
|
|
168
|
+
[proof.points[0], proof.points[1]],
|
|
169
|
+
[[proof.points[2], proof.points[3]], [proof.points[4], proof.points[5]]],
|
|
170
|
+
[proof.points[6], proof.points[7]],
|
|
171
|
+
[proof.merkleTreeRoot, proof.nullifier, _hashToField(proof.message), _hashToField(proof.scope)],
|
|
172
|
+
proof.merkleTreeDepth
|
|
173
|
+
);
|
|
174
|
+
if (!verified) {
|
|
175
|
+
return ("", _packValidationData(true, 0, 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// a non empty context is what makes the EntryPoint call `postOp`
|
|
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;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Same logic as `Semaphore.sol`'s `_hash`, which is private so cannot be called here.
|
|
196
|
+
*/
|
|
197
|
+
function _hashToField(uint256 message) private pure returns (uint256) {
|
|
198
|
+
return uint256(keccak256(abi.encodePacked(message))) >> 8;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.34;
|
|
3
|
+
|
|
4
|
+
import { ISemaphore } from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol";
|
|
5
|
+
|
|
6
|
+
/// @title NetworkMemberGate
|
|
7
|
+
/// @notice Gates functions on an anonymous Semaphore membership proof from an approved network
|
|
8
|
+
/// member, bound to the call it authorises.
|
|
9
|
+
/// @dev A gated call learns that it came from some approved network member and nothing else. Nothing
|
|
10
|
+
/// here reads `msg.sender`, so the call can be submitted from a stealth address and relayed by a
|
|
11
|
+
/// paymaster.
|
|
12
|
+
///
|
|
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.
|
|
16
|
+
/// Everything about the proof lifecycle is likewise Semaphore's: membership, the recent-root window
|
|
17
|
+
/// (the group's Merkle tree duration) and the nullifier set that stops a proof being submitted
|
|
18
|
+
/// twice.
|
|
19
|
+
///
|
|
20
|
+
/// What the gate adds is the binding: the Semaphore message is not accepted from the caller at all,
|
|
21
|
+
/// it is derived from the gated call's own arguments, so a proof authorises exactly one call and a
|
|
22
|
+
/// proof repurposed for another payload simply fails verification.
|
|
23
|
+
abstract contract NetworkMemberGate {
|
|
24
|
+
/// @notice A membership proof authorising one call.
|
|
25
|
+
/// @dev Semaphore's proof without its message field. The message is the one public signal the
|
|
26
|
+
/// caller does not supply, because the gated function derives it.
|
|
27
|
+
struct NetworkMemberProof {
|
|
28
|
+
uint256 merkleTreeDepth;
|
|
29
|
+
uint256 merkleTreeRoot;
|
|
30
|
+
uint256 nullifier;
|
|
31
|
+
uint256 scope;
|
|
32
|
+
uint256[8] points;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @notice The Semaphore deployment holding the approved-member group.
|
|
36
|
+
ISemaphore public immutable SEMAPHORE;
|
|
37
|
+
|
|
38
|
+
/// @notice The approved-member group this contract gates on.
|
|
39
|
+
uint256 public immutable NETWORK_MEMBER_GROUP_ID;
|
|
40
|
+
|
|
41
|
+
/// @dev Gates a function on a membership proof over `message`. The message is derived from the
|
|
42
|
+
/// call's own arguments, so the expression passed here is evaluated before the proof is checked.
|
|
43
|
+
/// @param message The call the proof must authorise.
|
|
44
|
+
/// @param proof The network member's Semaphore proof.
|
|
45
|
+
modifier onlyNetworkMember(uint256 message, NetworkMemberProof calldata proof) {
|
|
46
|
+
_validateNetworkMemberProof(message, proof);
|
|
47
|
+
_;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// @param semaphore The Semaphore deployment holding the approved-member group.
|
|
51
|
+
/// @param networkMemberGroupId The group to gate on. It must already exist, and governance must
|
|
52
|
+
/// be its admin; creating it and maintaining its membership happen outside this contract.
|
|
53
|
+
constructor(ISemaphore semaphore, uint256 networkMemberGroupId) {
|
|
54
|
+
SEMAPHORE = semaphore;
|
|
55
|
+
NETWORK_MEMBER_GROUP_ID = networkMemberGroupId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// @dev Hands the proof to Semaphore with `message` as its message, which verifies membership
|
|
59
|
+
/// against a live root of the group and burns the nullifier so the proof cannot be submitted
|
|
60
|
+
/// again. A proof generated for a different call carries a different message and fails
|
|
61
|
+
/// verification here.
|
|
62
|
+
/// @param message The call the proof must authorise.
|
|
63
|
+
/// @param proof The network member's Semaphore proof.
|
|
64
|
+
function _validateNetworkMemberProof(uint256 message, NetworkMemberProof calldata proof) internal {
|
|
65
|
+
SEMAPHORE.validateProof(
|
|
66
|
+
NETWORK_MEMBER_GROUP_ID,
|
|
67
|
+
ISemaphore.SemaphoreProof({
|
|
68
|
+
merkleTreeDepth: proof.merkleTreeDepth,
|
|
69
|
+
merkleTreeRoot: proof.merkleTreeRoot,
|
|
70
|
+
nullifier: proof.nullifier,
|
|
71
|
+
message: message,
|
|
72
|
+
scope: proof.scope,
|
|
73
|
+
points: proof.points
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|