All guides

Diamond Security Checklist

Practical review checklist for EIP-2535 contracts: ownership of diamondCut, selector clashes, initializer safety, and storage collision risks.

12 min read

This is a practical review checklist for EIP-2535 contracts, whether you are auditing your own diamond before an upgrade or assessing someone else's before integrating. It assumes familiarity with how diamonds work; each item explains what to check and why it matters.

This checklist is a starting point for review, not a substitute for a professional audit. It cannot tell you whether a specific contract is safe.

1. Who controls diamondCut?

This is the first and most important question about any diamond. Whoever can call diamondCut can replace any function with arbitrary code, and therefore can drain anything the contract holds.

// Who can call diamondCut? Read it directly:
cast call $DIAMOND "owner()(address)" --rpc-url $RPC

// Then check whether that address is an EOA or a multisig:
cast code $OWNER --rpc-url $RPC   // "0x" means EOA
  • A single EOA means one private key can rewrite the protocol. For a contract holding user funds this is a critical finding, not a nitpick.
  • A multisig is better. Check the threshold and the signer count — a 1-of-3 is an EOA wearing a hat.
  • A timelock is better still, because users get warning and an exit window. Check the delay is long enough to actually act on.
  • Zero address / no cut facet means the diamond is immutable. Safe from upgrades, but also unfixable.

2. Is every facet verified?

List the facets in Louper and check each one on a block explorer. An unverified facet is code you cannot read that can move the protocol's money. Note that verification of the diamond tells you nothing about the facets — they are separate contracts.

Louper marks functions it cannot resolve as unknown_0x.... A handful of those on a peripheral facet may be benign; a core facet full of them warrants stopping.

3. Are the loupe functions intact?

# Does every facet still resolve? Any zero address is a dead selector.
cast call $DIAMOND "facetAddress(bytes4)(address)" 0x1f931c1c --rpc-url $RPC

# Is the cut function still present? (0x1f931c1c = diamondCut)
# A zero result means the diamond is now immutable.

A diamond that has lost facets() cannot be enumerated by tooling, which makes ongoing monitoring effectively impossible. It also usually indicates a botched upgrade.

4. Storage layout discipline

Storage collisions are the highest-severity bug class unique to diamonds, and they fail silently. See diamond storage patterns for the mechanics. During review, confirm:

  • No facet declares ordinary state variables outside the agreed pattern. With AppStorage, the shared struct must be the first and only state variable in every facet.
  • Diamond Storage slots are derived from distinct, namespaced strings — not from short or guessable constants, and never reused across modules.
  • Struct changes across versions are strictly append-only. No insertions, no reordering, no type changes, no repacking.
  • Nested structs that may grow have reserved gaps.
  • Storage layouts were diffed mechanically (e.g. forge inspect storage-layout), not eyeballed.

5. Initialisation safety

Init contracts run via delegatecall with full access to diamond storage. They are the most privileged code in the system and the most frequently overlooked.

// UNSAFE: can be called again by anyone if left registered
contract Init {
    function init() external {
        LibDiamond.setContractOwner(msg.sender);  // takeover vector
    }
}

// SAFER: single purpose, no privilege changes, guarded
contract InitV2 {
    function init() external {
        AppStorage storage s = LibAppStorage.diamondStorage();
        require(s.version == 1, "already migrated");
        s.rate = 500;
        s.version = 2;
    }
}
  • Init functions must not be registered as diamond selectors.
  • They should be idempotency-guarded, or provably callable only once.
  • They should never set or reset ownership outside the very first deployment.
  • Constructor logic in a facet is dead code — it never runs under delegatecall.

6. Selector hygiene

  • No unexpected selectors. Diff the live selector set against what the source is supposed to export; anything extra deserves an explanation.
  • No leftover selectors from removed facets still pointing at old addresses — a partial Replace is easy to do by accident.
  • Watch for functions that look administrative but are not access-controlled. In a diamond these are easy to miss because they are spread across many files.

7. Access control consistency

Each facet enforces its own permissions. There is no central place where the compiler checks that every sensitive function is guarded, so gaps are easy to introduce — particularly when a new facet is written by someone who did not write the original ones.

  • Enumerate every state-changing function and record which modifier guards it.
  • Confirm all facets read roles from the same storage location. Two facets using different ownership libraries is a real and recurring bug.
  • Check that pause or emergency-stop logic actually covers the functions that matter.

8. Upgrade history

Every structural change emits a DiamondCut event. The log is a public audit trail:

  • How often has this diamond been cut, and by whom?
  • Were any cuts made from an EOA rather than the expected governance address?
  • Do any cuts remove loupe or ownership selectors?
  • Were facets verified before or only after being cut in?

9. External call surface

Because facets share the diamond's storage and identity, a reentrancy guard in one facet does not protect another unless they share guard state. Confirm that reentrancy protection is stored in shared storage rather than per-facet, and that msg.sender and msg.value are handled consistently across facets — under delegatecall they refer to the original caller and value, which surprises people who expect proxy-like isolation.

10. Monitoring after deployment

  • Alert on every DiamondCut event for diamonds you depend on.
  • Alert on ownership transfers of the cut facet.
  • Periodically re-run the loupe and diff the selector table against a known-good snapshot. Louper's JSON export makes this scriptable.

Quick triage

If you have five minutes and need a rough read on an unfamiliar diamond:

  1. Open it in Louper and check how many facets are unverified.
  2. Read owner() and check whether it is an EOA.
  3. Confirm diamondCut is still registered and note who can call it.
  4. Skim the facet names for anything that sounds like an escape hatch.

That will not find subtle bugs, but it reliably surfaces the two failure modes that account for most real-world losses: an unverified facet, and a single key controlling upgrades.