Skip to main content

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:

  1. Verifiers have no wallet and must still see the full answer.
  2. 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.

ReaderEnvironment 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 CheckedRpcProvider that calls eth_chainId once before its first request and throws RpcChainMismatchError if 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 the RpcChainMismatchError if that was the last failure. Error messages never repeat an endpoint address.
  • ethers' own FallbackProvider is 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:

OptionValueMeaning
window20,000 blocksFirst range size per request
minWindow500 blocksSmallest range tried before giving up on a range
maxRequests1,000Request cap for one scan
reorgDepth12 blocksHow far back a cached scan is re-read, in case recent blocks were replaced
retry.attempts3Retries of a transient failure
retry.baseMs500 msBase 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​

  1. Read the head block, with retries.
  2. Load the cached scan for cacheKey if there is one. It is trusted only when it started at the same fromBlock and its scannedTo is 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).
  3. Resume from scannedTo - reorgDepth + 1, keeping the cached logs older than that block.
  4. Request windows of window blocks. 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.
  5. A transient failure (TIMEOUT, NETWORK_ERROR, HTTP 429 or 5xx) is first retried up to 3 times, with a delay of baseMs * 2^attempt multiplied by a random factor between 0.5 and 1.5.
  6. 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 with ScanUnavailableError. After each successful window the window doubles again, up to the configured size.
  7. 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.
ErrorMessageCause
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​

CacheWhereUsed 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 effortcreateScanCache() in the browser
MemoryScanCache (src/services/indexing/memory-scan-cache.ts)Process memoryThe 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.

ContractsWhere the start block comes from
v0.3 registriesdeploymentBlock of the set in src/contracts/v03/deployment.<chainId>.json
Access gatedeploymentBlock in src/contracts/zk/deployment.<chainId>.json
v0.2 AcademicRecordsNEXT_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.

MethodEvents readUsed for
issuers()IssuerAdded, IssuerUpdated on the IssuerRegistryIssuer names, the inbox's list of issuers
issuedTo(issuer, subjectCommitment)CredentialIssued filtered by issuer and subject commitment topicsThe holder inbox
issuedBy(issuer)CredentialIssued filtered by issuerThe issuer dashboard, status list positions
retirements()IssuerRetiredRetired keys on the governance screen
predecessorsOf(issuer)IssuerRetired, followed back at most 8 hopsCredentials of retired keys an issuer now controls
recordTypesOf(issuer)RecordTypeRegistered, RecordTypeActiveChangedRecord 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 CredentialSigned topic and the credential id topic, so the query is narrow.
  • It scans from the set's deploymentBlock with 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.ts
  • src/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.ts
  • src/services/indexing/browser-cache.ts, storage-scan-cache.ts, memory-scan-cache.ts
  • src/services/domain/inbox-service.ts
  • src/services/contracts/address-book.ts, src/contracts/v03/manifest.ts

Tests​

  • src/lib/rpc/provider.test.ts, src/lib/rpc/env.test.ts
  • src/services/indexing/log-scan.test.ts, credential-index.test.ts, share-index.test.ts, access-group-index.test.ts
  • src/services/indexing/storage-scan-cache.test.ts, memory-scan-cache.test.ts, browser-cache.test.ts
  • src/services/domain/inbox-service.test.ts
  • e2e/journeys/invite-and-inbox.journey.ts, e2e/journeys/contract-sets.journey.ts