@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/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ export type { NetworkMemberClientErrorCode } from "./errors"
2
+ export { NetworkMemberClientError, RegistryRevertError, RevocationRevertError } from "./errors"
3
+ export type {
4
+ Announcement,
5
+ AnnouncementSubmitter,
6
+ ApprovalResolver,
7
+ ChainDomain,
8
+ ContractLeafReader,
9
+ CredentialSigner,
10
+ DeliveredToStealth,
11
+ GroupMirror,
12
+ IdentityReader,
13
+ IssuedCredential,
14
+ IssuerMembershipWitness,
15
+ RegistrationProver,
16
+ RegistrationRequest,
17
+ RegistrationWrite,
18
+ RegistrationWriter,
19
+ RevocationProver,
20
+ RevocationRegistryWriter,
21
+ RevocationWrite,
22
+ Sponsorship,
23
+ SubmitRegistrationResult
24
+ } from "./types"
25
+ export type { FetchIssuerMembershipWitnessParams, IssuerMembershipWitnessResult } from "./witness"
26
+ export { fetchIssuerMembershipWitness } from "./witness"
27
+ export type { IssueVCPorts } from "./issue"
28
+ export { issueVC } from "./issue"
29
+ export type { DeliverToStealthParams, DeliverToStealthPorts } from "./deliver"
30
+ export { deliverToStealth } from "./deliver"
31
+ export type { SubmitRegistrationPorts } from "./submit"
32
+ export { submitRegistration } from "./submit"
33
+ export type { SemaphoreRegistrationProverParams, SemaphoreRevocationProverParams } from "./adapters"
34
+ export {
35
+ ApprovedIssuerSetClient,
36
+ ERC5564AnnouncerClient,
37
+ IdentityRegistryClient,
38
+ IdentityRegistryWriter,
39
+ SemaphoreGroupClient,
40
+ SemaphoreRegistrationProver,
41
+ SemaphoreRevocationProver
42
+ } from "./adapters"
43
+ export type { SponsoredIdentityRegistryWriterParams } from "./sponsored"
44
+ export { SponsoredIdentityRegistryWriter } from "./sponsored"
45
+ export type { RelayCall, RelayedCall, SponsoredMemberKeys, SponsoredRelayParams } from "./sponsored-relay"
46
+ export { SponsoredRelay } from "./sponsored-relay"
47
+ export {
48
+ CREDENTIAL_REVOCATION_REGISTRY_ABI,
49
+ DirectRevocationWriter,
50
+ SponsoredRevocationWriter
51
+ } from "./sponsored-revocation"
52
+ export type { SponsoredUserOperation, UserOperationReceipt, UserOperationSubmitter } from "./user-operation"
53
+ export { BundlerRpcSubmitter, EntryPointSubmitter, parseUserOperationReceipt } from "./user-operation"
package/src/issue.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { issueZkCredential, serializeZkCredential } from "@coordination-network/credential"
2
+ import type { ApprovalResolver, CredentialSigner, ZkCredentialIssueParams } from "@coordination-network/credential"
3
+ import type { ContractLeafReader } from "@coordination-network/issuer-set"
4
+ import type { IssuedCredential } from "./types"
5
+ import { fetchIssuerMembershipWitness } from "./witness"
6
+
7
+ export interface IssueVCPorts {
8
+ signer: CredentialSigner
9
+ approval: ApprovalResolver
10
+ issuerSetReader: ContractLeafReader
11
+ }
12
+
13
+ /**
14
+ * Issues a UserApproval credential plus the issuer's fresh Merkle path into the
15
+ * approved-issuer set. The witness is fetched before any signing, so an issuer
16
+ * whose key was never published fails at issuance without spending a signature.
17
+ */
18
+ export async function issueVC(
19
+ params: ZkCredentialIssueParams,
20
+ { signer, approval, issuerSetReader }: IssueVCPorts
21
+ ): Promise<IssuedCredential> {
22
+ const { root, witness } = await fetchIssuerMembershipWitness({
23
+ issuerSetReader,
24
+ issuerPublicKey: await signer.getPublicKey()
25
+ })
26
+ const credential = await issueZkCredential(params, signer, approval)
27
+
28
+ return { credential: serializeZkCredential(credential), issuerSetRoot: root, issuerMembershipWitness: witness }
29
+ }
@@ -0,0 +1,242 @@
1
+ import { encodeAbiParameters, encodeFunctionData } from "viem"
2
+ import type { Address, Hex, PublicClient } from "viem"
3
+ import { entryPoint08Abi, getUserOperationHash } from "viem/account-abstraction"
4
+ import { privateKeyToAccount } from "viem/accounts"
5
+ import { buildAnnouncement, generateStealthPrivateKey } from "@coordination-network/identity"
6
+ import { generateProof } from "@semaphore-protocol/proof"
7
+ import type { Identity } from "@semaphore-protocol/identity"
8
+ import type { GroupMirror, Sponsorship } from "./types"
9
+ import type { SponsoredUserOperation, UserOperationReceipt, UserOperationSubmitter } from "./user-operation"
10
+
11
+ const SIMPLE_7702_ACCOUNT_ABI = [
12
+ {
13
+ type: "function",
14
+ name: "execute",
15
+ stateMutability: "nonpayable",
16
+ inputs: [
17
+ { name: "target", type: "address" },
18
+ { name: "value", type: "uint256" },
19
+ { name: "data", type: "bytes" }
20
+ ],
21
+ outputs: []
22
+ }
23
+ ] as const
24
+
25
+ const PAYMASTER_ABI = [
26
+ { type: "function", name: "getDeposit", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }
27
+ ] as const
28
+
29
+ /** `ISemaphore.SemaphoreProof`'s own ABI shape: five scalar words plus eight proof points, matching `SemaphorePaymaster._validatePaymasterUserOp`'s decode. */
30
+ const PAYMASTER_PROOF_ABI_PARAMS = [
31
+ { name: "merkleTreeDepth", type: "uint256" },
32
+ { name: "merkleTreeRoot", type: "uint256" },
33
+ { name: "nullifier", type: "uint256" },
34
+ { name: "message", type: "uint256" },
35
+ { name: "scope", type: "uint256" },
36
+ { name: "points", type: "uint256[8]" }
37
+ ] as const
38
+
39
+ /** Fixed at the contract's own minimums/limits; not worth exposing as constructor knobs for one call site. */
40
+ const GAS_LIMITS = {
41
+ callGasLimit: 700_000n,
42
+ verificationGasLimit: 500_000n,
43
+ preVerificationGas: 100_000n,
44
+ paymasterVerificationGasLimit: 600_000n,
45
+ paymasterPostOpGasLimit: 50_000n
46
+ } as const
47
+ const GAS_FEE_PER_GAS = 1_000_000_000n // 1 gwei
48
+
49
+ export interface SponsoredMemberKeys {
50
+ /** The member's own long-term meta-stealth address; each call derives a fresh, disposable sender from it. */
51
+ metaStealthAddress: Hex
52
+ spendingPrivateKey: Hex
53
+ viewingPrivateKey: Hex
54
+ }
55
+
56
+ export interface SponsoredRelayParams {
57
+ chainId: number
58
+ entryPoint: Address
59
+ paymaster: Address
60
+ accountImplementation: Address
61
+ memberKeys: SponsoredMemberKeys
62
+ /** The member's own Semaphore identity; must be a leaf of `paymasterGroup`. */
63
+ paymasterIdentity: Identity
64
+ /** The paymaster's own mirrored Semaphore group (its admitted members, not the network's). */
65
+ paymasterGroup: GroupMirror
66
+ submitter: UserOperationSubmitter
67
+ publicClient: Pick<PublicClient, "getBalance" | "getTransactionCount" | "readContract">
68
+ }
69
+
70
+ /** One relayed call's outcome: whatever the EntryPoint reported, plus what the sponsorship cost. */
71
+ export interface RelayedCall {
72
+ receipt: UserOperationReceipt
73
+ sponsorship: Sponsorship
74
+ }
75
+
76
+ /** The call to relay. `value` defaults to zero, which is every call this track makes today. */
77
+ export interface RelayCall {
78
+ target: Address
79
+ value?: bigint
80
+ data: Hex
81
+ }
82
+
83
+ /**
84
+ * Relays one call through a fresh, disposable stealth sender delegated to Simple7702Account and
85
+ * sponsored by SemaphorePaymaster, so the member pays nothing and no sender links two of the
86
+ * member's relayed calls to each other.
87
+ *
88
+ * A reverted operation comes back as a receipt, not an error: which reverts are worth retrying and
89
+ * which are final depends on the contract being called, so the caller owns that reading.
90
+ */
91
+ export class SponsoredRelay {
92
+ private readonly chainId: number
93
+ private readonly entryPoint: Address
94
+ private readonly paymaster: Address
95
+ private readonly accountImplementation: Address
96
+ private readonly memberKeys: SponsoredMemberKeys
97
+ private readonly paymasterIdentity: Identity
98
+ private readonly paymasterGroup: GroupMirror
99
+ private readonly submitter: UserOperationSubmitter
100
+ private readonly publicClient: Pick<PublicClient, "getBalance" | "getTransactionCount" | "readContract">
101
+
102
+ constructor(params: SponsoredRelayParams) {
103
+ this.chainId = params.chainId
104
+ this.entryPoint = params.entryPoint
105
+ this.paymaster = params.paymaster
106
+ this.accountImplementation = params.accountImplementation
107
+ this.memberKeys = params.memberKeys
108
+ this.paymasterIdentity = params.paymasterIdentity
109
+ this.paymasterGroup = params.paymasterGroup
110
+ this.submitter = params.submitter
111
+ this.publicClient = params.publicClient
112
+ }
113
+
114
+ async relay({ target, value = 0n, data }: RelayCall): Promise<RelayedCall> {
115
+ const sender = this.deriveSender()
116
+
117
+ const [senderBalanceBefore, paymasterDepositBefore] = await Promise.all([
118
+ this.publicClient.getBalance({ address: sender.address }),
119
+ this.readPaymasterDeposit()
120
+ ])
121
+
122
+ const callData = encodeFunctionData({
123
+ abi: SIMPLE_7702_ACCOUNT_ABI,
124
+ functionName: "execute",
125
+ args: [target, value, data]
126
+ })
127
+ const paymasterData = await this.buildPaymasterProof(sender.address)
128
+
129
+ const [nonce, transactionCount] = await Promise.all([
130
+ this.publicClient.readContract({
131
+ address: this.entryPoint,
132
+ abi: entryPoint08Abi,
133
+ functionName: "getNonce",
134
+ args: [sender.address, 0n]
135
+ }),
136
+ this.publicClient.getTransactionCount({ address: sender.address })
137
+ ])
138
+ const authorization = await sender.signAuthorization({
139
+ address: this.accountImplementation,
140
+ chainId: this.chainId,
141
+ nonce: transactionCount
142
+ })
143
+
144
+ const unsignedUserOperation: SponsoredUserOperation = {
145
+ sender: sender.address,
146
+ nonce,
147
+ factory: "0x7702",
148
+ callData,
149
+ ...GAS_LIMITS,
150
+ maxFeePerGas: GAS_FEE_PER_GAS,
151
+ maxPriorityFeePerGas: GAS_FEE_PER_GAS,
152
+ paymaster: this.paymaster,
153
+ paymasterData,
154
+ authorization,
155
+ signature: "0x"
156
+ }
157
+ const userOpHash = getUserOperationHash({
158
+ chainId: this.chainId,
159
+ entryPointAddress: this.entryPoint,
160
+ entryPointVersion: "0.8",
161
+ userOperation: unsignedUserOperation
162
+ })
163
+ const signature = await sender.sign({ hash: userOpHash })
164
+ const userOperation: SponsoredUserOperation = { ...unsignedUserOperation, signature }
165
+
166
+ const submittedHash = await this.submitter.send(userOperation)
167
+ // A bundler on another EntryPoint (or a bug in this one) would otherwise surface as
168
+ // waitForReceipt timing out on a hash nothing will ever mine.
169
+ if (submittedHash !== userOpHash) {
170
+ throw new Error(`bundler returned userOpHash ${submittedHash}, expected ${userOpHash}`)
171
+ }
172
+ const receipt = await this.submitter.waitForReceipt(userOpHash)
173
+
174
+ const [senderBalanceAfter, paymasterDepositAfter] = await Promise.all([
175
+ this.publicClient.getBalance({ address: sender.address }),
176
+ this.readPaymasterDeposit()
177
+ ])
178
+
179
+ return {
180
+ receipt,
181
+ sponsorship: {
182
+ sender: sender.address,
183
+ senderBalanceBefore,
184
+ senderBalanceAfter,
185
+ userOpHash,
186
+ bundleTxHash: receipt.transactionHash,
187
+ actualGasCost: receipt.actualGasCost,
188
+ paymaster: this.paymaster,
189
+ paymasterDepositBefore,
190
+ paymasterDepositAfter
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * A fresh, disposable stealth address derived from the member's own meta-address: used once
197
+ * to sign and pay (nothing, since it's sponsored) for this call, then dropped. No announcement
198
+ * is made, so it can't link two of the member's relayed calls to each other.
199
+ */
200
+ private deriveSender() {
201
+ const announcement = buildAnnouncement({ metaStealthAddress: this.memberKeys.metaStealthAddress })
202
+ const privateKey = generateStealthPrivateKey({
203
+ ephemeralPublicKey: announcement.ephemeralPublicKey,
204
+ viewingPrivateKey: this.memberKeys.viewingPrivateKey,
205
+ spendingPrivateKey: this.memberKeys.spendingPrivateKey
206
+ })
207
+ return privateKeyToAccount(privateKey)
208
+ }
209
+
210
+ private readPaymasterDeposit(): Promise<bigint> {
211
+ return this.publicClient.readContract({
212
+ address: this.paymaster,
213
+ abi: PAYMASTER_ABI,
214
+ functionName: "getDeposit"
215
+ })
216
+ }
217
+
218
+ /** 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). */
219
+ private async buildPaymasterProof(sender: Address): Promise<Hex> {
220
+ const group = await this.paymasterGroup.currentGroup()
221
+ const senderAsScalar = BigInt(sender)
222
+ const proof = await generateProof(this.paymasterIdentity, group, senderAsScalar, senderAsScalar)
223
+
224
+ return encodeAbiParameters(PAYMASTER_PROOF_ABI_PARAMS, [
225
+ BigInt(proof.merkleTreeDepth),
226
+ BigInt(proof.merkleTreeRoot),
227
+ BigInt(proof.nullifier),
228
+ BigInt(proof.message),
229
+ BigInt(proof.scope),
230
+ proof.points.map(BigInt) as unknown as readonly [
231
+ bigint,
232
+ bigint,
233
+ bigint,
234
+ bigint,
235
+ bigint,
236
+ bigint,
237
+ bigint,
238
+ bigint
239
+ ]
240
+ ])
241
+ }
242
+ }
@@ -0,0 +1,120 @@
1
+ import { decodeErrorResult, encodeFunctionData } from "viem"
2
+ import type { Account, Chain, Hex, Transport, WalletClient } from "viem"
3
+ import type { NetworkMemberProof, RegistryLocation } from "@coordination-network/network-member-proof"
4
+ import { revertErrorName } from "./adapters"
5
+ import { RevocationRevertError } from "./errors"
6
+ import { SponsoredRelay } from "./sponsored-relay"
7
+ import type { RevocationRegistryWriter, RevocationWrite } from "./types"
8
+
9
+ /**
10
+ * The `CredentialRevocationRegistry` fragments a writer needs. The errors matter as much as the
11
+ * function: `InvalidLowLeaf` is the one a caller recomputes an index and retries on, and
12
+ * `LeafAlreadyExists` the one it stops on, so both writers must decode the same names.
13
+ */
14
+ export const CREDENTIAL_REVOCATION_REGISTRY_ABI = [
15
+ {
16
+ type: "function",
17
+ name: "revoke",
18
+ stateMutability: "nonpayable",
19
+ inputs: [
20
+ { name: "revocationCommitment", type: "uint256" },
21
+ { name: "lowLeafIndex", type: "uint256" },
22
+ {
23
+ name: "proof",
24
+ type: "tuple",
25
+ components: [
26
+ { name: "merkleTreeDepth", type: "uint256" },
27
+ { name: "merkleTreeRoot", type: "uint256" },
28
+ { name: "nullifier", type: "uint256" },
29
+ { name: "scope", type: "uint256" },
30
+ { name: "points", type: "uint256[8]" }
31
+ ]
32
+ }
33
+ ],
34
+ outputs: []
35
+ },
36
+ { type: "error", name: "InvalidLowLeaf", inputs: [] },
37
+ { type: "error", name: "LeafAlreadyExists", inputs: [] },
38
+ { type: "error", name: "LeafCannotBeZero", inputs: [] },
39
+ { type: "error", name: "LeafGreaterThanSnarkScalarField", inputs: [] },
40
+ { type: "error", name: "RegistryDoesNotExist", inputs: [] },
41
+ { type: "error", name: "CallerIsNotTheRegistryAdmin", inputs: [] },
42
+ { type: "error", name: "Semaphore__GroupHasNoMembers", inputs: [] },
43
+ { type: "error", name: "Semaphore__MerkleTreeDepthIsNotSupported", inputs: [] },
44
+ { type: "error", name: "Semaphore__MerkleTreeRootIsExpired", inputs: [] },
45
+ { type: "error", name: "Semaphore__MerkleTreeRootIsNotPartOfTheGroup", inputs: [] },
46
+ { type: "error", name: "Semaphore__YouAreUsingTheSameNullifierTwice", inputs: [] },
47
+ { type: "error", name: "Semaphore__InvalidProof", inputs: [] }
48
+ ] as const
49
+
50
+ /**
51
+ * Sponsored RevocationRegistryWriter: relays a `revoke` call through {@link SponsoredRelay}, so
52
+ * the write costs the member nothing and its sender names no member. That matters more here than for
53
+ * registration: one tree holds the whole network's revocations, and an EOA paying for the write
54
+ * would say which member revoked which credential.
55
+ */
56
+ export class SponsoredRevocationWriter implements RevocationRegistryWriter {
57
+ constructor(
58
+ readonly registry: RegistryLocation,
59
+ private readonly relay: SponsoredRelay
60
+ ) {}
61
+
62
+ async revoke(commitment: bigint, lowLeafIndex: bigint, proof: NetworkMemberProof): Promise<RevocationWrite> {
63
+ const { receipt, sponsorship } = await this.relay.relay({
64
+ target: this.registry.address,
65
+ data: encodeFunctionData({
66
+ abi: CREDENTIAL_REVOCATION_REGISTRY_ABI,
67
+ functionName: "revoke",
68
+ args: [commitment, lowLeafIndex, proof]
69
+ })
70
+ })
71
+
72
+ if (!receipt.success) {
73
+ throw new RevocationRevertError(decodedRevertReason(receipt.revertReason), receipt.revertReason)
74
+ }
75
+
76
+ return { txHash: receipt.transactionHash, sponsorship }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Chain-backed RevocationRegistryWriter over `CredentialRevocationRegistry.revoke`, for a member
82
+ * server running without an account-abstraction stack. The submitting EOA is on chain, so it says
83
+ * which member revoked; the sponsored writer above is what keeps that unattributable.
84
+ *
85
+ * No receipt wait: a reverting write surfaces at viem's gas estimate, and a returned txHash means
86
+ * submitted, matching `IdentityRegistryWriter`.
87
+ */
88
+ export class DirectRevocationWriter implements RevocationRegistryWriter {
89
+ constructor(
90
+ readonly registry: RegistryLocation,
91
+ private readonly walletClient: Pick<WalletClient<Transport, Chain, Account>, "writeContract">
92
+ ) {}
93
+
94
+ async revoke(commitment: bigint, lowLeafIndex: bigint, proof: NetworkMemberProof): Promise<RevocationWrite> {
95
+ try {
96
+ const txHash = await this.walletClient.writeContract({
97
+ address: this.registry.address,
98
+ abi: CREDENTIAL_REVOCATION_REGISTRY_ABI,
99
+ functionName: "revoke",
100
+ args: [commitment, lowLeafIndex, proof]
101
+ })
102
+ return { txHash }
103
+ } catch (error) {
104
+ const errorName = revertErrorName(error)
105
+ if (errorName !== undefined) throw new RevocationRevertError(errorName, error)
106
+ throw error
107
+ }
108
+ }
109
+ }
110
+
111
+ /** The registry error name `revertReason` decodes to, falling back to the raw data so a caller
112
+ * that can't match a name still has something to report. */
113
+ function decodedRevertReason(revertReason: Hex | undefined): string {
114
+ if (revertReason === undefined) return "reverted without a reason"
115
+ try {
116
+ return decodeErrorResult({ abi: CREDENTIAL_REVOCATION_REGISTRY_ABI, data: revertReason }).errorName
117
+ } catch {
118
+ return revertReason
119
+ }
120
+ }
@@ -0,0 +1,63 @@
1
+ import { decodeErrorResult, encodeFunctionData } from "viem"
2
+ import type { Hex } from "viem"
3
+ import type { Hex32 } from "@coordination-network/credential"
4
+ import type { NetworkMemberProof, RegistryLocation } from "@coordination-network/network-member-proof"
5
+ import { IDENTITY_REGISTRY_ABI } from "./adapters"
6
+ import { RegistryRevertError } from "./errors"
7
+ import { SponsoredRelay } from "./sponsored-relay"
8
+ import type { SponsoredRelayParams } from "./sponsored-relay"
9
+ import type { RegistrationWrite, RegistrationWriter } from "./types"
10
+
11
+ export type { SponsoredMemberKeys } from "./sponsored-relay"
12
+
13
+ export interface SponsoredIdentityRegistryWriterParams extends SponsoredRelayParams {
14
+ registry: RegistryLocation
15
+ }
16
+
17
+ /**
18
+ * Sponsored RegistrationWriter: relays a `registerIdentity` call through {@link SponsoredRelay},
19
+ * so the member pays nothing and no sender links two of the member's relayed registrations to
20
+ * each other.
21
+ */
22
+ export class SponsoredIdentityRegistryWriter implements RegistrationWriter {
23
+ readonly registry: RegistryLocation
24
+ private readonly relay: SponsoredRelay
25
+
26
+ constructor({ registry, ...relayParams }: SponsoredIdentityRegistryWriterParams) {
27
+ this.registry = registry
28
+ this.relay = new SponsoredRelay(relayParams)
29
+ }
30
+
31
+ async registerIdentity(
32
+ networkId: Hex32,
33
+ metaStealthAddress: Hex,
34
+ proof: NetworkMemberProof
35
+ ): Promise<RegistrationWrite> {
36
+ const { receipt, sponsorship } = await this.relay.relay({
37
+ target: this.registry.address,
38
+ data: encodeFunctionData({
39
+ abi: IDENTITY_REGISTRY_ABI,
40
+ functionName: "registerIdentity",
41
+ args: [networkId, metaStealthAddress, proof]
42
+ })
43
+ })
44
+
45
+ if (!receipt.success) {
46
+ if (receipt.revertReason !== undefined && decodedErrorName(receipt.revertReason) === "AlreadyRegistered") {
47
+ throw new RegistryRevertError("AlreadyRegistered", receipt.revertReason)
48
+ }
49
+ throw new Error(`sponsored registerIdentity user operation reverted for ${networkId}`)
50
+ }
51
+
52
+ return { txHash: receipt.transactionHash, sponsorship }
53
+ }
54
+ }
55
+
56
+ /** The IdentityRegistry error name `revertReason` decodes to, or undefined for anything else (a different error, or no revert data at all). */
57
+ function decodedErrorName(revertReason: Hex): string | undefined {
58
+ try {
59
+ return decodeErrorResult({ abi: IDENTITY_REGISTRY_ABI, data: revertReason }).errorName
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
package/src/submit.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { verifyRegistrationRequest } from "@coordination-network/user-client"
2
+ import type { ChainDomain, RegistrationRequest } from "@coordination-network/user-client"
3
+ import { UpdateAction } from "@coordination-network/network-member-proof"
4
+ import type { RegistryLocation } from "@coordination-network/network-member-proof"
5
+ import type { Address, Hex } from "@coordination-network/identity"
6
+ import { NetworkMemberClientError, RegistryRevertError } from "./errors"
7
+ import type { IdentityReader, RegistrationProver, RegistrationWriter, SubmitRegistrationResult } from "./types"
8
+
9
+ export interface SubmitRegistrationPorts {
10
+ domain: ChainDomain
11
+ identityReader: IdentityReader
12
+ prover: RegistrationProver
13
+ writer: RegistrationWriter
14
+ }
15
+
16
+ /** Throws when `port`'s bound registry doesn't match `domain`: a member-app wiring bug, not something a request can trigger. */
17
+ function assertRegistryMatches(port: "prover" | "writer", registry: RegistryLocation, domain: RegistryLocation): void {
18
+ if (registry.chainId !== domain.chainId || registry.address.toLowerCase() !== domain.address.toLowerCase()) {
19
+ throw new Error(
20
+ `${port} targets registry ${registry.address} on chain ${registry.chainId}, but domain is ${domain.address} on chain ${domain.chainId}`
21
+ )
22
+ }
23
+ }
24
+
25
+ /** Same guard as `assertRegistryMatches`, for a reader that only reports the address it reads.
26
+ * Optional on `IdentityReader`, so a no-op when the reader doesn't report one. */
27
+ function assertReaderMatches(registryAddress: Address | undefined, domain: RegistryLocation): void {
28
+ if (registryAddress !== undefined && registryAddress.toLowerCase() !== domain.address.toLowerCase()) {
29
+ throw new Error(
30
+ `identityReader targets registry ${registryAddress}, but domain is ${domain.address} on chain ${domain.chainId}`
31
+ )
32
+ }
33
+ }
34
+
35
+ /** Reads the current registry entry, mapping a transport failure to REGISTRY_UNAVAILABLE. */
36
+ async function readIdentity(identityReader: IdentityReader, networkId: Hex): Promise<Hex | null> {
37
+ try {
38
+ return await identityReader.getIdentity(networkId)
39
+ } catch (error) {
40
+ throw new NetworkMemberClientError("REGISTRY_UNAVAILABLE", `identity read failed for ${networkId}`, error)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Submits a user's signed registration consent to `IdentityRegistry`, as an approved network
46
+ * member. The ports' own registry binding is checked first, then consent, both before any port
47
+ * runs, so a misconfigured member app or a forged/mistargeted request never reaches the registry.
48
+ */
49
+ export async function submitRegistration(
50
+ request: RegistrationRequest,
51
+ { domain, identityReader, prover, writer }: SubmitRegistrationPorts
52
+ ): Promise<SubmitRegistrationResult> {
53
+ const registryDomain: RegistryLocation = { chainId: BigInt(domain.chainId), address: domain.verifyingContract }
54
+ assertRegistryMatches("prover", prover.registry, registryDomain)
55
+ assertRegistryMatches("writer", writer.registry, registryDomain)
56
+ assertReaderMatches(identityReader.registryAddress, registryDomain)
57
+
58
+ if (!verifyRegistrationRequest(request, domain)) {
59
+ throw new NetworkMemberClientError("INVALID_REGISTRATION_REQUEST", "registration request failed verification")
60
+ }
61
+
62
+ const { networkId, metaStealthAddress } = request
63
+
64
+ const existing = await readIdentity(identityReader, networkId)
65
+ if (existing !== null) {
66
+ return {
67
+ status: "alreadyRegistered",
68
+ networkId,
69
+ metaStealthAddress: existing,
70
+ matchesRequest: existing.toLowerCase() === metaStealthAddress.toLowerCase()
71
+ }
72
+ }
73
+
74
+ const proof = await prover.prove({ action: UpdateAction.Register, networkId, metaStealthAddress })
75
+
76
+ try {
77
+ const write = await writer.registerIdentity(networkId, metaStealthAddress, proof)
78
+ return { status: "submitted", networkId, metaStealthAddress, ...write }
79
+ } catch (error) {
80
+ if (error instanceof RegistryRevertError) {
81
+ // Lost a race between the read above and this write: someone else registered this
82
+ // networkId in between. The re-read, not the revert's own payload, is the source of
83
+ // truth for what the caller should treat as the current entry.
84
+ if (error.errorName === "AlreadyRegistered") {
85
+ let raced: Hex | null
86
+ try {
87
+ raced = await readIdentity(identityReader, networkId)
88
+ } catch {
89
+ // The write's revert already confirmed the entry exists; a failed re-read
90
+ // doesn't change that, so surface the confirmed revert, not the read failure.
91
+ throw error
92
+ }
93
+ // The registry answered; the write's gas estimate saw pending state the read
94
+ // above did not. The revert is still the right thing to surface.
95
+ if (raced === null) throw error
96
+ return {
97
+ status: "alreadyRegistered",
98
+ networkId,
99
+ metaStealthAddress: raced,
100
+ matchesRequest: raced.toLowerCase() === metaStealthAddress.toLowerCase()
101
+ }
102
+ }
103
+ throw error
104
+ }
105
+ throw new NetworkMemberClientError(
106
+ "REGISTRY_UNAVAILABLE",
107
+ `registration submission failed for ${networkId}`,
108
+ error
109
+ )
110
+ }
111
+ }