Skip to main content

Contract-set routing

A contract set is one deployment of the v0.3 registries (IssuerRegistry, CredentialRegistry, StatusRegistry, HolderRegistry, RecordTypeRegistry). Certichain upgrades by deploying a new set next to the old ones instead of replacing contracts in place (ADR-0088). Old sets stay listed for ever, so a credential issued years ago is still checked against the registries that anchored it. One set is active: it receives new credentials.

As of 2026-10-24, the Sepolia manifest lists set 0.3.0 (active) and set 0.5.0 (deployed, not active). Background and the upgrade procedure are in Contract versions and sets and Contract set upgrade.

This page explains the rule the app follows: a credential is always checked, listed and changed in the set that holds it, and nothing is ever checked against a registry that is not in the manifest.

Where the sets come from​

The sets are listed in the bundled deployment manifest src/contracts/v03/deployment.<chainId>.json, read by src/contracts/v03/manifest.ts (format: Deployment manifest). Schema 1 holds one set; schema 2 holds a list of sets, the label of the active one, and optionally the chain-wide timelock address.

The v0.3 credential gateway exposes the sets:

Gateway memberReturns
sets()Every set as { label, active, registry }, where registry is the set's CredentialRegistry address. That address identifies the set, because every credential's EIP-712 domain names it as verifyingContract.
forRegistry(address)A gateway scoped to the set that owns that credential registry (cached per set), or null when the address is not listed

The composition root builds one bundle of per-set services for each scoped gateway (see Service layer) and remembers it, so routing to a set reuses that set's services and caches.

Four routing patterns​

PatternInputCodeUsed by
Locate by idA credential id onlyCredentialLocator.locate(id)The verify page, anchors.getCredential, re-issue, dashboard status changes
Route by named registryA file that names its registrypickByRegistry(...) in the routersDisclosure files, packages, VC export options, phone presentation codes
Fan outA wallet addressSetFanOut.collect(...)Holder inbox, issuer's issued list
Active set onlyNew workthe active servicesIssuing, new challenges, enrolment invitations, record types

Locate by id​

A credential id does not say which set issued it. CredentialLocator (src/services/domain/credential-locator.ts) asks the sets in turn.

The important property is the last two branches: "not found" is returned only when every set answered. If one set could not be read, the locator throws instead, so an unreachable set can never turn into "this credential does not exist".

createRoutedCredentials(locator) (src/services/domain/routed-credentials.ts) wraps this as anchors.getCredential(id). The document service uses it to read the anchor of the credential whose document it opens.

Route by named registry​

Files a holder or issuer carries name the registry they were signed for in their EIP-712 domain (domain.verifyingContract). The routers read that address and pick the set that owns it:

export async function pickByRegistry<S>(active: S, forRegistry: (registry: string) => Promise<S | null>, registry: string | null, what: string): Promise<S> {
if (!registry) return active;
try {
return (await forRegistry(registry)) ?? active;
} catch (error) {
console.error(`Could not load the contract sets to route ${what}:`, error);
return active;
}
}

A file that names no registry, an unlisted registry, or a set list that fails to load goes to the active set. The active set's own check then compares the file's domain with its registry and reports the mismatch (or the read error) to the user. So an unlisted registry is never trusted: it is checked against the active set and fails there.

Router (src/services/domain/)Routed callsCalls kept on the active set
DisclosureRouter (package-routers.ts)attestPackage(packageText) (registry read from the package JSON by registryOfPackageText), createDisclosure(pkg, ...), verify(file, challenge)newChallenge(), attest(...) (new work), isFullyVerified (a pure function)
VcRouter (package-routers.ts)listOptions(pkg): the status list entries for an exportnone
PresentationRouter (presentation-router.ts)checkStatic(code) by code.signed.domain.verifyingContract; checkDisclosureCode(code, challenge) by the domain of the decoded disclosure codeisAccepted (a pure function)

