@coordination-network/network-member-client
The two things a network-member app does: issue a UserApproval credential a wallet can present, and deliver value to a recipient's stealth address by announcing it on chain. Both gate on the caller being currently approved, checked against the chain rather than assumed from configuration.
Built on packages this repo already ships — issueZkCredential and the registry client
(packages/credential), ApprovedIssuerSet (packages/issuer-set), and the stealth-address and
announcement builders (packages/identity). This package composes them; it adds no new
cryptography of its own.
Install
yarn add @coordination-network/network-member-client
issueVC
Issues a UserApproval credential and, alongside it, the issuer's fresh Merkle path into the approved-issuer set: the witness a holder's wallet needs to prove the issuer was approved when it signed, without revealing which issuer.
import { issueVC } from "@coordination-network/network-member-client"
const { credential, issuerSetRoot, issuerMembershipWitness } = await issueVC(params, {
signer,
approval,
issuerSetReader
})
signer and approval are packages/credential's ports (CredentialSigner, ApprovalResolver);
issuerSetReader is packages/issuer-set's ContractLeafReader. params is
ZkCredentialIssueParams from packages/credential, unchanged.
The witness goes stale the moment the approved-issuer set changes: a key published or retired
after issuance moves the root. fetchIssuerMembershipWitness is the same lookup on its own, for a
member server to re-serve a fresh witness to a wallet whose credential has outlived the root it
was issued against. ApprovedIssuerSetClient pins all reads of one rebuild to a single block and
must not serve two rebuilds at once, so a server handling concurrent requests should construct a
fresh reader per request.
import { fetchIssuerMembershipWitness } from "@coordination-network/network-member-client"
const { root, witness } = await fetchIssuerMembershipWitness({ issuerSetReader, issuerPublicKey })
An approved issuer whose signing key was never published to the set fails at issueVC with
ISSUER_KEY_NOT_PUBLISHED — a misconfiguration caught at issuance, not at the holder's first
presentation.
deliverToStealth
Delivers value to a network member's recipient by resolving their registered identity to a meta-stealth address and announcing a fresh stealth address for it, ERC-5564 style.
import { deliverToStealth } from "@coordination-network/network-member-client"
const { stealthAddress, announcement, txHash } = await deliverToStealth(
{ memberNetworkId, recipientNetworkId },
{ approval, identityReader, submitter }
)
ERC5564Announcer.announce is permissionless — anyone can call it — so "an unapproved member is
rejected" is not a rule the chain enforces on its own. This package enforces it client-side, and
first: approval.isApprovedNetworkMember(memberNetworkId) runs before any other port is touched,
so an unapproved caller never even reads the recipient's identity.
MEMBER_NOT_APPROVED— the member is not currently approved.RECIPIENT_NOT_REGISTERED— the recipient's networkId has no identity onIdentityRegistry.INVALID_META_STEALTH_ADDRESS— the resolved identity doesn't decode to a valid meta-stealth address.
txHash is returned when the announce transaction is submitted, not when it lands. A dropped or
reverted transaction leaves no Announcement event for the recipient's indexer to find, so
confirm the transaction (viem's waitForTransactionReceipt, for example) before recording the
delivery as done.
stealthAddress and announcement are fresh on every call: buildAnnouncement draws a new
ephemeral key each time, so the same recipient gets a different, unlinkable address for every
delivery.
submitRegistration
Submits a user's signed registration consent (@coordination-network/user-client's
RegistrationRequest) to IdentityRegistry, as an approved network member. The user's EIP-712
signature is off-chain consent; the chain itself only checks the member's anonymous Semaphore
membership proof, so this is the check that stands between an arbitrary request and a spent proof.
import { submitRegistration } from "@coordination-network/network-member-client"
const result = await submitRegistration(request, { domain, identityReader, prover, writer })
domain and identityReader are the same ChainDomain and IdentityReader used elsewhere in
this package; prover and writer are new ports (RegistrationProver, RegistrationWriter), each
carrying a registry (RegistryLocation) naming the IdentityRegistry deployment it's bound to.
identityReader can optionally report the same thing through registryAddress.
submitRegistration checks all three against domain before anything else, including before
request is verified: this is a configuration guard over what the ports report about
themselves, not a check on the request, so a mismatch throws a plain Error rather than a coded
one — a member app that wires a port at the wrong registry fails loudly instead of proving or
writing against the wrong deployment. request itself is then verified against domain — it is
domain, not any port, that the user's own signature is pinned to, so a forged or mistargeted
request never reaches the registry.
- If the registry already holds an entry for the request's networkId,
submitRegistrationreturns{ status: "alreadyRegistered", networkId, metaStealthAddress, matchesRequest }without generating a proof.matchesRequestis whether the stored entry equals the request's ownmetaStealthAddress— false means someone else's registration is occupying this networkId. - Otherwise it proves membership and writes, returning
{ status: "submitted", networkId, metaStealthAddress, txHash }. As withdeliverToStealth,txHashis the hash of the submitted transaction, not proof it landed — confirm it before treating the registration as done. - Losing the race between the read above and the write (someone else registers the same networkId
in between) also resolves to
alreadyRegistered, re-read from the registry rather than trusted from the revert.
Error codes, beyond the two already listed under deliverToStealth:
INVALID_REGISTRATION_REQUEST— the request failedverifyRegistrationRequestagainstdomain.REGISTRY_UNAVAILABLE— a registry read or write failed for a reason other than a named revert.GROUP_MIRROR_UNAVAILABLE— the approved-member group could not be rebuilt, or its rebuilt root did not match the chain's.MEMBER_NOT_IN_GROUP— the member's own identity is not (or no longer) a member of the group.REGISTRATION_REJECTED—IdentityRegistryor Semaphore reverted for any other named reason; the thrownRegistryRevertError'serrorNamecarries which one.
The approved-member group is re-mirrored on every call, never cached: a stale mirror proves membership against a root the chain may have moved past, and a proof against a root outside Semaphore's recent-root window is rejected on submission anyway.
Adapters
adapters.ts wires the ports above to viem, so a member app has one way to run this against a real
chain:
- Approval comes from
@coordination-network/credential'sNetworkMemberRegistryClient, used as-is for both issuance and delivery. IdentityRegistryClient—IdentityReaderoverIdentityRegistry.identity.ApprovedIssuerSetClient—ContractLeafReaderoverApprovedIssuerSet's paging getters.ERC5564AnnouncerClient—AnnouncementSubmitteroverERC5564Announcer.announce, given aWalletClientalready carrying its account and chain.SemaphoreGroupClient—GroupMirrorover Semaphore's group events, replayed into a@semaphore-protocol/coreGroupand checked againstgetMerkleTreeRooton every call.fromBlockshould be the Semaphore deployment's own block. Each call makes one topic-filteredeth_getLogsper event type over[fromBlock, toBlock], wheretoBlockis the block pinned at the start of that call. Range caps on hosted RPCs are the scaling limit here — a chunked or incremental mirror is a follow-up, not something this client does today.SemaphoreRegistrationProver—RegistrationProverthat mirrors the group and generates the membership proof, given the member's own SemaphoreIdentityand where the registry lives.IdentityRegistryWriter—RegistrationWriteroverIdentityRegistry.registerIdentity, given aWalletClientand theRegistryLocationit targets. Maps a named revert (AlreadyRegistered, aSemaphore__*error, ...) toRegistryRevertErrorrather than letting viem's raw error through.submitRegistration's lost-race handling depends on that revert surfacing at the write call, which in turn depends on viem's own gas estimation — that only runs for a local account. AWalletClientbacked by a JSON-RPC account (a browser wallet, for example) skips it, soAlreadyRegisteredsurfaces only once the transaction is mined, not at submission.
None of it is required. Every port is a plain interface, so an app can supply its own
implementation — a different chain library, a queued submitter — without touching issueVC or
deliverToStealth.
What this package does not do
- It holds no keys.
signer,approval,identityReader,submitter,proverandwriterare ports the caller supplies. Issuing a credential, announcing a stealth address, or proving and submitting a registration happens with keys and clients this package never sees — including the member's own Semaphore identity, which lives inside whateverRegistrationProverthe caller constructs. - It owns no RPC. The functions take ports, not a client or an RPC URL.
adapters.tsis one convenient wiring, not the only one; nothing here callscreatePublicClienton its own. - It does not revoke.
packages/credential's revocation registry and commitments are unrelated to this package's two flows. - It does not move assets.
deliverToStealthannounces a stealth address; it transfers nothing. The ERC-1155 scoped-token machinery inspecs/05-stealth-addresses.mddoesn't exist yet, and wiring it in is a separate change. - It is not
apps/issuer-service. That app still runs its own HTTP layer over pieces this package composes. Refactoring it ontoissueVCis a follow-up issue, not part of this package.