@coordination-network/network-member-client 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/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/index.cjs +975 -0
- package/dist/index.js +952 -0
- package/package.json +59 -0
- package/src/adapters.ts +486 -0
- package/src/deliver.ts +45 -0
- package/src/errors.ts +55 -0
- package/src/index.ts +53 -0
- package/src/issue.ts +29 -0
- package/src/sponsored-relay.ts +242 -0
- package/src/sponsored-revocation.ts +120 -0
- package/src/sponsored.ts +63 -0
- package/src/submit.ts +111 -0
- package/src/types.ts +128 -0
- package/src/user-operation.ts +162 -0
- package/src/witness.ts +30 -0
- package/types/index.d.ts +550 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { ApprovedIssuerSet } from '@coordination-network/issuer-set';
|
|
2
|
+
import { issueZkCredential, serializeZkCredential } from '@coordination-network/credential';
|
|
3
|
+
import { validateMetaStealthAddress, buildAnnouncement, generateStealthPrivateKey } from '@coordination-network/identity';
|
|
4
|
+
import { verifyRegistrationRequest } from '@coordination-network/user-client';
|
|
5
|
+
import { UpdateAction, generateSemaphoreProof, generateRevocationProof } from '@coordination-network/network-member-proof';
|
|
6
|
+
import { BaseError, ContractFunctionRevertedError, encodeFunctionData, encodeAbiParameters, decodeErrorResult, http, parseEventLogs } from 'viem';
|
|
7
|
+
import { Group } from '@semaphore-protocol/group';
|
|
8
|
+
import { entryPoint08Abi, getUserOperationHash, createBundlerClient, formatUserOperationRequest, toPackedUserOperation } from 'viem/account-abstraction';
|
|
9
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
10
|
+
import { generateProof } from '@semaphore-protocol/proof';
|
|
11
|
+
|
|
12
|
+
class NetworkMemberClientError extends Error {
|
|
13
|
+
constructor(code, message, cause) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "NetworkMemberClientError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
if (cause !== undefined) {
|
|
18
|
+
// Non-enumerable, matching the standard Error.cause: a provider error's circular
|
|
19
|
+
// transport graph would otherwise make JSON.stringify throw in a caller's handler.
|
|
20
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A named revert from IdentityRegistry or Semaphore, walked out of a viem contract-call error. */
|
|
25
|
+
class RegistryRevertError extends NetworkMemberClientError {
|
|
26
|
+
constructor(errorName, cause) {
|
|
27
|
+
super("REGISTRATION_REJECTED", errorName, cause);
|
|
28
|
+
this.name = "RegistryRevertError";
|
|
29
|
+
this.errorName = errorName;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A revert from CredentialRevocationRegistry, or from the revocation service or Semaphore behind
|
|
34
|
+
* it. `reason` is the decoded error name, or the raw revert data when nothing in the registry's
|
|
35
|
+
* ABI matches it: a caller retries `InvalidLowLeaf` against a re-synced mirror and gives up on
|
|
36
|
+
* `LeafAlreadyExists`, so it has to be able to tell them apart.
|
|
37
|
+
*/
|
|
38
|
+
class RevocationRevertError extends NetworkMemberClientError {
|
|
39
|
+
constructor(reason, cause) {
|
|
40
|
+
super("REVOCATION_REJECTED", reason, cause);
|
|
41
|
+
this.name = "RevocationRevertError";
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Rebuilds the approved-issuer set from its current on-chain leaves and proves membership against the fresh root. */
|
|
47
|
+
async function fetchIssuerMembershipWitness({ issuerSetReader, issuerPublicKey }) {
|
|
48
|
+
const issuerSet = await ApprovedIssuerSet.fromContract(issuerSetReader);
|
|
49
|
+
if (!issuerSet.isApproved(issuerPublicKey)) {
|
|
50
|
+
throw new NetworkMemberClientError("ISSUER_KEY_NOT_PUBLISHED", "issuer signing key is not published to the approved-issuer set");
|
|
51
|
+
}
|
|
52
|
+
return { root: issuerSet.root, witness: issuerSet.generateProof(issuerPublicKey) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Issues a UserApproval credential plus the issuer's fresh Merkle path into the
|
|
57
|
+
* approved-issuer set. The witness is fetched before any signing, so an issuer
|
|
58
|
+
* whose key was never published fails at issuance without spending a signature.
|
|
59
|
+
*/
|
|
60
|
+
async function issueVC(params, { signer, approval, issuerSetReader }) {
|
|
61
|
+
const { root, witness } = await fetchIssuerMembershipWitness({
|
|
62
|
+
issuerSetReader,
|
|
63
|
+
issuerPublicKey: await signer.getPublicKey()
|
|
64
|
+
});
|
|
65
|
+
const credential = await issueZkCredential(params, signer, approval);
|
|
66
|
+
return { credential: serializeZkCredential(credential), issuerSetRoot: root, issuerMembershipWitness: witness };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Delivers value to a network member's recipient: resolves their registered identity and announces
|
|
71
|
+
* a fresh stealth address for it. `ERC5564Announcer.announce` is permissionless, so the member's
|
|
72
|
+
* approval is checked here, first, before any other port runs.
|
|
73
|
+
*/
|
|
74
|
+
async function deliverToStealth({ memberNetworkId, recipientNetworkId }, { approval, identityReader, submitter }) {
|
|
75
|
+
if (!(await approval.isApprovedNetworkMember(memberNetworkId))) {
|
|
76
|
+
throw new NetworkMemberClientError("MEMBER_NOT_APPROVED", "network member is not approved");
|
|
77
|
+
}
|
|
78
|
+
const metaStealthAddress = await identityReader.getIdentity(recipientNetworkId);
|
|
79
|
+
if (metaStealthAddress === null) {
|
|
80
|
+
throw new NetworkMemberClientError("RECIPIENT_NOT_REGISTERED", "recipient has no registered identity");
|
|
81
|
+
}
|
|
82
|
+
if (!validateMetaStealthAddress(metaStealthAddress)) {
|
|
83
|
+
throw new NetworkMemberClientError("INVALID_META_STEALTH_ADDRESS", "recipient's registered meta-stealth address is malformed");
|
|
84
|
+
}
|
|
85
|
+
const announcement = buildAnnouncement({ metaStealthAddress });
|
|
86
|
+
const txHash = await submitter.announce(announcement);
|
|
87
|
+
return { stealthAddress: announcement.stealthAddress, announcement, txHash };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Throws when `port`'s bound registry doesn't match `domain`: a member-app wiring bug, not something a request can trigger. */
|
|
91
|
+
function assertRegistryMatches(port, registry, domain) {
|
|
92
|
+
if (registry.chainId !== domain.chainId || registry.address.toLowerCase() !== domain.address.toLowerCase()) {
|
|
93
|
+
throw new Error(`${port} targets registry ${registry.address} on chain ${registry.chainId}, but domain is ${domain.address} on chain ${domain.chainId}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Same guard as `assertRegistryMatches`, for a reader that only reports the address it reads.
|
|
97
|
+
* Optional on `IdentityReader`, so a no-op when the reader doesn't report one. */
|
|
98
|
+
function assertReaderMatches(registryAddress, domain) {
|
|
99
|
+
if (registryAddress !== undefined && registryAddress.toLowerCase() !== domain.address.toLowerCase()) {
|
|
100
|
+
throw new Error(`identityReader targets registry ${registryAddress}, but domain is ${domain.address} on chain ${domain.chainId}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Reads the current registry entry, mapping a transport failure to REGISTRY_UNAVAILABLE. */
|
|
104
|
+
async function readIdentity(identityReader, networkId) {
|
|
105
|
+
try {
|
|
106
|
+
return await identityReader.getIdentity(networkId);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new NetworkMemberClientError("REGISTRY_UNAVAILABLE", `identity read failed for ${networkId}`, error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Submits a user's signed registration consent to `IdentityRegistry`, as an approved network
|
|
114
|
+
* member. The ports' own registry binding is checked first, then consent, both before any port
|
|
115
|
+
* runs, so a misconfigured member app or a forged/mistargeted request never reaches the registry.
|
|
116
|
+
*/
|
|
117
|
+
async function submitRegistration(request, { domain, identityReader, prover, writer }) {
|
|
118
|
+
const registryDomain = { chainId: BigInt(domain.chainId), address: domain.verifyingContract };
|
|
119
|
+
assertRegistryMatches("prover", prover.registry, registryDomain);
|
|
120
|
+
assertRegistryMatches("writer", writer.registry, registryDomain);
|
|
121
|
+
assertReaderMatches(identityReader.registryAddress, registryDomain);
|
|
122
|
+
if (!verifyRegistrationRequest(request, domain)) {
|
|
123
|
+
throw new NetworkMemberClientError("INVALID_REGISTRATION_REQUEST", "registration request failed verification");
|
|
124
|
+
}
|
|
125
|
+
const { networkId, metaStealthAddress } = request;
|
|
126
|
+
const existing = await readIdentity(identityReader, networkId);
|
|
127
|
+
if (existing !== null) {
|
|
128
|
+
return {
|
|
129
|
+
status: "alreadyRegistered",
|
|
130
|
+
networkId,
|
|
131
|
+
metaStealthAddress: existing,
|
|
132
|
+
matchesRequest: existing.toLowerCase() === metaStealthAddress.toLowerCase()
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const proof = await prover.prove({ action: UpdateAction.Register, networkId, metaStealthAddress });
|
|
136
|
+
try {
|
|
137
|
+
const write = await writer.registerIdentity(networkId, metaStealthAddress, proof);
|
|
138
|
+
return { status: "submitted", networkId, metaStealthAddress, ...write };
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof RegistryRevertError) {
|
|
142
|
+
// Lost a race between the read above and this write: someone else registered this
|
|
143
|
+
// networkId in between. The re-read, not the revert's own payload, is the source of
|
|
144
|
+
// truth for what the caller should treat as the current entry.
|
|
145
|
+
if (error.errorName === "AlreadyRegistered") {
|
|
146
|
+
let raced;
|
|
147
|
+
try {
|
|
148
|
+
raced = await readIdentity(identityReader, networkId);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// The write's revert already confirmed the entry exists; a failed re-read
|
|
152
|
+
// doesn't change that, so surface the confirmed revert, not the read failure.
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
// The registry answered; the write's gas estimate saw pending state the read
|
|
156
|
+
// above did not. The revert is still the right thing to surface.
|
|
157
|
+
if (raced === null)
|
|
158
|
+
throw error;
|
|
159
|
+
return {
|
|
160
|
+
status: "alreadyRegistered",
|
|
161
|
+
networkId,
|
|
162
|
+
metaStealthAddress: raced,
|
|
163
|
+
matchesRequest: raced.toLowerCase() === metaStealthAddress.toLowerCase()
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
throw new NetworkMemberClientError("REGISTRY_UNAVAILABLE", `registration submission failed for ${networkId}`, error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Shared with `sponsored.ts`: encoding the same call and decoding the same reverts from two writers must not drift apart. */
|
|
173
|
+
const IDENTITY_REGISTRY_ABI = [
|
|
174
|
+
{
|
|
175
|
+
type: "function",
|
|
176
|
+
name: "identity",
|
|
177
|
+
stateMutability: "view",
|
|
178
|
+
inputs: [{ name: "networkId", type: "bytes32" }],
|
|
179
|
+
outputs: [{ type: "bytes" }]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
type: "function",
|
|
183
|
+
name: "registerIdentity",
|
|
184
|
+
stateMutability: "nonpayable",
|
|
185
|
+
inputs: [
|
|
186
|
+
{ name: "networkId", type: "bytes32" },
|
|
187
|
+
{ name: "metaStealthAddress", type: "bytes" },
|
|
188
|
+
{
|
|
189
|
+
name: "proof",
|
|
190
|
+
type: "tuple",
|
|
191
|
+
components: [
|
|
192
|
+
{ name: "merkleTreeDepth", type: "uint256" },
|
|
193
|
+
{ name: "merkleTreeRoot", type: "uint256" },
|
|
194
|
+
{ name: "nullifier", type: "uint256" },
|
|
195
|
+
{ name: "scope", type: "uint256" },
|
|
196
|
+
{ name: "points", type: "uint256[8]" }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
outputs: []
|
|
201
|
+
},
|
|
202
|
+
{ type: "error", name: "AlreadyRegistered", inputs: [{ name: "networkId", type: "bytes32" }] },
|
|
203
|
+
{ type: "error", name: "InvalidMetaStealthAddressLength", inputs: [] },
|
|
204
|
+
{ type: "error", name: "NotRegistered", inputs: [{ name: "networkId", type: "bytes32" }] },
|
|
205
|
+
{ type: "error", name: "Semaphore__GroupHasNoMembers", inputs: [] },
|
|
206
|
+
{ type: "error", name: "Semaphore__MerkleTreeDepthIsNotSupported", inputs: [] },
|
|
207
|
+
{ type: "error", name: "Semaphore__MerkleTreeRootIsExpired", inputs: [] },
|
|
208
|
+
{ type: "error", name: "Semaphore__MerkleTreeRootIsNotPartOfTheGroup", inputs: [] },
|
|
209
|
+
{ type: "error", name: "Semaphore__YouAreUsingTheSameNullifierTwice", inputs: [] },
|
|
210
|
+
{ type: "error", name: "Semaphore__InvalidProof", inputs: [] }
|
|
211
|
+
];
|
|
212
|
+
/** The four Semaphore group-membership events a mirror replays, in the order emission can produce them. */
|
|
213
|
+
const SEMAPHORE_GROUP_EVENTS = [
|
|
214
|
+
{
|
|
215
|
+
type: "event",
|
|
216
|
+
name: "MemberAdded",
|
|
217
|
+
inputs: [
|
|
218
|
+
{ name: "groupId", type: "uint256", indexed: true },
|
|
219
|
+
{ name: "index", type: "uint256", indexed: false },
|
|
220
|
+
{ name: "identityCommitment", type: "uint256", indexed: false },
|
|
221
|
+
{ name: "merkleTreeRoot", type: "uint256", indexed: false }
|
|
222
|
+
]
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
type: "event",
|
|
226
|
+
name: "MembersAdded",
|
|
227
|
+
inputs: [
|
|
228
|
+
{ name: "groupId", type: "uint256", indexed: true },
|
|
229
|
+
{ name: "startIndex", type: "uint256", indexed: false },
|
|
230
|
+
{ name: "identityCommitments", type: "uint256[]", indexed: false },
|
|
231
|
+
{ name: "merkleTreeRoot", type: "uint256", indexed: false }
|
|
232
|
+
]
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
type: "event",
|
|
236
|
+
name: "MemberUpdated",
|
|
237
|
+
inputs: [
|
|
238
|
+
{ name: "groupId", type: "uint256", indexed: true },
|
|
239
|
+
{ name: "index", type: "uint256", indexed: false },
|
|
240
|
+
{ name: "identityCommitment", type: "uint256", indexed: false },
|
|
241
|
+
{ name: "newIdentityCommitment", type: "uint256", indexed: false },
|
|
242
|
+
{ name: "merkleTreeRoot", type: "uint256", indexed: false }
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
type: "event",
|
|
247
|
+
name: "MemberRemoved",
|
|
248
|
+
inputs: [
|
|
249
|
+
{ name: "groupId", type: "uint256", indexed: true },
|
|
250
|
+
{ name: "index", type: "uint256", indexed: false },
|
|
251
|
+
{ name: "identityCommitment", type: "uint256", indexed: false },
|
|
252
|
+
{ name: "merkleTreeRoot", type: "uint256", indexed: false }
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
/** The `ISemaphoreGroups` slice a group mirror needs: the four membership events plus the live root. */
|
|
257
|
+
const SEMAPHORE_GROUPS_ABI = [
|
|
258
|
+
...SEMAPHORE_GROUP_EVENTS,
|
|
259
|
+
{
|
|
260
|
+
type: "function",
|
|
261
|
+
name: "getMerkleTreeRoot",
|
|
262
|
+
stateMutability: "view",
|
|
263
|
+
inputs: [{ name: "groupId", type: "uint256" }],
|
|
264
|
+
outputs: [{ type: "uint256" }]
|
|
265
|
+
}
|
|
266
|
+
];
|
|
267
|
+
/** Whether a replayed leaf array has no gaps: every index up to the highest one seen was set. */
|
|
268
|
+
function isDense(leaves) {
|
|
269
|
+
return leaves.every((leaf) => leaf !== undefined);
|
|
270
|
+
}
|
|
271
|
+
/** The revert's decoded error name, if `error` (or something it wraps) is a contract revert.
|
|
272
|
+
* Shared with `sponsored-revocation.ts`, whose direct writer walks the same viem error shape. */
|
|
273
|
+
function revertErrorName(error) {
|
|
274
|
+
if (!(error instanceof BaseError))
|
|
275
|
+
return undefined;
|
|
276
|
+
const revert = error.walk((e) => e instanceof ContractFunctionRevertedError);
|
|
277
|
+
return revert instanceof ContractFunctionRevertedError ? revert.data?.errorName : undefined;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Chain-backed IdentityReader over `IdentityRegistry`'s public `identity` mapping. Each read puts
|
|
281
|
+
* the recipient's networkId in `eth_call` calldata, visible to whoever serves the RPC.
|
|
282
|
+
*/
|
|
283
|
+
class IdentityRegistryClient {
|
|
284
|
+
constructor(registryAddress, client) {
|
|
285
|
+
this.registryAddress = registryAddress;
|
|
286
|
+
this.client = client;
|
|
287
|
+
}
|
|
288
|
+
async getIdentity(networkId) {
|
|
289
|
+
const metaStealthAddress = await this.client.readContract({
|
|
290
|
+
address: this.registryAddress,
|
|
291
|
+
abi: IDENTITY_REGISTRY_ABI,
|
|
292
|
+
functionName: "identity",
|
|
293
|
+
args: [networkId]
|
|
294
|
+
});
|
|
295
|
+
return metaStealthAddress === "0x" ? null : metaStealthAddress;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const APPROVED_ISSUER_SET_ABI = [
|
|
299
|
+
{
|
|
300
|
+
type: "function",
|
|
301
|
+
name: "getLeafCount",
|
|
302
|
+
stateMutability: "view",
|
|
303
|
+
inputs: [],
|
|
304
|
+
outputs: [{ type: "uint256" }]
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
type: "function",
|
|
308
|
+
name: "getLeaves",
|
|
309
|
+
stateMutability: "view",
|
|
310
|
+
inputs: [
|
|
311
|
+
{ name: "start", type: "uint256" },
|
|
312
|
+
{ name: "end", type: "uint256" }
|
|
313
|
+
],
|
|
314
|
+
outputs: [{ name: "leaves", type: "uint256[]" }]
|
|
315
|
+
}
|
|
316
|
+
];
|
|
317
|
+
/**
|
|
318
|
+
* Chain-backed ContractLeafReader over ApprovedIssuerSet's paging getters. Reads are pinned to
|
|
319
|
+
* the block observed at `getLeafCount`, so a multi-page rebuild can't mix chain states into a
|
|
320
|
+
* root that never existed; serve one rebuild at a time per instance.
|
|
321
|
+
*/
|
|
322
|
+
class ApprovedIssuerSetClient {
|
|
323
|
+
constructor(setAddress, client) {
|
|
324
|
+
this.setAddress = setAddress;
|
|
325
|
+
this.client = client;
|
|
326
|
+
}
|
|
327
|
+
async getLeafCount() {
|
|
328
|
+
this.blockNumber = await this.client.getBlockNumber();
|
|
329
|
+
return this.client.readContract({
|
|
330
|
+
address: this.setAddress,
|
|
331
|
+
abi: APPROVED_ISSUER_SET_ABI,
|
|
332
|
+
functionName: "getLeafCount",
|
|
333
|
+
blockNumber: this.blockNumber
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async getLeaves(start, end) {
|
|
337
|
+
const leaves = await this.client.readContract({
|
|
338
|
+
address: this.setAddress,
|
|
339
|
+
abi: APPROVED_ISSUER_SET_ABI,
|
|
340
|
+
functionName: "getLeaves",
|
|
341
|
+
args: [BigInt(start), BigInt(end)],
|
|
342
|
+
blockNumber: this.blockNumber
|
|
343
|
+
});
|
|
344
|
+
return [...leaves];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const ERC5564_ANNOUNCER_ABI = [
|
|
348
|
+
{
|
|
349
|
+
type: "function",
|
|
350
|
+
name: "announce",
|
|
351
|
+
stateMutability: "nonpayable",
|
|
352
|
+
inputs: [
|
|
353
|
+
{ name: "schemeId", type: "uint256" },
|
|
354
|
+
{ name: "stealthAddress", type: "address" },
|
|
355
|
+
{ name: "ephemeralPubKey", type: "bytes" },
|
|
356
|
+
{ name: "metadata", type: "bytes" }
|
|
357
|
+
],
|
|
358
|
+
outputs: []
|
|
359
|
+
}
|
|
360
|
+
];
|
|
361
|
+
/** Chain-backed AnnouncementSubmitter over `ERC5564Announcer.announce`. */
|
|
362
|
+
class ERC5564AnnouncerClient {
|
|
363
|
+
constructor(announcerAddress, walletClient) {
|
|
364
|
+
this.announcerAddress = announcerAddress;
|
|
365
|
+
this.walletClient = walletClient;
|
|
366
|
+
}
|
|
367
|
+
async announce(announcement) {
|
|
368
|
+
return this.walletClient.writeContract({
|
|
369
|
+
address: this.announcerAddress,
|
|
370
|
+
abi: ERC5564_ANNOUNCER_ABI,
|
|
371
|
+
functionName: "announce",
|
|
372
|
+
args: [
|
|
373
|
+
BigInt(announcement.schemeId),
|
|
374
|
+
announcement.stealthAddress,
|
|
375
|
+
announcement.ephemeralPublicKey,
|
|
376
|
+
announcement.metadata
|
|
377
|
+
]
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Chain-backed GroupMirror over Semaphore's own group events. Rebuilds the approved-member group
|
|
383
|
+
* by replaying every MemberAdded/MembersAdded/MemberUpdated/MemberRemoved event for `groupId` in
|
|
384
|
+
* emission order, pinned to one `getBlockNumber` so the replay and the root check below can't
|
|
385
|
+
* straddle two chain states. A removal zeroes its leaf rather than deleting it, since Semaphore
|
|
386
|
+
* never reuses an index, so the replay must carry the zero forward into the rebuilt tree.
|
|
387
|
+
*
|
|
388
|
+
* The rebuilt root is then checked against `getMerkleTreeRoot` at that same block: a mismatch
|
|
389
|
+
* means the replay is wrong, not that membership is false, and this is what makes that loud
|
|
390
|
+
* instead of silently producing a proof nothing will accept.
|
|
391
|
+
*/
|
|
392
|
+
class SemaphoreGroupClient {
|
|
393
|
+
constructor(semaphoreAddress, groupId, client, { fromBlock = 0n } = {}) {
|
|
394
|
+
this.semaphoreAddress = semaphoreAddress;
|
|
395
|
+
this.groupId = groupId;
|
|
396
|
+
this.client = client;
|
|
397
|
+
this.fromBlock = fromBlock;
|
|
398
|
+
}
|
|
399
|
+
async currentGroup() {
|
|
400
|
+
try {
|
|
401
|
+
// viem caches getBlockNumber for `pollingInterval`, so two calls made in quick
|
|
402
|
+
// succession — as a caller re-mirroring right after its own write — can otherwise
|
|
403
|
+
// return the same stale height and silently miss that write's own group event.
|
|
404
|
+
const toBlock = await this.client.getBlockNumber({ cacheTime: 0 });
|
|
405
|
+
const [leaves, onChainRoot] = await Promise.all([
|
|
406
|
+
this.replayLeaves(toBlock),
|
|
407
|
+
this.client.readContract({
|
|
408
|
+
address: this.semaphoreAddress,
|
|
409
|
+
abi: SEMAPHORE_GROUPS_ABI,
|
|
410
|
+
functionName: "getMerkleTreeRoot",
|
|
411
|
+
args: [this.groupId],
|
|
412
|
+
blockNumber: toBlock
|
|
413
|
+
})
|
|
414
|
+
]);
|
|
415
|
+
if (leaves.length === 0) {
|
|
416
|
+
throw new NetworkMemberClientError("GROUP_MIRROR_UNAVAILABLE", `no membership events for group ${this.groupId} at ${this.semaphoreAddress}; wrong group id, wrong Semaphore address, or fromBlock past the first admission`);
|
|
417
|
+
}
|
|
418
|
+
if (!isDense(leaves)) {
|
|
419
|
+
const missing = [];
|
|
420
|
+
let holeCount = 0;
|
|
421
|
+
for (let index = 0; index < leaves.length; index += 1) {
|
|
422
|
+
if (leaves[index] === undefined) {
|
|
423
|
+
holeCount += 1;
|
|
424
|
+
if (missing.length < 10)
|
|
425
|
+
missing.push(index);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
throw new NetworkMemberClientError("GROUP_MIRROR_UNAVAILABLE", `group ${this.groupId} replay is missing ${holeCount} leaf index(es), starting with ${missing.join(", ")}; fromBlock is likely later than the first admission`);
|
|
429
|
+
}
|
|
430
|
+
const group = new Group(leaves);
|
|
431
|
+
if (BigInt(group.root) !== onChainRoot) {
|
|
432
|
+
throw new NetworkMemberClientError("GROUP_MIRROR_UNAVAILABLE", `rebuilt group root does not match the chain's for group ${this.groupId}`);
|
|
433
|
+
}
|
|
434
|
+
return group;
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (error instanceof NetworkMemberClientError)
|
|
438
|
+
throw error;
|
|
439
|
+
throw new NetworkMemberClientError("GROUP_MIRROR_UNAVAILABLE", `failed to rebuild the approved-member group ${this.groupId}`, error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** Replays the four membership events into a dense, index-ordered leaf array. `undefined`
|
|
443
|
+
* marks an index no admission log ever set, so a hole can't silently pass as leaf `0n`. */
|
|
444
|
+
async replayLeaves(toBlock) {
|
|
445
|
+
// One topic-filtered eth_getLogs per event type, not one unfiltered call for all four:
|
|
446
|
+
// a shared Semaphore deployment hosts other groups too, and their history isn't ours to
|
|
447
|
+
// download on every mirror.
|
|
448
|
+
const logsByEvent = await Promise.all(SEMAPHORE_GROUP_EVENTS.map((event) => this.client.getLogs({
|
|
449
|
+
address: this.semaphoreAddress,
|
|
450
|
+
event,
|
|
451
|
+
args: { groupId: this.groupId },
|
|
452
|
+
fromBlock: this.fromBlock,
|
|
453
|
+
toBlock,
|
|
454
|
+
strict: true
|
|
455
|
+
})));
|
|
456
|
+
const logs = logsByEvent.flat().sort((a, b) => {
|
|
457
|
+
if (a.blockNumber !== b.blockNumber)
|
|
458
|
+
return a.blockNumber < b.blockNumber ? -1 : 1;
|
|
459
|
+
return a.logIndex - b.logIndex;
|
|
460
|
+
});
|
|
461
|
+
const byIndex = new Map();
|
|
462
|
+
let maxIndex = -1;
|
|
463
|
+
const set = (index, commitment) => {
|
|
464
|
+
byIndex.set(index, commitment);
|
|
465
|
+
if (index > maxIndex)
|
|
466
|
+
maxIndex = index;
|
|
467
|
+
};
|
|
468
|
+
for (const log of logs) {
|
|
469
|
+
switch (log.eventName) {
|
|
470
|
+
case "MemberAdded":
|
|
471
|
+
set(Number(log.args.index), log.args.identityCommitment);
|
|
472
|
+
break;
|
|
473
|
+
case "MembersAdded":
|
|
474
|
+
log.args.identityCommitments.forEach((commitment, offset) => {
|
|
475
|
+
set(Number(log.args.startIndex) + offset, commitment);
|
|
476
|
+
});
|
|
477
|
+
break;
|
|
478
|
+
case "MemberUpdated":
|
|
479
|
+
set(Number(log.args.index), log.args.newIdentityCommitment);
|
|
480
|
+
break;
|
|
481
|
+
case "MemberRemoved":
|
|
482
|
+
set(Number(log.args.index), 0n);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return Array.from({ length: maxIndex + 1 }, (_, index) => byIndex.get(index));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Chain-backed RegistrationProver: mirrors the approved-member group fresh for every call, then
|
|
491
|
+
* generates the Semaphore proof authorising one IdentityRegistry write.
|
|
492
|
+
*/
|
|
493
|
+
class SemaphoreRegistrationProver {
|
|
494
|
+
constructor({ identity, groupMirror, registry }) {
|
|
495
|
+
this.identity = identity;
|
|
496
|
+
this.groupMirror = groupMirror;
|
|
497
|
+
this.registry = registry;
|
|
498
|
+
}
|
|
499
|
+
async prove(update) {
|
|
500
|
+
const group = await this.groupMirror.currentGroup();
|
|
501
|
+
if (group.indexOf(this.identity.commitment) === -1) {
|
|
502
|
+
throw new NetworkMemberClientError("MEMBER_NOT_IN_GROUP", "identity is not a member of the approved-member group");
|
|
503
|
+
}
|
|
504
|
+
return generateSemaphoreProof({ identity: this.identity, group, registry: this.registry, update });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Chain-backed RevocationProver: mirrors the approved-member group fresh for every call, then
|
|
509
|
+
* generates the Semaphore proof authorising one write to the network's credential revocation
|
|
510
|
+
* registry. The same group as registration's, reached through the same mirror: the registry
|
|
511
|
+
* inherits `NetworkMemberGate`, so `SEMAPHORE()` and `NETWORK_MEMBER_GROUP_ID()` are public on it
|
|
512
|
+
* too and the mirror only needs a different address.
|
|
513
|
+
*/
|
|
514
|
+
class SemaphoreRevocationProver {
|
|
515
|
+
constructor({ identity, groupMirror, registry }) {
|
|
516
|
+
this.identity = identity;
|
|
517
|
+
this.groupMirror = groupMirror;
|
|
518
|
+
this.registry = registry;
|
|
519
|
+
}
|
|
520
|
+
async prove(update) {
|
|
521
|
+
const group = await this.groupMirror.currentGroup();
|
|
522
|
+
if (group.indexOf(this.identity.commitment) === -1) {
|
|
523
|
+
throw new NetworkMemberClientError("MEMBER_NOT_IN_GROUP", "identity is not a member of the approved-member group");
|
|
524
|
+
}
|
|
525
|
+
return generateRevocationProof({ identity: this.identity, group, registry: this.registry, update });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Chain-backed RegistrationWriter over `IdentityRegistry.registerIdentity`. No receipt wait:
|
|
530
|
+
* `AlreadyRegistered` surfaces at viem's gas estimate, and a returned txHash means submitted,
|
|
531
|
+
* matching `RegistrationSubmitter`'s contract in `@coordination-network/user-client`.
|
|
532
|
+
*/
|
|
533
|
+
class IdentityRegistryWriter {
|
|
534
|
+
constructor(registry, walletClient) {
|
|
535
|
+
this.registry = registry;
|
|
536
|
+
this.walletClient = walletClient;
|
|
537
|
+
}
|
|
538
|
+
async registerIdentity(networkId, metaStealthAddress, proof) {
|
|
539
|
+
try {
|
|
540
|
+
const txHash = await this.walletClient.writeContract({
|
|
541
|
+
address: this.registry.address,
|
|
542
|
+
abi: IDENTITY_REGISTRY_ABI,
|
|
543
|
+
functionName: "registerIdentity",
|
|
544
|
+
args: [networkId, metaStealthAddress, proof]
|
|
545
|
+
});
|
|
546
|
+
return { txHash };
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
const errorName = revertErrorName(error);
|
|
550
|
+
if (errorName !== undefined)
|
|
551
|
+
throw new RegistryRevertError(errorName, error);
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const SIMPLE_7702_ACCOUNT_ABI = [
|
|
558
|
+
{
|
|
559
|
+
type: "function",
|
|
560
|
+
name: "execute",
|
|
561
|
+
stateMutability: "nonpayable",
|
|
562
|
+
inputs: [
|
|
563
|
+
{ name: "target", type: "address" },
|
|
564
|
+
{ name: "value", type: "uint256" },
|
|
565
|
+
{ name: "data", type: "bytes" }
|
|
566
|
+
],
|
|
567
|
+
outputs: []
|
|
568
|
+
}
|
|
569
|
+
];
|
|
570
|
+
const PAYMASTER_ABI = [
|
|
571
|
+
{ type: "function", name: "getDeposit", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }
|
|
572
|
+
];
|
|
573
|
+
/** `ISemaphore.SemaphoreProof`'s own ABI shape: five scalar words plus eight proof points, matching `SemaphorePaymaster._validatePaymasterUserOp`'s decode. */
|
|
574
|
+
const PAYMASTER_PROOF_ABI_PARAMS = [
|
|
575
|
+
{ name: "merkleTreeDepth", type: "uint256" },
|
|
576
|
+
{ name: "merkleTreeRoot", type: "uint256" },
|
|
577
|
+
{ name: "nullifier", type: "uint256" },
|
|
578
|
+
{ name: "message", type: "uint256" },
|
|
579
|
+
{ name: "scope", type: "uint256" },
|
|
580
|
+
{ name: "points", type: "uint256[8]" }
|
|
581
|
+
];
|
|
582
|
+
/** Fixed at the contract's own minimums/limits; not worth exposing as constructor knobs for one call site. */
|
|
583
|
+
const GAS_LIMITS = {
|
|
584
|
+
callGasLimit: 700000n,
|
|
585
|
+
verificationGasLimit: 500000n,
|
|
586
|
+
preVerificationGas: 100000n,
|
|
587
|
+
paymasterVerificationGasLimit: 600000n,
|
|
588
|
+
paymasterPostOpGasLimit: 50000n
|
|
589
|
+
};
|
|
590
|
+
const GAS_FEE_PER_GAS = 1000000000n; // 1 gwei
|
|
591
|
+
/**
|
|
592
|
+
* Relays one call through a fresh, disposable stealth sender delegated to Simple7702Account and
|
|
593
|
+
* sponsored by SemaphorePaymaster, so the member pays nothing and no sender links two of the
|
|
594
|
+
* member's relayed calls to each other.
|
|
595
|
+
*
|
|
596
|
+
* A reverted operation comes back as a receipt, not an error: which reverts are worth retrying and
|
|
597
|
+
* which are final depends on the contract being called, so the caller owns that reading.
|
|
598
|
+
*/
|
|
599
|
+
class SponsoredRelay {
|
|
600
|
+
constructor(params) {
|
|
601
|
+
this.chainId = params.chainId;
|
|
602
|
+
this.entryPoint = params.entryPoint;
|
|
603
|
+
this.paymaster = params.paymaster;
|
|
604
|
+
this.accountImplementation = params.accountImplementation;
|
|
605
|
+
this.memberKeys = params.memberKeys;
|
|
606
|
+
this.paymasterIdentity = params.paymasterIdentity;
|
|
607
|
+
this.paymasterGroup = params.paymasterGroup;
|
|
608
|
+
this.submitter = params.submitter;
|
|
609
|
+
this.publicClient = params.publicClient;
|
|
610
|
+
}
|
|
611
|
+
async relay({ target, value = 0n, data }) {
|
|
612
|
+
const sender = this.deriveSender();
|
|
613
|
+
const [senderBalanceBefore, paymasterDepositBefore] = await Promise.all([
|
|
614
|
+
this.publicClient.getBalance({ address: sender.address }),
|
|
615
|
+
this.readPaymasterDeposit()
|
|
616
|
+
]);
|
|
617
|
+
const callData = encodeFunctionData({
|
|
618
|
+
abi: SIMPLE_7702_ACCOUNT_ABI,
|
|
619
|
+
functionName: "execute",
|
|
620
|
+
args: [target, value, data]
|
|
621
|
+
});
|
|
622
|
+
const paymasterData = await this.buildPaymasterProof(sender.address);
|
|
623
|
+
const [nonce, transactionCount] = await Promise.all([
|
|
624
|
+
this.publicClient.readContract({
|
|
625
|
+
address: this.entryPoint,
|
|
626
|
+
abi: entryPoint08Abi,
|
|
627
|
+
functionName: "getNonce",
|
|
628
|
+
args: [sender.address, 0n]
|
|
629
|
+
}),
|
|
630
|
+
this.publicClient.getTransactionCount({ address: sender.address })
|
|
631
|
+
]);
|
|
632
|
+
const authorization = await sender.signAuthorization({
|
|
633
|
+
address: this.accountImplementation,
|
|
634
|
+
chainId: this.chainId,
|
|
635
|
+
nonce: transactionCount
|
|
636
|
+
});
|
|
637
|
+
const unsignedUserOperation = {
|
|
638
|
+
sender: sender.address,
|
|
639
|
+
nonce,
|
|
640
|
+
factory: "0x7702",
|
|
641
|
+
callData,
|
|
642
|
+
...GAS_LIMITS,
|
|
643
|
+
maxFeePerGas: GAS_FEE_PER_GAS,
|
|
644
|
+
maxPriorityFeePerGas: GAS_FEE_PER_GAS,
|
|
645
|
+
paymaster: this.paymaster,
|
|
646
|
+
paymasterData,
|
|
647
|
+
authorization,
|
|
648
|
+
signature: "0x"
|
|
649
|
+
};
|
|
650
|
+
const userOpHash = getUserOperationHash({
|
|
651
|
+
chainId: this.chainId,
|
|
652
|
+
entryPointAddress: this.entryPoint,
|
|
653
|
+
entryPointVersion: "0.8",
|
|
654
|
+
userOperation: unsignedUserOperation
|
|
655
|
+
});
|
|
656
|
+
const signature = await sender.sign({ hash: userOpHash });
|
|
657
|
+
const userOperation = { ...unsignedUserOperation, signature };
|
|
658
|
+
const submittedHash = await this.submitter.send(userOperation);
|
|
659
|
+
// A bundler on another EntryPoint (or a bug in this one) would otherwise surface as
|
|
660
|
+
// waitForReceipt timing out on a hash nothing will ever mine.
|
|
661
|
+
if (submittedHash !== userOpHash) {
|
|
662
|
+
throw new Error(`bundler returned userOpHash ${submittedHash}, expected ${userOpHash}`);
|
|
663
|
+
}
|
|
664
|
+
const receipt = await this.submitter.waitForReceipt(userOpHash);
|
|
665
|
+
const [senderBalanceAfter, paymasterDepositAfter] = await Promise.all([
|
|
666
|
+
this.publicClient.getBalance({ address: sender.address }),
|
|
667
|
+
this.readPaymasterDeposit()
|
|
668
|
+
]);
|
|
669
|
+
return {
|
|
670
|
+
receipt,
|
|
671
|
+
sponsorship: {
|
|
672
|
+
sender: sender.address,
|
|
673
|
+
senderBalanceBefore,
|
|
674
|
+
senderBalanceAfter,
|
|
675
|
+
userOpHash,
|
|
676
|
+
bundleTxHash: receipt.transactionHash,
|
|
677
|
+
actualGasCost: receipt.actualGasCost,
|
|
678
|
+
paymaster: this.paymaster,
|
|
679
|
+
paymasterDepositBefore,
|
|
680
|
+
paymasterDepositAfter
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* A fresh, disposable stealth address derived from the member's own meta-address: used once
|
|
686
|
+
* to sign and pay (nothing, since it's sponsored) for this call, then dropped. No announcement
|
|
687
|
+
* is made, so it can't link two of the member's relayed calls to each other.
|
|
688
|
+
*/
|
|
689
|
+
deriveSender() {
|
|
690
|
+
const announcement = buildAnnouncement({ metaStealthAddress: this.memberKeys.metaStealthAddress });
|
|
691
|
+
const privateKey = generateStealthPrivateKey({
|
|
692
|
+
ephemeralPublicKey: announcement.ephemeralPublicKey,
|
|
693
|
+
viewingPrivateKey: this.memberKeys.viewingPrivateKey,
|
|
694
|
+
spendingPrivateKey: this.memberKeys.spendingPrivateKey
|
|
695
|
+
});
|
|
696
|
+
return privateKeyToAccount(privateKey);
|
|
697
|
+
}
|
|
698
|
+
readPaymasterDeposit() {
|
|
699
|
+
return this.publicClient.readContract({
|
|
700
|
+
address: this.paymaster,
|
|
701
|
+
abi: PAYMASTER_ABI,
|
|
702
|
+
functionName: "getDeposit"
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/** The paymaster's own proof: the member's Semaphore identity against its mirrored group, message and scope both the disposable sender (`SemaphorePaymaster`'s own binding). */
|
|
706
|
+
async buildPaymasterProof(sender) {
|
|
707
|
+
const group = await this.paymasterGroup.currentGroup();
|
|
708
|
+
const senderAsScalar = BigInt(sender);
|
|
709
|
+
const proof = await generateProof(this.paymasterIdentity, group, senderAsScalar, senderAsScalar);
|
|
710
|
+
return encodeAbiParameters(PAYMASTER_PROOF_ABI_PARAMS, [
|
|
711
|
+
BigInt(proof.merkleTreeDepth),
|
|
712
|
+
BigInt(proof.merkleTreeRoot),
|
|
713
|
+
BigInt(proof.nullifier),
|
|
714
|
+
BigInt(proof.message),
|
|
715
|
+
BigInt(proof.scope),
|
|
716
|
+
proof.points.map(BigInt)
|
|
717
|
+
]);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Sponsored RegistrationWriter: relays a `registerIdentity` call through {@link SponsoredRelay},
|
|
723
|
+
* so the member pays nothing and no sender links two of the member's relayed registrations to
|
|
724
|
+
* each other.
|
|
725
|
+
*/
|
|
726
|
+
class SponsoredIdentityRegistryWriter {
|
|
727
|
+
constructor({ registry, ...relayParams }) {
|
|
728
|
+
this.registry = registry;
|
|
729
|
+
this.relay = new SponsoredRelay(relayParams);
|
|
730
|
+
}
|
|
731
|
+
async registerIdentity(networkId, metaStealthAddress, proof) {
|
|
732
|
+
const { receipt, sponsorship } = await this.relay.relay({
|
|
733
|
+
target: this.registry.address,
|
|
734
|
+
data: encodeFunctionData({
|
|
735
|
+
abi: IDENTITY_REGISTRY_ABI,
|
|
736
|
+
functionName: "registerIdentity",
|
|
737
|
+
args: [networkId, metaStealthAddress, proof]
|
|
738
|
+
})
|
|
739
|
+
});
|
|
740
|
+
if (!receipt.success) {
|
|
741
|
+
if (receipt.revertReason !== undefined && decodedErrorName(receipt.revertReason) === "AlreadyRegistered") {
|
|
742
|
+
throw new RegistryRevertError("AlreadyRegistered", receipt.revertReason);
|
|
743
|
+
}
|
|
744
|
+
throw new Error(`sponsored registerIdentity user operation reverted for ${networkId}`);
|
|
745
|
+
}
|
|
746
|
+
return { txHash: receipt.transactionHash, sponsorship };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** The IdentityRegistry error name `revertReason` decodes to, or undefined for anything else (a different error, or no revert data at all). */
|
|
750
|
+
function decodedErrorName(revertReason) {
|
|
751
|
+
try {
|
|
752
|
+
return decodeErrorResult({ abi: IDENTITY_REGISTRY_ABI, data: revertReason }).errorName;
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The `CredentialRevocationRegistry` fragments a writer needs. The errors matter as much as the
|
|
761
|
+
* function: `InvalidLowLeaf` is the one a caller recomputes an index and retries on, and
|
|
762
|
+
* `LeafAlreadyExists` the one it stops on, so both writers must decode the same names.
|
|
763
|
+
*/
|
|
764
|
+
const CREDENTIAL_REVOCATION_REGISTRY_ABI = [
|
|
765
|
+
{
|
|
766
|
+
type: "function",
|
|
767
|
+
name: "revoke",
|
|
768
|
+
stateMutability: "nonpayable",
|
|
769
|
+
inputs: [
|
|
770
|
+
{ name: "revocationCommitment", type: "uint256" },
|
|
771
|
+
{ name: "lowLeafIndex", type: "uint256" },
|
|
772
|
+
{
|
|
773
|
+
name: "proof",
|
|
774
|
+
type: "tuple",
|
|
775
|
+
components: [
|
|
776
|
+
{ name: "merkleTreeDepth", type: "uint256" },
|
|
777
|
+
{ name: "merkleTreeRoot", type: "uint256" },
|
|
778
|
+
{ name: "nullifier", type: "uint256" },
|
|
779
|
+
{ name: "scope", type: "uint256" },
|
|
780
|
+
{ name: "points", type: "uint256[8]" }
|
|
781
|
+
]
|
|
782
|
+
}
|
|
783
|
+
],
|
|
784
|
+
outputs: []
|
|
785
|
+
},
|
|
786
|
+
{ type: "error", name: "InvalidLowLeaf", inputs: [] },
|
|
787
|
+
{ type: "error", name: "LeafAlreadyExists", inputs: [] },
|
|
788
|
+
{ type: "error", name: "LeafCannotBeZero", inputs: [] },
|
|
789
|
+
{ type: "error", name: "LeafGreaterThanSnarkScalarField", inputs: [] },
|
|
790
|
+
{ type: "error", name: "RegistryDoesNotExist", inputs: [] },
|
|
791
|
+
{ type: "error", name: "CallerIsNotTheRegistryAdmin", inputs: [] },
|
|
792
|
+
{ type: "error", name: "Semaphore__GroupHasNoMembers", inputs: [] },
|
|
793
|
+
{ type: "error", name: "Semaphore__MerkleTreeDepthIsNotSupported", inputs: [] },
|
|
794
|
+
{ type: "error", name: "Semaphore__MerkleTreeRootIsExpired", inputs: [] },
|
|
795
|
+
{ type: "error", name: "Semaphore__MerkleTreeRootIsNotPartOfTheGroup", inputs: [] },
|
|
796
|
+
{ type: "error", name: "Semaphore__YouAreUsingTheSameNullifierTwice", inputs: [] },
|
|
797
|
+
{ type: "error", name: "Semaphore__InvalidProof", inputs: [] }
|
|
798
|
+
];
|
|
799
|
+
/**
|
|
800
|
+
* Sponsored RevocationRegistryWriter: relays a `revoke` call through {@link SponsoredRelay}, so
|
|
801
|
+
* the write costs the member nothing and its sender names no member. That matters more here than for
|
|
802
|
+
* registration: one tree holds the whole network's revocations, and an EOA paying for the write
|
|
803
|
+
* would say which member revoked which credential.
|
|
804
|
+
*/
|
|
805
|
+
class SponsoredRevocationWriter {
|
|
806
|
+
constructor(registry, relay) {
|
|
807
|
+
this.registry = registry;
|
|
808
|
+
this.relay = relay;
|
|
809
|
+
}
|
|
810
|
+
async revoke(commitment, lowLeafIndex, proof) {
|
|
811
|
+
const { receipt, sponsorship } = await this.relay.relay({
|
|
812
|
+
target: this.registry.address,
|
|
813
|
+
data: encodeFunctionData({
|
|
814
|
+
abi: CREDENTIAL_REVOCATION_REGISTRY_ABI,
|
|
815
|
+
functionName: "revoke",
|
|
816
|
+
args: [commitment, lowLeafIndex, proof]
|
|
817
|
+
})
|
|
818
|
+
});
|
|
819
|
+
if (!receipt.success) {
|
|
820
|
+
throw new RevocationRevertError(decodedRevertReason(receipt.revertReason), receipt.revertReason);
|
|
821
|
+
}
|
|
822
|
+
return { txHash: receipt.transactionHash, sponsorship };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Chain-backed RevocationRegistryWriter over `CredentialRevocationRegistry.revoke`, for a member
|
|
827
|
+
* server running without an account-abstraction stack. The submitting EOA is on chain, so it says
|
|
828
|
+
* which member revoked; the sponsored writer above is what keeps that unattributable.
|
|
829
|
+
*
|
|
830
|
+
* No receipt wait: a reverting write surfaces at viem's gas estimate, and a returned txHash means
|
|
831
|
+
* submitted, matching `IdentityRegistryWriter`.
|
|
832
|
+
*/
|
|
833
|
+
class DirectRevocationWriter {
|
|
834
|
+
constructor(registry, walletClient) {
|
|
835
|
+
this.registry = registry;
|
|
836
|
+
this.walletClient = walletClient;
|
|
837
|
+
}
|
|
838
|
+
async revoke(commitment, lowLeafIndex, proof) {
|
|
839
|
+
try {
|
|
840
|
+
const txHash = await this.walletClient.writeContract({
|
|
841
|
+
address: this.registry.address,
|
|
842
|
+
abi: CREDENTIAL_REVOCATION_REGISTRY_ABI,
|
|
843
|
+
functionName: "revoke",
|
|
844
|
+
args: [commitment, lowLeafIndex, proof]
|
|
845
|
+
});
|
|
846
|
+
return { txHash };
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
const errorName = revertErrorName(error);
|
|
850
|
+
if (errorName !== undefined)
|
|
851
|
+
throw new RevocationRevertError(errorName, error);
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/** The registry error name `revertReason` decodes to, falling back to the raw data so a caller
|
|
857
|
+
* that can't match a name still has something to report. */
|
|
858
|
+
function decodedRevertReason(revertReason) {
|
|
859
|
+
if (revertReason === undefined)
|
|
860
|
+
return "reverted without a reason";
|
|
861
|
+
try {
|
|
862
|
+
return decodeErrorResult({ abi: CREDENTIAL_REVOCATION_REGISTRY_ABI, data: revertReason }).errorName;
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
return revertReason;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Decodes `UserOperationEvent`/`UserOperationRevertReason` for one userOpHash out of a mined transaction's logs. */
|
|
870
|
+
function parseUserOperationReceipt(transactionReceipt, entryPointAbi, userOpHash) {
|
|
871
|
+
const operationEvent = parseEventLogs({
|
|
872
|
+
abi: entryPointAbi,
|
|
873
|
+
eventName: "UserOperationEvent",
|
|
874
|
+
logs: transactionReceipt.logs
|
|
875
|
+
}).find((event) => event.args.userOpHash === userOpHash);
|
|
876
|
+
if (!operationEvent) {
|
|
877
|
+
throw new Error(`no UserOperationEvent for ${userOpHash} in transaction ${transactionReceipt.transactionHash}`);
|
|
878
|
+
}
|
|
879
|
+
const revertEvent = parseEventLogs({
|
|
880
|
+
abi: entryPointAbi,
|
|
881
|
+
eventName: "UserOperationRevertReason",
|
|
882
|
+
logs: transactionReceipt.logs
|
|
883
|
+
}).find((event) => event.args.userOpHash === userOpHash);
|
|
884
|
+
return {
|
|
885
|
+
transactionHash: transactionReceipt.transactionHash,
|
|
886
|
+
success: operationEvent.args.success,
|
|
887
|
+
actualGasCost: operationEvent.args.actualGasCost,
|
|
888
|
+
actualGasUsed: operationEvent.args.actualGasUsed,
|
|
889
|
+
revertReason: revertEvent?.args.revertReason
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
/** Submits to a bundler's JSON-RPC (`eth_sendUserOperation`/`eth_getUserOperationReceipt`), for the member server. */
|
|
893
|
+
class BundlerRpcSubmitter {
|
|
894
|
+
constructor(url, entryPoint) {
|
|
895
|
+
this.entryPoint = entryPoint;
|
|
896
|
+
this.client = createBundlerClient({ transport: http(url) });
|
|
897
|
+
}
|
|
898
|
+
async send(userOperation) {
|
|
899
|
+
return this.client.request({
|
|
900
|
+
method: "eth_sendUserOperation",
|
|
901
|
+
params: [formatUserOperationRequest(userOperation), this.entryPoint]
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async waitForReceipt(userOpHash) {
|
|
905
|
+
const receipt = await this.client.waitForUserOperationReceipt({ hash: userOpHash });
|
|
906
|
+
return parseUserOperationReceipt(receipt.receipt, entryPoint08Abi, userOpHash);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Submits straight to the EntryPoint's `handleOps`, for the stack bundler and the integration
|
|
911
|
+
* test: no mempool, one operation per call, the beneficiary is the submitting account itself.
|
|
912
|
+
*/
|
|
913
|
+
class EntryPointSubmitter {
|
|
914
|
+
constructor(client, entryPoint) {
|
|
915
|
+
this.client = client;
|
|
916
|
+
this.entryPoint = entryPoint;
|
|
917
|
+
this.transactionHashes = new Map();
|
|
918
|
+
}
|
|
919
|
+
async send(userOperation) {
|
|
920
|
+
const userOpHash = getUserOperationHash({
|
|
921
|
+
chainId: this.client.chain.id,
|
|
922
|
+
entryPointAddress: this.entryPoint,
|
|
923
|
+
entryPointVersion: "0.8",
|
|
924
|
+
userOperation
|
|
925
|
+
});
|
|
926
|
+
const data = encodeFunctionData({
|
|
927
|
+
abi: entryPoint08Abi,
|
|
928
|
+
functionName: "handleOps",
|
|
929
|
+
args: [[toPackedUserOperation(userOperation)], this.client.account.address]
|
|
930
|
+
});
|
|
931
|
+
const transactionHash = await this.client.sendTransaction({
|
|
932
|
+
account: this.client.account,
|
|
933
|
+
chain: this.client.chain,
|
|
934
|
+
to: this.entryPoint,
|
|
935
|
+
data,
|
|
936
|
+
authorizationList: [userOperation.authorization]
|
|
937
|
+
});
|
|
938
|
+
// waitForReceipt only gets the userOpHash back, so the mapping to the transaction that
|
|
939
|
+
// carried it has to live here.
|
|
940
|
+
this.transactionHashes.set(userOpHash, transactionHash);
|
|
941
|
+
return userOpHash;
|
|
942
|
+
}
|
|
943
|
+
async waitForReceipt(userOpHash) {
|
|
944
|
+
const transactionHash = this.transactionHashes.get(userOpHash);
|
|
945
|
+
if (transactionHash === undefined)
|
|
946
|
+
throw new Error(`no transaction sent for user operation ${userOpHash}`);
|
|
947
|
+
const transactionReceipt = await this.client.waitForTransactionReceipt({ hash: transactionHash });
|
|
948
|
+
return parseUserOperationReceipt(transactionReceipt, entryPoint08Abi, userOpHash);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export { ApprovedIssuerSetClient, BundlerRpcSubmitter, CREDENTIAL_REVOCATION_REGISTRY_ABI, DirectRevocationWriter, ERC5564AnnouncerClient, EntryPointSubmitter, IdentityRegistryClient, IdentityRegistryWriter, NetworkMemberClientError, RegistryRevertError, RevocationRevertError, SemaphoreGroupClient, SemaphoreRegistrationProver, SemaphoreRevocationProver, SponsoredIdentityRegistryWriter, SponsoredRelay, SponsoredRevocationWriter, deliverToStealth, fetchIssuerMembershipWitness, issueVC, parseUserOperationReceipt, submitRegistration };
|