The verify page works the other way round: it first locates the credential by id, then checks every file it is given (disclosure, VC or Open Badge) with the services of the set that holds that credential. A file for another credential id is refused ("This file belongs to a different credential." for VC and badge files, "This disclosure belongs to a different credential." for disclosures).

Fan out​

Lists that belong to a wallet must include every set. SetFanOut (src/services/domain/set-fan-out.ts) runs one read in every set in parallel and tags each result with { label, active, registry }:

If any set cannot be read, the whole list fails. The code says why: "a list that silently leaves a set out looks like a list with nothing in it."

Service (all-sets-lists.ts)MethodPer-set call
AllSetsIssuedissuedBy(wallet), sorted newest first by blockIssuedCredentialsService.issuedBy
AllSetsIssuedissuedIds(wallet)IssuedCredentialsService.issuedIds
AllSetsInboxmyCredentials(wallet), sorted newest first by blockInboxService.myCredentials (enrolment is per set, so each set answers for its own)

The issuer dashboard and the holder page mark items from a set that is not active with Older set and the set's label.

Active set only​

New credentials, enrolment invitations, record type registrations and new disclosure challenges always use the active set (services.credentials, services.enrolment, services.recordTypes, DisclosureRouter.newChallenge). An issuer must be registered in the active set's IssuerRegistry to issue.

Changing a credential in its own set​

Status changes from the issuer dashboard go through createRoutedStatus(locator, fallback) (src/services/domain/routed-status.ts):

CallBehaviour
getStatus(id)Locate the credential and read its status in that set; an unknown id is read from the fallback (the active set)
revoke(id, reason, note)Locate it and revoke in the set that holds it; "The credential was not found in any contract set" otherwise
supersede(id, replacementId)Locate both. Same set: the plain supersede. Different sets: supersedeToRegistry(id, replacementId, replacementRegistry), which records the replacement's own registry as the successor

This is how a credential in the old set is re-issued into the new active set: the replacement is anchored in the active set, then the old one is superseded in its own set with a pointer to the new registry.

The verify page closes the loop. When a credential is superseded and names its replacement, src/app/verify/replacement-check.ts locates the replacement in any listed set and checks that it is held by the same key in charge and is in good standing before presenting it as the replacement.

The same rule on the server​

The server applies the rule independently of the browser:

Server featureBehaviour
Verification API lookuplookupInSets asks the active set, then the older ones; found: false only when every set answered. See Verification API.
Verification API file checkThe file's verifyingContract picks the set; an unlisted registry is refused with 422 REGISTRY_NOT_LISTED and not checked at all
Status listsStatusListRouter serves each set's lists only for that set's registry. See Status lists.
MonitorWatches the admin events and admins of every listed set. See Monitoring endpoints.

The API is stricter than the browser on unlisted registries: the browser checks such a file against the active set (where it fails visibly), while the API refuses it outright.

Source files​

  • src/contracts/v03/manifest.ts, src/contracts/v03/deployment.11155111.json
  • src/services/credentials/credential-gateway.ts (sets, forRegistry)
  • src/services/index.ts (rememberPerGateway, createSetServices)
  • src/services/domain/credential-locator.ts, routed-credentials.ts, set-fan-out.ts, all-sets-lists.ts
  • src/services/domain/registry-pick.ts, package-routers.ts, presentation-router.ts, routed-status.ts
  • src/app/verify/credential-lookup.ts, src/app/verify/replacement-check.ts, src/app/records/add/use-replacement.ts

Tests​

  • src/services/domain/credential-locator.test.ts, set-fan-out.test.ts, all-sets-lists.test.ts
  • src/services/domain/package-routers.test.ts, presentation-router.test.ts, routed-status.test.ts, routed-credentials.test.ts
  • src/contracts/v03/manifest.test.ts
  • src/app/verify/replacement-check.test.ts
  • e2e/journeys/contract-sets.journey.ts runs the app against two live sets on a local chain