Status lists
The chain is the source of truth for whether a credential is active: StatusRegistry.isValid answers that for anyone with an RPC connection. Standard verifiable credential software does not call Certichain contracts, though. It reads Bitstring Status Lists (a W3C format): a long string of bits, one per credential, served as a signed credential at a web address, with an entry in each credential naming its list and its bit.
Certichain serves such lists as a derived view of the chain. The server reads the issuer's credentials and their latest status from chain events, packs them into two lists (revocation and suspension), and signs each list with a dedicated publisher key. Exported VCs and Open Badges can then carry list entries. The app itself checks those entries against the chain and treats any difference as the list's fault, never the chain's.
The feature is off unless STATUS_LIST_SIGNER_KEY, NEXT_PUBLIC_STATUS_LIST_BASE_URL and NEXT_PUBLIC_STATUS_LIST_PUBLISHER are all set (plus a server RPC URL and NEXT_PUBLIC_NETWORK_ID). As of 2026-10-24 the handbook lists these as not yet set on the hosted Sepolia site. When off, the route answers 503 and exports carry only the chain status entry.
The server side (route, caching, configuration) is also covered in Status lists on the server. The entries' place in the file is in Verifiable credential file.
Who takes part
| Actor | Role in this flow |
|---|---|
| Issuer | Suspends, reinstates, revokes or supersedes credentials on chain (see Lifecycle). Nothing extra to do for lists. |
| Operator | Creates the publisher key (a key that holds no funds), sets the three settings, keeps the key server-only. |
| Holder or issuer | Exports a VC or badge from an export panel; the panel adds list entries when the credential is deep enough. |
| Verifier | Any VC library that reads Bitstring Status Lists, or the Certichain verify page, which also compares with the chain. |
| Status list route | GET /api/status-lists/<chainId>/<registry>/<issuer>/<purpose>/<list>, public, no sign-in. |
Key ideas
| Term | Meaning in Certichain |
|---|---|
| List size | 131,072 bits per list (LIST_SIZE), the minimum the W3C format asks for. |
| Position | A credential's index in its issuer's issuance order (block number, then log index), counting only credentials at least 64 blocks deep. It never changes once given. |
| List number | position / 131,072, rounded down. |
statusListIndex | position mod 131,072. Bit 0 is the leftmost bit of the first byte. |
| Revocation bit | Set when the chain status is Revoked or Superseded. Final. |
| Suspension bit | Set when the credential is Suspended, or when its issuer is suspended (following key retirement to the key in charge now). Can be cleared again. |
| Expiry | Not a bit. Verifiers read validUntil from the credential itself. |
| Publisher | The address of STATUS_LIST_SIGNER_KEY. Lists are issued by did:pkh:eip155:<chainId>:<publisher>, not by the institution. |
One list address exists per chain, contract set (registry), issuer, purpose and list number. Each contract set answers only for its own registry (StatusListRouter in src/lib/server/status-lists/router.ts).
Step 1: the server builds a list on request
Lists are not stored. Every request is answered from chain data, through short caches.
What the service checks before building:
- The chain id and registry name a contract set of this deployment, else 404 "This deployment has no status lists for that chain and registry."
- The address was ever added as an issuer in that set (a removed issuer's credentials still have lists), else 404 "This address is not an issuer on this deployment."
- For list numbers above 0, that the issuer has enough credentials to reach that list, else 404 "There is no such status list for this issuer."
- Every confirmed credential has a status history; anything inconsistent is an error, never a guess.
The signed typed data uses the normal Certichain domain (Certichain, 1, chain id, CredentialRegistry) with its own type, so a list signature can never be mistaken for a credential signature:
StatusList(address registry, address issuer, string purpose, uint256 listNumber,
bytes32 encodedListHash, uint64 validFrom, uint64 validUntil)
encodedListHash is the keccak-256 of the encodedList string as served. validUntil is validFrom + 600 seconds. The list's ttl is 300,000 ms. The GZIP header byte that names the operating system is fixed to "unknown" so the same bits give the same text and the same signed hash on any machine.
Caching and cost bounds
| Layer | Value | Where |
|---|---|---|
| Chain state per issuer, issuer check, signed list | STATUS_LIST_CACHE_SECONDS, default 300, allowed 0 to 300; at most 512 entries each | src/lib/server/status-lists/service.ts, ttl-cache.ts |
| HTTP | Cache-Control: public, max-age=60 | src/lib/server/status-lists/handler.ts |
| Rate limit | 600 requests per IP per hour (shared Redis store when configured, else per process) | STATUS_LIST_IP_LIMIT in src/lib/server/limits.ts |
| Encoded list size accepted by readers | 200,000 characters, at most 1 MiB unpacked | src/lib/vc/status/list-credential.ts, bitstring.ts |
So a list can trail the chain by up to the cache time plus the HTTP cache. This is one reason the chain always wins.
Errors from the route
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | The path is not exactly <chainId>/<registry>/<issuer>/<purpose>/<list> with valid values |
| 404 | NOT_FOUND | Unknown set, not an issuer, or no such list number |
| 429 | Rate limit reached (with Retry-After) | |
| 503 | CONFIG_MISSING | "Status lists are not available on this deployment." |
| 503 | UPSTREAM_ERROR | "The status list could not be built right now. Try again shortly." with Retry-After: 30 |
The error body uses the shared envelope described in API envelope.
Step 2: exports carry list entries
When the browser has both public settings, VcService.listOptions asks the StatusListChecker for an export plan:
- It finds the credential's position among the issuer's credentials that are at least 64 blocks deep (
STATUS_LIST_CONFIRMATIONSinsrc/services/index.ts). - If the credential is newer, the export goes ahead without list entries and says: "Status list entries were left out: this credential is not yet confirmed deeply enough to have a stable position. Export it again in about 15 minutes."
- Otherwise
statusEntrieswrites two entries. A VC gets the chain entry, then the revocation entry, then the suspension entry. An Open Badge gets the revocation entry alone (its schema allows one status entry).
The issued card on /records/add never adds list entries: a credential that was just issued is never 64 blocks deep.
An entry looks like this (fake addresses):
{
"id": "https://certichain.example/api/status-lists/11155111/0x0000000000000000000000000000000000000C0d/0x00000000000000000000000000000000000015A5/revocation/0#42",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "42",
"statusListCredential": "https://certichain.example/api/status-lists/11155111/0x0000000000000000000000000000000000000C0d/0x00000000000000000000000000000000000015A5/revocation/0"
}
The base address must be https (plain http only for localhost or 127.0.0.1) with no credentials, query or fragment (checkedBase in src/lib/vc/status/location.ts).
Step 3: checking entries against the chain
The verify page, the API and the export panels share one checker: StatusListChecker in src/services/domain/status-list-checker.ts. It runs only when the file has list entries and the page has lists configured.
A list counts only when every one of these holds (verifyListCredential):
- the signature recovers to the pinned publisher (
NEXT_PUBLIC_STATUS_LIST_PUBLISHER), and the list's issuer is that publisher; - the list's
idequals the address it was fetched from, and that address names the chain, registry, issuer, purpose and list number in the signed fields and domain; - the hash of
encodedListequals the signed hash, and the subject purpose equals the signed purpose; - the dates match the signed values; a list past
validUntilis "stale", not forged.
Then the bit is compared with the bit the chain gives today.
| State | Line on the verify page |
|---|---|
| All lists agree | "The status lists agree with the chain." (a badge: "The revocation list agrees with the chain.") |
entries: mismatch | "The status list entries do not point at this credential." (the whole credential then fails) |
entries: unconfirmed | "This credential is too new to check its status list entries yet." |
entries: error | "The status list entries could not be checked right now." |
disagrees | "The revocation list differs from the chain (the chain wins)." |
unavailable | "The suspension list could not be reached." |
untrusted | "The revocation list is not signed by the publisher this site trusts." |
stale | "The suspension list is out of date." |
Only an entries mismatch makes the credential fail. The other list states are caution lines: the verdict still comes from StatusRegistry.isValid.
The issuer's signature does not cover the list entries in a plain VC. Someone could move statusListIndex to a clear bit of another credential and a list-only verifier would read "not revoked". The interop tests show exactly this with the Digital Bazaar library. The Certichain checker recomputes the position from the chain, so it catches the move. The standard eddsa-rdfc-2022 proof of an Open Badge does cover its entry.
Using the lists from other software
With @digitalbazaar/vc-bitstring-status-list (tested in interop/), a verifier must:
- turn off the default rule that the list issuer equals the credential issuer, because lists come from the publisher key;
- pin the publisher address from the deployment's public configuration instead;
- accept that the library checks the list against the real clock, so a list is valid only in its 10 minute window.
Lifecycle of the bits
Configuration
| Name | Side | Meaning |
|---|---|---|
STATUS_LIST_SIGNER_KEY | server only, secret | 32-byte private key of the publisher. Read only in src/lib/server/status-lists/wiring.ts. Must hold no funds. |
NEXT_PUBLIC_STATUS_LIST_BASE_URL | public | Base address the lists are served from, normally the site address. |
NEXT_PUBLIC_STATUS_LIST_PUBLISHER | public | Address of the publisher key. The server refuses to start the feature if it differs from the key's address. |
STATUS_LIST_CACHE_SECONDS | server | 0 to 300, default 300. |
RPC_URL or RPC_URLS, NEXT_PUBLIC_NETWORK_ID | server | Where chain events are read and which chain the lists are for. |
See Configuration for all settings.
Limits
- Lists can trail the chain by the cache time (up to 5 minutes) plus 60 seconds of HTTP caching.
- A credential gets no position until it is 64 blocks deep (about 13 minutes on Sepolia).
- The publisher is the deployment, not the institution. A verifier must trust the deployment's publisher address.
- The scan of an issuer's credentials grows with the number of credentials the issuer has; the caches bound the cost per request window.
Source files
src/lib/vc/status/derive.ts,bitstring.ts,entry.ts,location.ts,list-credential.ts,access.tssrc/app/api/status-lists/[...path]/route.tssrc/lib/server/status-lists/handler.ts,config.ts,wiring.ts,router.ts,service.ts,chain-state.ts,ttl-cache.tssrc/services/domain/status-list-checker.ts,src/services/domain/vc-service.ts,src/services/index.ts(createVcLists)src/app/verify/VcCheckPanel.tsx,src/components/vc/VcExportPanel.tsxvc/cryptosuite-certichain-eip712-2026.md(section 6)
Tests
src/lib/vc/status/bitstring.test.ts,derive.test.ts,entry.test.ts,list-credential.test.ts,location.test.tssrc/lib/server/status-lists/handler.test.ts,config.test.ts,wiring.test.ts,router.test.ts,service.test.ts,chain-state.test.tssrc/services/domain/status-list-checker.test.tsinterop/(the library reads the right bit; forged, expired, moved or wrong-purpose lists are refused)- E2E:
e2e/journeys/status-lists.journey.ts(lists follow suspend, reinstate and revoke; a moved index is caught)