How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades
How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades
ERC 2535 (the Diamond standard) is the most structurally complex upgradeable contract pattern in Solidity. A single proxy contract delegates calls to multiple implementation contracts called facets. Each facet handles a different set of function selectors, and the diamond can be upgraded by adding, replacing, or removing facets through diamondCut(). This gives protocols modular upgradeability, but it also creates a class of bugs that traditional contract testing misses. Selector collisions, storage slot corruption across facets, and inconsistent upgrade sequences all fall into this category.
Fuzzing diamond proxies means verifying structural integrity across upgrades. The invariants are about selector routing, storage isolation, and the consistency between the diamond's internal mapping and what the loupe functions report. This guide walks through those invariants, a Chimera setup, and handlers that exercise the upgrade surface.
<a href="/request-audit" class="cta-button">Get expert diamond proxy fuzzing</a>
What ERC 2535 guarantees
The EIP-2535 specification defines a proxy pattern that maintains a mapping from function selectors to facet addresses. The diamondCut() function modifies this mapping by adding new selector/facet pairs, replacing existing ones, or removing selectors entirely. Four loupe functions provide read access to the current configuration: facets(), facetFunctionSelectors(), facetAddresses(), and facetAddress().
The guarantees that matter most for security:
- Selector uniqueness. No two facets can claim the same function selector. If two facets both implement
transfer(address,uint256), the diamond must reject the conflict duringdiamondCut(). - Loupe/routing consistency. The loupe functions must always reflect the actual routing table. If
facetAddress(selector)returns facet A, calling that selector must delegate to facet A. - Storage isolation. Facet A's storage writes must not corrupt facet B's state. Since all facets share the diamond's storage, this requires disciplined use of storage slots.
diamondCutatomicity. A multi-facet upgrade must be all-or-nothing. If the third facet addition in a batch fails, the first two must not be applied.- No dangling selectors. After removing a facet, none of its selectors should remain routable. Calls to removed selectors must revert.
- Initialization safety. An initializer function called during
diamondCut()must not be callable again. Re-initialization can reset state or bypass access controls.
These properties are hard to test with unit tests because the bugs emerge from sequences of upgrades, not from individual operations. A diamond that passes every isolated test can still corrupt storage after a specific sequence of facet additions and removals.
Key invariants
Selector uniqueness
Every active selector maps to exactly one facet address. No duplicates exist in the routing table.
for all selectors s in facets():
count(s) == 1
Loupe/routing consistency
The loupe functions and the actual delegatecall routing must agree. If facetAddress(s) returns address A, then calling selector s on the diamond must execute code at address A.
facetAddress(s) == address(facet that handles s)
Storage isolation
Writing a value through facet A must not change any storage slot owned by facet B. We verify this by reading facet B's state before and after an operation on facet A.
diamondCut atomicity
If a diamondCut() call includes multiple facet operations and one of them reverts, the entire cut must revert. No partial upgrades.
No dangling selectors after removal
After removing a facet, every selector that was assigned to that facet must revert when called. The loupe functions must no longer list those selectors.
after remove(facetA):
facetAddress(selectorFromA) == address(0)
call(diamond, selectorFromA) -> revert
Setting up Chimera
We use the Chimera framework with a setup that includes a diamond, several facets, and the infrastructure to perform upgrades. The beginner's guide covers basic installation.
The file structure:
test/
invariants/
Setup.sol // Deploy diamond + initial facets
TargetFunctions.sol // diamondCut, facet calls, storage ops
Properties.sol // Selector, loupe, and storage checks
CryticTester.sol // Entrypoint for Echidna/Medusa
mocks/
FacetA.sol // Test facet with storage
FacetB.sol // Another test facet with storage
FacetC.sol // Facet for dynamic add/remove testing
Setup
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Diamond} from "src/Diamond.sol";
import {IDiamondCut} from "src/interfaces/IDiamondCut.sol";
import {IDiamondLoupe} from "src/interfaces/IDiamondLoupe.sol";
import {FacetA} from "./mocks/FacetA.sol";
import {FacetB} from "./mocks/FacetB.sol";
import {FacetC} from "./mocks/FacetC.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";
abstract contract Setup is BaseSetup {
Diamond internal diamond;
FacetA internal facetA;
FacetB internal facetB;
FacetC internal facetC;
IDiamondLoupe internal loupe;
// Track known selectors for verification
bytes4[] internal knownSelectors;
mapping(bytes4 => bool) internal selectorActive;
function setup() internal virtual override {
facetA = new FacetA();
facetB = new FacetB();
facetC = new FacetC();
// Build initial diamond cut with facetA and facetB
IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](2);
bytes4[] memory selectorsA = new bytes4[](2);
selectorsA[0] = FacetA.setValue.selector;
selectorsA[1] = FacetA.getValue.selector;
bytes4[] memory selectorsB = new bytes4[](2);
selectorsB[0] = FacetB.setFlag.selector;
selectorsB[1] = FacetB.getFlag.selector;
cuts[0] = IDiamondCut.FacetCut({
facetAddress: address(facetA),
action: IDiamondCut.FacetCutAction.Add,
functionSelectors: selectorsA
});
cuts[1] = IDiamondCut.FacetCut({
facetAddress: address(facetB),
action: IDiamondCut.FacetCutAction.Add,
functionSelectors: selectorsB
});
diamond = new Diamond(cuts, address(0), "");
loupe = IDiamondLoupe(address(diamond));
// Track initial selectors
for (uint256 i = 0; i < selectorsA.length; i++) {
knownSelectors.push(selectorsA[i]);
selectorActive[selectorsA[i]] = true;
}
for (uint256 i = 0; i < selectorsB.length; i++) {
knownSelectors.push(selectorsB[i]);
selectorActive[selectorsB[i]] = true;
}
}
}
We deploy the diamond with two facets (A and B) that have non-overlapping selectors. FacetC is available for dynamic addition/removal during fuzzing. The knownSelectors array and selectorActive mapping track the expected routing state.
Writing properties
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Setup} from "./Setup.sol";
import {IDiamondLoupe} from "src/interfaces/IDiamondLoupe.sol";
abstract contract Properties is Setup {
// P-1: Selector uniqueness - no selector maps to two facets
function invariant_selectorUniqueness() public view returns (bool) {
IDiamondLoupe.Facet[] memory allFacets = loupe.facets();
for (uint256 i = 0; i < allFacets.length; i++) {
for (uint256 j = 0; j < allFacets[i].functionSelectors.length; j++) {
bytes4 sel = allFacets[i].functionSelectors[j];
// Check this selector doesn't appear in any other facet
for (uint256 k = i + 1; k < allFacets.length; k++) {
for (uint256 l = 0; l < allFacets[k].functionSelectors.length; l++) {
if (allFacets[k].functionSelectors[l] == sel) {
return false;
}
}
}
}
}
return true;
}
// P-2: Loupe consistency - facetAddress matches facets() listing
function invariant_loupeConsistency() public view returns (bool) {
IDiamondLoupe.Facet[] memory allFacets = loupe.facets();
for (uint256 i = 0; i < allFacets.length; i++) {
for (uint256 j = 0; j < allFacets[i].functionSelectors.length; j++) {
bytes4 sel = allFacets[i].functionSelectors[j];
address reported = loupe.facetAddress(sel);
if (reported != allFacets[i].facetAddress) return false;
}
}
return true;
}
// P-3: No dangling selectors - removed selectors map to address(0)
function invariant_noDanglingSelectors() public view returns (bool) {
for (uint256 i = 0; i < knownSelectors.length; i++) {
bytes4 sel = knownSelectors[i];
address facet = loupe.facetAddress(sel);
if (!selectorActive[sel] && facet != address(0)) {
return false;
}
if (selectorActive[sel] && facet == address(0)) {
return false;
}
}
return true;
}
// P-4: Facet address list has no zero address
function invariant_noZeroFacet() public view returns (bool) {
address[] memory addrs = loupe.facetAddresses();
for (uint256 i = 0; i < addrs.length; i++) {
if (addrs[i] == address(0)) return false;
}
return true;
}
// P-5: facetFunctionSelectors is consistent with facets()
function invariant_facetSelectorsConsistency() public view returns (bool) {
address[] memory addrs = loupe.facetAddresses();
for (uint256 i = 0; i < addrs.length; i++) {
bytes4[] memory sels = loupe.facetFunctionSelectors(addrs[i]);
if (sels.length == 0) return false; // Active facet must have selectors
}
return true;
}
}
P-1 is the core structural check. It iterates all facets and verifies no selector appears twice. P-2 cross-checks the individual facetAddress() lookups against the batch facets() return. P-3 uses our ghost tracking to verify that removed selectors are actually gone.
Writing target functions
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Properties} from "./Properties.sol";
import {IDiamondCut} from "src/interfaces/IDiamondCut.sol";
import {FacetA} from "./mocks/FacetA.sol";
import {FacetC} from "./mocks/FacetC.sol";
abstract contract TargetFunctions is Properties {
// --- Facet operations through the diamond ---
function handler_setValueA(uint256 val) public {
FacetA(address(diamond)).setValue(val);
}
function handler_getValueA() public view {
FacetA(address(diamond)).getValue();
}
// --- diamondCut: Add facetC ---
function handler_addFacetC() public {
bytes4[] memory selectorsC = new bytes4[](2);
selectorsC[0] = FacetC.setData.selector;
selectorsC[1] = FacetC.getData.selector;
// Skip if already added
if (selectorActive[selectorsC[0]]) return;
IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1);
cuts[0] = IDiamondCut.FacetCut({
facetAddress: address(facetC),
action: IDiamondCut.FacetCutAction.Add,
functionSelectors: selectorsC
});
IDiamondCut(address(diamond)).diamondCut(cuts, address(0), "");
for (uint256 i = 0; i < selectorsC.length; i++) {
knownSelectors.push(selectorsC[i]);
selectorActive[selectorsC[i]] = true;
}
}
// --- diamondCut: Remove facetC ---
function handler_removeFacetC() public {
bytes4[] memory selectorsC = new bytes4[](2);
selectorsC[0] = FacetC.setData.selector;
selectorsC[1] = FacetC.getData.selector;
// Skip if not active
if (!selectorActive[selectorsC[0]]) return;
IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1);
cuts[0] = IDiamondCut.FacetCut({
facetAddress: address(0),
action: IDiamondCut.FacetCutAction.Remove,
functionSelectors: selectorsC
});
IDiamondCut(address(diamond)).diamondCut(cuts, address(0), "");
for (uint256 i = 0; i < selectorsC.length; i++) {
selectorActive[selectorsC[i]] = false;
}
}
// --- diamondCut: Replace facetA with new implementation ---
function handler_replaceFacetA() public {
FacetA newFacetA = new FacetA();
bytes4[] memory selectorsA = new bytes4[](2);
selectorsA[0] = FacetA.setValue.selector;
selectorsA[1] = FacetA.getValue.selector;
IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1);
cuts[0] = IDiamondCut.FacetCut({
facetAddress: address(newFacetA),
action: IDiamondCut.FacetCutAction.Replace,
functionSelectors: selectorsA
});
IDiamondCut(address(diamond)).diamondCut(cuts, address(0), "");
}
// --- Storage isolation check ---
function handler_storageIsolation(uint256 newVal) public {
newVal = bound(newVal, 1, type(uint256).max);
// Read facetB state before
bool flagBefore = FacetA(address(diamond)).getValue() > 0;
// Not reading flagBefore from FacetB since it would be a bool
// Snapshot FacetB storage via getFlag
(bool ok, bytes memory data) = address(diamond).staticcall(
abi.encodeCall(FacetB.getFlag, ())
);
if (!ok) return;
bool flagBeforeB = abi.decode(data, (bool));
// Write through FacetA
FacetA(address(diamond)).setValue(newVal);
// Read FacetB state after
(ok, data) = address(diamond).staticcall(
abi.encodeCall(FacetB.getFlag, ())
);
if (!ok) return;
bool flagAfterB = abi.decode(data, (bool));
// FacetB state must be unchanged
assert(flagBeforeB == flagAfterB);
}
// --- Add/remove cycle ---
function handler_addRemoveCycle() public {
handler_addFacetC();
// Write some data through facetC
(bool ok, ) = address(diamond).call(
abi.encodeCall(FacetC.setData, (42))
);
if (!ok) return;
handler_removeFacetC();
// Calling removed selector must revert
(ok, ) = address(diamond).call(
abi.encodeCall(FacetC.getData, ())
);
assert(!ok); // Must revert
}
}
The handlers here exercise the three diamondCut actions: Add, Replace, and Remove. The key patterns:
handler_addFacetC/handler_removeFacetCtoggle a facet and update the ghost state. The fuzzer can call these in any order, testing repeated add/remove cycles.handler_replaceFacetAdeploys a fresh implementation and replaces the existing facet. This tests that storage persists across implementation swaps.handler_storageIsolationis an inline assertion that writes through one facet and verifies another facet's state is unchanged.handler_addRemoveCyclecompresses the full lifecycle into one call, verifying that removed selectors actually revert.
Running the campaign
Foundry (quick smoke test)
forge test --match-contract CryticTester --fuzz-runs 10000
Diamond tests are relatively cheap per-call since the operations are lightweight. 10,000 runs gives good coverage.
Medusa (broad coverage)
{
"fuzzing": {
"targetContracts": ["CryticTester"],
"testLimit": 500000,
"callSequenceLength": 100,
"workers": 8,
"corpusDirectory": "corpus-medusa"
}
}
medusa fuzz
Medusa's parallel workers will explore different upgrade sequences simultaneously. This is where you find bugs in the interaction between multiple diamondCut calls.
Echidna (deep exploration)
# echidna.yaml
testMode: assertion
testLimit: 2000000
seqLen: 150
corpusDir: "corpus-echidna"
echidna . --contract CryticTester --config echidna.yaml
Echidna's sequence shrinking is especially useful for diamond bugs. When a property fails after a long upgrade sequence, the shrunk result shows the minimal set of diamondCut calls needed to reproduce. For more on choosing fuzzers, see our comparison guide.
Interpreting results
Selector collision
If invariant_selectorUniqueness fails, two facets claim the same selector. This means diamondCut() allowed an Add operation for a selector that was already registered. Calls to that selector will route to the last facet the diamond registered, silently breaking the other facet's functionality.
Loupe/routing mismatch
If invariant_loupeConsistency fails, the loupe functions report a different state than the actual routing table. This is dangerous because tools and UIs rely on loupe functions to display the diamond's configuration. A mismatch means the diamond's observable state doesn't match its behavior.
Storage corruption
If handler_storageIsolation asserts false, writing through one facet corrupted another facet's storage. Common causes:
- Shared storage positions. Two facets using
slot 0for different purposes. - Struct packing overlap. A facet that extends its storage struct past the boundary of another facet's reserved range.
- Missing ERC-7201 namespacing. Without namespaced storage, facet developers must manually coordinate slot assignments.
Dangling selectors
If invariant_noDanglingSelectors fails after a removal, the diamondCut Remove action didn't fully clean up. Some selector references remain in the routing table. This can leave orphaned functionality accessible through the diamond.
Combining with ERC-7201 namespaced storage
ERC-7201 defines a convention for namespaced storage that prevents slot collisions between facets. Each facet derives its storage position from a unique namespace string.
bytes32 constant FACET_A_STORAGE = keccak256(
abi.encode(uint256(keccak256("facetA.storage")) - 1)
) & ~bytes32(uint256(0xff));
If your diamond uses ERC-7201, add a property that verifies each facet's storage position derives from its expected namespace. You can also add a handler that deploys a new facet with a colliding namespace. Verify that the diamond either rejects it or that the storage isolation property still holds.
Combining ERC 2535 selector-level properties with ERC-7201 storage-level properties gives you full coverage of the diamond's integrity from the routing table down to the storage layout.
Beyond the basics
Once your core diamond properties pass, consider:
- Test initialization replay. Add a handler that calls the initializer function a second time after a
diamondCutand verify it reverts. - Test access control on
diamondCut. Add a handler where a non-owner tries to calldiamondCut()and verify it reverts. - Test large batch upgrades. Create a handler that performs all three cut actions in a single
diamondCut()call and verify atomicity. - Run in CI. Set up continuous fuzzing to catch regressions when facets change.
Diamond proxies are one of the most structurally complex patterns in Solidity. The bugs are rarely in individual facets. They live in the interactions between facets, in upgrade sequences, and in the gap between what the loupe reports and what the diamond actually does.
If you're building with ERC 2535 and want a complete property suite covering selectors and storage across upgrades, request an audit with Recon. We've fuzzed diamond implementations across DeFi and know where the structural failures hide.