Smart Contract Development with Diamond Standard (EIP-2535)
The contract grew to 23KB — the EVM size limit (EIP-170: 24KB for deployed bytecode). There's nowhere to add another feature. Rewriting everything is a migration, downtime, loss of transaction history, and potentially millions in TVL at risk. Diamond Standard (EIP-2535) solves this systematically: instead of one monolithic contract — one Diamond proxy with unlimited facets, each carrying part of the logic.
How Diamond Works: Routing via Fallback
A Diamond contract itself contains minimal logic. Its fallback() intercepts all calls, looks in DiamondStorage — a mapping from function selector to facet address, and delegates the call to the right facet via delegatecall.
fallback() external payable {
DiamondStorage storage ds = diamondStorage();
address facet = ds.selectorToFacet[msg.sig];
require(facet != address(0), "Diamond: function not found");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
All state is stored in the Diamond (because delegatecall executes facet code in the Diamond's storage context). Facets are stateless logic. This means all facets share the same storage space, which creates the main Diamond-specific problem.
Storage Collision: The Most Dangerous EIP-2535 Trap
In the standard proxy pattern (EIP-1967), storage collision is solved by the proxy storing the implementation address at a pre-known slot, calculated as keccak256('eip1967.proxy.implementation') - 1. In Diamond, multiple facets share the contract's entire storage.
If Facet A declares uint256 public totalSupply in slot 0, and Facet B declares address public owner in slot 0 — when reading owner, it returns the interpretation of totalSupply as an address. The contract works "without errors" and gives incorrect results.
Diamond Storage Pattern — the solution from EIP-2535: each facet stores its data in a named struct, placed at a pseudo-random storage slot:
library LibToken {
bytes32 constant STORAGE_POSITION =
keccak256("diamond.storage.token.v1");
struct TokenStorage {
uint256 totalSupply;
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;
}
function tokenStorage() internal pure returns (TokenStorage storage ts) {
bytes32 position = STORAGE_POSITION;
assembly {
ts.slot := position
}
}
}
Each facet uses LibToken.tokenStorage() instead of direct variables. Collision is only possible if two different STORAGE_POSITION values match — using unique strings makes this practically impossible.
DiamondCut: Adding and Replacing Facets
Facet management happens through diamondCut() — the only function that changes the Diamond's routing table. This is the control point for upgrades.
struct FacetCut {
address facetAddress;
FacetCutAction action; // Add, Replace, Remove
bytes4[] functionSelectors;
}
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
_init + _calldata — optional: contract address and calldata to be called via delegatecall immediately after changing facets. Used for storage migration when replacing facets (analogous to OpenZeppelin's upgradeAndCall).
The right to call diamondCut must be protected. Standard pattern: OwnershipFacet controls access, diamondCut available only to owner. For DAO-governed protocols — governance via TimelockController + Governor, which calls diamondCut after voting.
Comparison with Alternative Proxy Patterns
| Pattern | Size Limit | Upgradeability | Complexity | Gas Overhead |
|---|---|---|---|---|
| Transparent Proxy (EIP-1967) | 24KB for logic | Full replacement | Low | ~2000 gas |
| UUPS (EIP-1822) | 24KB for logic | Full replacement | Medium | ~1500 gas |
| Beacon Proxy | 24KB, one beacon | Group replacement | Medium | ~2500 gas |
| Diamond (EIP-2535) | Unlimited | Partial replacement | High | ~3000 gas |
Diamond is not always the right choice. For a contract under 15KB with simple logic, UUPS is simpler and cheaper. Diamond is justified when: the contract is already close to the size limit, granular upgradeability is needed (update only one module without replacing everything), or logic is developed by multiple teams independently.
Tooling and Diamond Auditing
Louper.dev — UI for inspecting Diamond contracts. Shows all facets, their function selectors, addresses. Essential tool for auditors and developers.
hardhat-diamond-abi — collects ABI from all facets into one file. Needed for frontend — frontend sees one contract, not multiple facets.
Nick Mudge's diamond-3 — reference implementation from the EIP-2535 author. Use as a base, not copy-paste — it's important to understand every line.
Auditing Diamond contracts requires specific expertise: auditors check the storage layout of all facets for collisions, correctness of diamondCut access control, absence of selector clashes (two facets with one selector). Slither has partial Diamond support, but manual review is mandatory.
Development Process
Designing facet structure (2-3 days). Break logic into logical modules: TokenFacet, GovernanceFacet, RewardsFacet, AdminFacet. Design storage namespaces for each. This is the most important stage — reworking storage layout after deployment is catastrophic.
Development (1.5-2 weeks). Each facet is developed and tested in isolation. Integration tests — against the full Diamond.
Storage collision check. Before deployment, run a custom script that compares all STORAGE_POSITION values across all facets for uniqueness. Intersection — blocking error.
Deployment and verification. Diamond is deployed first, then each facet separately, then diamondCut initializes routing. Each facet is verified on Etherscan. Use Louper.dev for final configuration check.
Timelines: 1-2 weeks for systems with 3-5 facets, up to a month for large protocols with 10+ facets and complex governance.







