Somnia Accounts / How-to guides
How to check an account is healthy
This guide shows you how to ask whether an account is configured the way it should be, and how to present the answer without overstating it. It assumes a read-only client.
Two halves, on purpose
readHealthFacts gathers facts off a chain. evaluateHealth judges them, and is pure — so the
conditions are testable without a chain, and a UI can re-evaluate without re-reading.
import {evaluateHealth, readHealthFacts} from "@somnia-chain/accounts-sdk";
const facts = await readHealthFacts(client, {account, chainId});
const report = evaluateHealth(facts);
report.band; // "critical" | "high" | "medium" | "low" | "info" | null
report.findings; // only the checks that found something
report.inconclusive; // how many could not be evaluated
Read the band, not a score
band is the worst finding's severity, or null when there are none. It is deliberately not an
average: nine passes and one catastrophe average to green, and green is what a user acts on.
A critical finding caps the band at critical no matter how much else passed.
inconclusive is not a pass
Every fact on HealthFacts can be undefined, meaning the read did not answer — distinct from
any particular value. A check over a fact that is missing comes back inconclusive rather than
ok, and report.inconclusive counts them.
Show that number. Folding it into the band would let an account with unreadable state present as healthy, which is the failure this shape exists to prevent.
import {evaluateHealth, readHealthFacts} from "@somnia-chain/accounts-sdk";
const report = evaluateHealth(await readHealthFacts(client, {account, chainId}));
if (report.band === null && report.inconclusive > 0) {
// "Nothing wrong found, but N checks could not run" — NOT "healthy".
}
Across chains
The account has the same address on every chain and different state behind it, which is where most real findings come from: a grant that ran out of gas on one network.
import {evaluateHealth, readHealthFacts} from "@somnia-chain/accounts-sdk";
const facts = await readHealthFacts(client, {
account,
chainId,
// Compare against the other chains. Findings then include "this chain disagrees".
across: [{client, chainId: 50312}],
chainName: (id) => (id === 5031 ? "Somnia" : `chain ${id}`),
});
void evaluateHealth(facts);
chainName is there so a finding reads as a sentence about a network rather than about a number.
What the facts cover
| Fact | Why it is checked |
|---|---|
deployed |
code at the address on this chain |
owners, threshold |
the authority, and whether it is the shape you intended |
accountOwners |
which owners are themselves accounts |
version |
the Safe version behind the proxy |
modules, moreModules |
anything with module authority. moreModules means the list was a prefix, so "no modules" cannot be concluded |
guard, moduleGuard |
policy contracts that can veto |
fallbackHandler |
what answers isValidSignature — without one, EIP-1271 does not work at all |
singleton |
the implementation in the proxy's slot 0 |
expected |
what the factory installs today, to compare against |
| derivation | the key and index the address was proven to come from |
moreModules is the subtle one: a truncated list that read as complete would report "no modules" on
an account that has one. configOf reads modules a page at a time — ten by default — and sets
moreModules when the page came back full, so an account with more than ten needs another read
before "these are its modules" is a true sentence. Pass modulePageSize to widen it.
Assembling facts yourself
If you already hold some of this — an owner set you read for another panel — skip the re-read:
import {assembleHealthFacts, evaluateHealth} from "@somnia-chain/accounts-sdk";
// Every part is nameable, and `undefined` means "this was not read" rather than "nothing there" —
// so a panel that already holds the owner set can judge on it without re-reading, and the checks
// it could not run come back inconclusive instead of passing.
const facts = assembleHealthFacts({
account,
chainId,
deployed: true,
owners,
config: undefined,
expected: undefined,
custody: undefined,
setups: undefined,
});
void evaluateHealth(facts);
Presenting the checks
Each entry in report.checks is a Check:
import {compareSeverity, type Check} from "@somnia-chain/accounts-sdk";
function render(checks: readonly Check[]) {
// Worst first. Exported so a view does not restate the severity order.
return [...checks]
.sort((a, b) => compareSeverity(a.severity, b.severity))
.map((c) => ({
title: c.title, // names the CHECK — "Across chains"
headline: c.headline, // names the ANSWER, and is the line to show when there is room for one
status: c.status, // "ok" | "finding" | "inconclusive"
severity: c.severity, // meaningful only on a finding; an `ok` check carries "info"
}));
}
void render;
title and headline are deliberately separate. A card showing the title read "Across chains",
which names the question rather than the answer — headline is the answer.
simpleMajority(owners) is Safe's own definition, floor(n/2) + 1, exported because the even case
is the one that gets guessed wrong: 2-of-4 is not a majority, it is a tie either half can break.
The stranded state
An account whose owners are all rotated away is reachable, funded, and controlled by nobody you know. The address survives owner rotation, so this is a real state rather than a theoretical one, and there is no step out of it. Health reports it; nothing can fix it.