Reading the chain
Certichain has no application database and no hosted indexer. Everything the pages show comes from the chain: contract calls for single facts ("is this credential active?") and event logs for lists ("which credentials were issued to me?"). This page explains how those reads work, why they work without a wallet, and what happens when an RPC endpoint is slow, refuses a request or serves another chain.
An RPC endpoint is an HTTP address of an Ethereum node that answers JSON-RPC calls such as eth_call and eth_getLogs. An event log is a record a contract writes during a transaction; logs are cheap to write, cannot be read by contracts, and can be queried by address and topics.
Wallet-free reads
Every gateway reads over the public RPC endpoints configured in NEXT_PUBLIC_RPC_URLS (a comma-separated list) and NEXT_PUBLIC_RPC_URL (a single address). The wallet is never used for v0.3 reads, for two reasons:
- Verifiers have no wallet and must still see the full answer.
- A wallet answers for the chain it is on. If it were on another network, a read through it would describe the wrong chain. Reads always go to the configured chain, and writes are checked against it separately (see Wallet and network).
parseRpcUrls(list, single) in src/lib/rpc/provider.ts builds the endpoint list: the entries of the comma list first, then the single address, keeping only http and https addresses and dropping duplicates. An invalid entry or an empty result raises RpcConfigError. publicRpcUrls(env) and serverRpcUrls(env) in src/lib/rpc/env.ts catch that error, log "The RPC settings cannot be used, so the features that need them stay off: ...", and return an empty list, so a half-valid setting is never partly used.
| Reader | Environment names, in order |
|---|---|
Browser (publicRpcUrls) | NEXT_PUBLIC_RPC_URLS, then NEXT_PUBLIC_RPC_URL |
Server (serverRpcUrls) | RPC_URLS or else NEXT_PUBLIC_RPC_URLS, then RPC_URL or else NEXT_PUBLIC_RPC_URL |
The server list lets an operator give server routes a private endpoint while the browser uses a public one.
RPC failover
createRpcProvider(urls, { chainId, timeoutMs }) returns a FailoverRpcProvider. The browser builds it through createDefaultReadRunner with an 8,000 ms timeout (DEFAULT_RPC_TIMEOUT_MS).
- Each endpoint is wrapped in a
CheckedRpcProviderthat callseth_chainIdonce before its first request and throwsRpcChainMismatchErrorif the endpoint serves another chain. - An endpoint that fails (an error, a timeout or the wrong chain) is skipped for 30,000 ms (
DEFAULT_RPC_COOLDOWN_MS), so a dead primary costs one timeout and not one per call. If every endpoint is in cooldown, all are tried again. - Answers the chain gives, such as a revert, are results. They are returned as they are and never retried on another endpoint.
- When every candidate fails, the provider throws
RpcUnavailableError("No RPC endpoint answered.") keeping the last error's code and status, or theRpcChainMismatchErrorif that was the last failure. Error messages never repeat an endpoint address. - ethers' own
FallbackProvideris not used: with a quorum of one it throws the first endpoint's error instead of asking the next.
The server builds the same provider through providerFor(target), which caches one provider per chain id, timeout and address list, so cooldowns carry over between requests in the same process.
Event log scans
Lists are rebuilt by scanning logs from a contract's deployment block to the chain head. Public endpoints limit how many blocks one eth_getLogs may cover, and they fail in different ways when a range is too large. scanLogs(source, filter, fromBlock, options) in src/services/indexing/log-scan.ts handles this in one place.
Defaults
DEFAULT_SCAN_OPTIONS:
| Option | Value | Meaning |
|---|---|---|
window | 20,000 blocks | First range size per request |
minWindow | 500 blocks | Smallest range tried before giving up on a range |
maxRequests | 1,000 | Request cap for one scan |
reorgDepth | 12 blocks | How far back a cached scan is re-read, in case recent blocks were replaced |
retry.attempts | 3 | Retries of a transient failure |
retry.baseMs | 500 ms | Base of the exponential backoff |
Some callers override these. The issuer signature lookup starts with a 1,000,000-block window, and the server monitor's chain events check uses 5,000-block windows with a cap of 20 requests (see Monitoring endpoints).
The scan loop
- Read the head block, with retries.
- Load the cached scan for
cacheKeyif there is one. It is trusted only when it started at the samefromBlockand itsscannedTois not above the current head (a head below the cached progress means a reset node or a lagging endpoint, so the cache could describe another chain). - Resume from
scannedTo - reorgDepth + 1, keeping the cached logs older than that block. - Request windows of
windowblocks. After each window, keep only logs that match the filter and the range (the filter is applied again on the client, whatever the endpoint returned), and move on. - A transient failure (
TIMEOUT,NETWORK_ERROR, HTTP 429 or 5xx) is first retried up to 3 times, with a delay ofbaseMs * 2^attemptmultiplied by a random factor between 0.5 and 1.5. - If the request still fails, the window is halved (never below
minWindow) and the same range is tried again, when the failure looks like an oversized range: any non-transient error (such as a "range too large" reply), or a timeout or 5xx that outlasted the retries. A 429 or a lost connection that outlasted the retries ends the scan withScanUnavailableError. After each successful window the window doubles again, up to the configured size. - Deduplicate by transaction hash and log index, sort by block and log index, and save progress to the cache. Progress is also saved when the scan fails part way, so the next attempt resumes.
| Error | Message | Cause |
|---|---|---|
ScanRangeError | "The RPC endpoint rejected log queries for blocks A to B, even in the smallest window" | The endpoint refused even a minWindow range |
ScanUnavailableError | "The RPC endpoint stayed unavailable while reading blocks A to B" | Transient failures outlasted the retries |
ScanLimitError | "Reading the chain history needed more than N requests; use an RPC endpoint with a higher log range limit" | The request cap was reached |
Caches
| Cache | Where | Used by |
|---|---|---|
StorageScanCache (src/services/indexing/storage-scan-cache.ts) | Browser localStorage, keys prefixed certichain.scan.v1:, entries up to 500,000 characters, validated on load, best effort | createScanCache() in the browser |
MemoryScanCache (src/services/indexing/memory-scan-cache.ts) | Process memory | The browser when localStorage is unavailable, and server code (status lists) |
createScanCache() (src/services/indexing/browser-cache.ts) picks local storage when it can and memory otherwise. The cache only speeds things up; the chain stays the source of truth, and a cache that does not fit is ignored.
The start block
A scan starts at a contract's deployment block, never at block 0, so it does not walk years of unrelated history.
| Contracts | Where the start block comes from |
|---|---|
| v0.3 registries | deploymentBlock of the set in src/contracts/v03/deployment.<chainId>.json |
| Access gate | deploymentBlock in src/contracts/zk/deployment.<chainId>.json |
v0.2 AcademicRecords | NEXT_PUBLIC_START_BLOCK |
Without NEXT_PUBLIC_START_BLOCK, the v0.2 share history is not built and the v0.2 "shared with me" lists say that history is not configured (ShareHistoryNotConfiguredError).
The indexes
An index answers list questions from scanned logs. CredentialIndex (src/services/indexing/credential-index.ts) is an interface (ADR-0051), so a hosted indexer could replace the log scans later without changing the services. Today its only implementation is LogQueryIndex.
Credential index (v0.3)
LogQueryIndex scans one contract set, from that set's deployment block. Its cache key is chainId:address:topics, so each query resumes on its own.
| Method | Events read | Used for |
|---|---|---|
issuers() | IssuerAdded, IssuerUpdated on the IssuerRegistry | Issuer names, the inbox's list of issuers |
issuedTo(issuer, subjectCommitment) | CredentialIssued filtered by issuer and subject commitment topics | The holder inbox |
issuedBy(issuer) | CredentialIssued filtered by issuer | The issuer dashboard, status list positions |
retirements() | IssuerRetired | Retired keys on the governance screen |
predecessorsOf(issuer) | IssuerRetired, followed back at most 8 hops | Credentials of retired keys an issuer now controls |
recordTypesOf(issuer) | RecordTypeRegistered, RecordTypeActiveChanged | Record type names and their active state |
A log that cannot be decoded is skipped with a warning ("Skipped an unreadable log in block N") rather than stopping the list.
The holder inbox shows how the index keeps work small. The chain keeps no wallet-to-credential list, on purpose. InboxService.myCredentials(wallet) reads the issuer list, asks the HolderRegistry for the wallet's enrolment commitment at each issuer, and then queries issuedTo only for the issuers where the wallet is enrolled. The work grows with the number of issuers and the holder's own credentials, not with every credential on the chain. Entries the status registry has never seen are dropped, and the rest are sorted newest first. See Holder inbox.
Share index (v0.2)
LogShareIndex (src/services/indexing/share-index.ts) replays RecordShared and RecordUnshared events of AcademicRecords from NEXT_PUBLIC_START_BLOCK and answers recordsSharedWith(wallet) and walletsSharedOn(recordId). It backs the v0.2 "shared with me" list and the share list of a record.
Access group index
LogAccessGroupIndex (src/services/indexing/access-group-index.ts) reads the access gate's admissions, removals and receipts for one credential. It backs the access panels and the proof builder. See ZK access.
Finding an issuer signature
The issuer's EIP-712 signature over a v0.3 credential is not stored in contract storage. It is carried in the CredentialSigned event emitted at issuance. findSignedEvent(source, registry, credentialId) in src/services/indexing/signature-lookup.ts finds it:
- It filters by the
CredentialSignedtopic and the credential id topic, so the query is narrow. - It scans from the set's
deploymentBlockwith a first window of 1,000,000 blocks. Most endpoints answer a narrow topic query over the whole history at once; the scan narrows only when one refuses. - It needs no block number from the contract (revision R2 of ADR-0088), so the same lookup works on every contract set.
- It uses no cache.
The verify page then checks that signature against the credential's fields, as described in Verification.
Freshness
Reads are live calls, but some answers can trail the chain briefly:
- A public endpoint can be a few blocks behind another, so a status read right after a revoke can still show the old status for a moment. Asking again resolves it.
- Lists built from scans include blocks up to the head the endpoint reported when the scan started.
- Status list positions count an issuance only after 64 confirmations, so a credential issued minutes ago has no list entry yet. See Status lists.
Source files
src/lib/rpc/provider.ts,src/lib/rpc/env.tssrc/services/contracts/contract-gateway.ts(createDefaultReadRunner)src/services/indexing/log-scan.ts,provider-log-source.ts,credential-index.ts,share-index.ts,access-group-index.ts,signature-lookup.tssrc/services/indexing/browser-cache.ts,storage-scan-cache.ts,memory-scan-cache.tssrc/services/domain/inbox-service.tssrc/services/contracts/address-book.ts,src/contracts/v03/manifest.ts
Tests
src/lib/rpc/provider.test.ts,src/lib/rpc/env.test.tssrc/services/indexing/log-scan.test.ts,credential-index.test.ts,share-index.test.ts,access-group-index.test.tssrc/services/indexing/storage-scan-cache.test.ts,memory-scan-cache.test.ts,browser-cache.test.tssrc/services/domain/inbox-service.test.tse2e/journeys/invite-and-inbox.journey.ts,e2e/journeys/contract-sets.journey.ts