Wallet and network
Certichain has no accounts and no passwords. A person's identity in the app is the address of their Ethereum wallet, and every action that changes the chain or proves who someone is needs that wallet: a signature (a free, off-chain message signed with the wallet's key) or a transaction (an on-chain write that costs gas). Reading and verifying never need a wallet.
The app supports an injected EIP-1193 wallet, the window.ethereum object that MetaMask and the in-app browsers of wallet apps provide. There is no WalletConnect integration.
WalletService
WalletService (src/services/wallet/wallet-service.ts) is the only code that talks to the wallet. It looks the wallet up on every call, because extensions can inject window.ethereum after the page has loaded.
| Method | What it does | Wallet request |
|---|---|---|
isAvailable() | True when a wallet object exists | none |
connect() | Asks for accounts, keeps a BrowserProvider and signer, returns { address } | eth_requestAccounts |
signer() | The current ethers signer, or null before connect() | none |
address() | The current account; throws "Signer not available" before connect() | none |
signMessage(message) | Personal-sign message; used for the server sign-in (SIWE) message | personal_sign through ethers |
signTypedData(domain, types, value) | EIP-712 typed data signature: issuer signatures, invitations, disclosures, badge key attestations, and the seed signatures that wallet-derived keys come from | eth_signTypedData_v4 |
chainId() | The wallet's current chain as a number | eth_chainId |
switchChain(chainId) | Asks the wallet to switch | wallet_switchEthereumChain with the hex chain id |
network() | A small provider whose getNetwork() asks the wallet for eth_chainId each time | eth_chainId |
onChange(listener) | Subscribes to accountsChanged and chainChanged; returns an unsubscribe function | event listeners |
Two details matter for correctness:
- The provider is created with
new BrowserProvider(wallet, "any")(createWalletProviderinsrc/lib/chain.ts). The"any"network lets ethers follow a chain switch instead of failing with a "network changed" error. network()and the chain guard readeth_chainIdstraight from the wallet (walletNetwork), because a provider's cached network can lag behind a switch. A write is therefore always checked against the chain the wallet is on at that moment.
When the wallet reports new accounts, onChange rebuilds the signer from the same provider before it tells listeners. An empty account list clears the connection.
Errors
Error (src/services/wallet/errors.ts) | Message | When |
|---|---|---|
WalletUnavailableError | "No wallet detected. Install MetaMask to continue." | Any call that needs the wallet when window.ethereum is missing |
WalletRejectedError | "The wallet request was rejected." | connect() when the user refuses |
isUserRejection(error) recognises a refusal by the EIP-1193 code 4001, the ethers code "ACTION_REJECTED", or a nested info.error.code of 4001. Pages translate these errors into their own words; for example /login shows "MetaMask is not installed. Please install it to continue." and "Connection cancelled. Please try again."
Methods that need a connection throw a plain Error ("Wallet not connected" or "Signer not available") when called before connect().
The wallet session hook
Pages that depend on the account use useWalletSession() (src/services/react/use-wallet-session.ts). It calls wallet.connect() once when the component mounts and then follows accountsChanged.
| Status | Meaning | Typical page reaction |
|---|---|---|
connecting | The first connect() is in flight | Show a skeleton, do not redirect yet |
connected | address holds the account | Check the role and render |
disconnected | The wallet has no accounts | Gated pages redirect to /login |
unavailable | No wallet. The hook logs "No wallet in this browser; pages that only read still work." with console.info | Gated pages redirect; read-only pages work |
rejected | The user refused the connection | Gated pages redirect |
failed | Another error, logged with console.error | Gated pages redirect |
The hook does not react to chainChanged; the network banner and the chain guard handle the chain.
Network guard
A wallet on the wrong chain would sign and send transactions to the wrong network. The app prevents this in two places.
The write guard
The composition root builds one chainGuard and gives it to every gateway (src/services/index.ts):
- If
NEXT_PUBLIC_NETWORK_IDis not a number, it throwsChainConfigError("Blockchain service is not configured"). - Otherwise
assertCorrectChain(wallet.network(), expected)readseth_chainIdfrom the wallet and throwsWrongNetworkError("Switch your wallet to Sepolia to continue", with the network name from the chain id) when the two differ.
Every gateway writer() (v0.2 contract, v0.3 registries, access gate) awaits the guard before it returns a contract bound to the signer, so no transaction is ever built for the wrong chain. Reads are not affected: they never use the wallet's chain (see Reading the chain).
Network names come from src/lib/chain.ts: 1 is "Ethereum mainnet", 11155111 is "Sepolia", 31337 is "Hardhat local", and any other id is "chain N".
The network banner
NetworkBanner (src/components/layout/NetworkBanner.tsx) sits under the header on every page.
- When a wallet exists and the expected chain can be read, it compares
wallet.chainId()withNEXT_PUBLIC_NETWORK_IDand listens forchainChanged. - On a mismatch it shows a non-dismissable
role="alert"strip: "Your wallet is on the wrong network. Actions that write to the blockchain are disabled until you switch to Sepolia." (with the configured network's name) and a Switch network button. - Switch network calls
wallet.switchChain(expected). If the wallet refuses or does not know the chain, the banner adds "Could not switch network. Please switch in your wallet." - When
NEXT_PUBLIC_NETWORK_IDcannot be read, the banner disables itself and logs "Network guard disabled: ..." withconsole.warn. The write guard still refuses writes in that case.
What prompts the user sees
A wallet prompt is either a signature or a transaction. The tables in each flow page list the prompts of that flow in order; the kinds are:
| Prompt | Kind | Example |
|---|---|---|
| Connect | Account access | /login, any gated page on first visit |
| Sign-in message | Personal signature (SIWE) | First upload or document lookup in a session; see Sign-in and sessions |
| Typed data | EIP-712 signature | Issuer signature over a credential, an invitation, a disclosure attestation |
| Seed signature | EIP-712 signature, asked for twice and required to match | Deriving the wallet's document keys for the page session (src/services/documents/wallet-key-session.ts) or an access identity (AccessRequesterService.walletSeeds) |
| Transaction | On-chain write, costs gas | Issue, revoke, redeem an invitation, register a record type, pause |
| Switch chain | Wallet setting | Switch network |
A smart contract wallet cannot produce the repeatable signatures some features derive keys from. For example /access refuses with "A smart contract wallet cannot make an access identity from its signature. Make a new identity and keep its file instead." Issuer signatures by contract wallets are checked on chain through ERC-1271, but issuing through the site with a contract wallet is not supported today. See Signatures.
Source files
src/services/wallet/wallet-service.ts,src/services/wallet/errors.ts,src/services/wallet/types.tssrc/lib/chain.tssrc/services/react/use-wallet-session.tssrc/components/layout/NetworkBanner.tsxsrc/services/index.ts(thechainGuard)src/app/login/page.tsx
Tests
src/services/wallet/wallet-service.test.tssrc/lib/chain.test.tssrc/services/react/use-wallet-session.test.tsxsrc/components/layout/NetworkBanner.test.tsxe2e/support/provides the injected test wallet the browser journeys use; see End-to-end tests.