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 member | Returns |
|---|---|
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
| Pattern | Input | Code | Used by |
|---|---|---|---|
| Locate by id | A credential id only | CredentialLocator.locate(id) | The verify page, anchors.getCredential, re-issue, dashboard status changes |
| Route by named registry | A file that names its registry | pickByRegistry(...) in the routers | Disclosure files, packages, VC export options, phone presentation codes |
| Fan out | A wallet address | SetFanOut.collect(...) | Holder inbox, issuer's issued list |
| Active set only | New work | the active services | Issuing, 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 calls | Calls 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 export | none |
PresentationRouter (presentation-router.ts) | checkStatic(code) by code.signed.domain.verifyingContract; checkDisclosureCode(code, challenge) by the domain of the decoded disclosure code | isAccepted (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) | Method | Per-set call |
|---|---|---|
AllSetsIssued | issuedBy(wallet), sorted newest first by block | IssuedCredentialsService.issuedBy |
AllSetsIssued | issuedIds(wallet) | IssuedCredentialsService.issuedIds |
AllSetsInbox | myCredentials(wallet), sorted newest first by block | InboxService.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):
| Call | Behaviour |
|---|---|
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 feature | Behaviour |
|---|---|
| Verification API lookup | lookupInSets asks the active set, then the older ones; found: false only when every set answered. See Verification API. |
| Verification API file check | The file's verifyingContract picks the set; an unlisted registry is refused with 422 REGISTRY_NOT_LISTED and not checked at all |
| Status lists | StatusListRouter serves each set's lists only for that set's registry. See Status lists. |
| Monitor | Watches 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.jsonsrc/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.tssrc/services/domain/registry-pick.ts,package-routers.ts,presentation-router.ts,routed-status.tssrc/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.tssrc/services/domain/package-routers.test.ts,presentation-router.test.ts,routed-status.test.ts,routed-credentials.test.tssrc/contracts/v03/manifest.test.tssrc/app/verify/replacement-check.test.tse2e/journeys/contract-sets.journey.tsruns the app against two live sets on a local chain