Upgrading a Diamond with diamondCut
A walkthrough of the diamondCut function: Add, Replace and Remove actions, initialization calls, and the mistakes that brick an upgrade.
diamondCut is the only way a diamond's structure changes. It adds, replaces and removes
selector-to-facet mappings, optionally running a migration in the same atomic transaction. Understanding
its rules is the difference between a routine upgrade and a permanently broken protocol.
The signature
enum FacetCutAction { Add, Replace, Remove }
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
/// @param _diamondCut Facets and selectors to add, replace or remove
/// @param _init Address of the contract to delegatecall for setup (or address(0))
/// @param _calldata Encoded call to execute on _init
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata); The whole array is applied in one transaction. If any single operation reverts, the entire
upgrade reverts — there is no partially-applied state. The DiamondCut event is mandatory,
and it is what lets indexers reconstruct a diamond's full history.
Add
Registers selectors the diamond does not currently have:
FacetCut[] memory cut = new FacetCut[](1);
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = StakingFacet.stake.selector;
selectors[1] = StakingFacet.unstake.selector;
cut[0] = FacetCut({
facetAddress: address(new StakingFacet()),
action: FacetCutAction.Add,
functionSelectors: selectors
});
IDiamondCut(diamond).diamondCut(cut, address(0), ""); Add reverts if:
- Any selector is already registered to any facet.
facetAddresshas no code. Deploying and cutting in the same transaction is fine, but passing an EOA or an undeployed address is not.- The selector array is empty.
Replace
Points existing selectors at a different facet. This is the normal path for a bug fix:
// Deploy the fixed facet, then repoint ONLY the selectors it changes.
address newFacet = address(new StakingFacetV2());
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = StakingFacet.stake.selector;
cut[0] = FacetCut({
facetAddress: newFacet,
action: FacetCutAction.Replace,
functionSelectors: selectors
}); Replace reverts if a selector is not currently registered, or if it already points at the facet you are naming. Note that replacing is per-selector, not per-facet: if the old facet owned ten selectors and you replace three, the other seven still route to the old contract. That split is legal and sometimes intentional, but it is rarely what people mean to do — always list every selector the new facet should own.
Remove
Deletes selectors entirely. Afterwards, calling them reverts in the fallback. The facet address must be the zero address:
// Remove REQUIRES facetAddress == address(0)
cut[0] = FacetCut({
facetAddress: address(0),
action: FacetCutAction.Remove,
functionSelectors: selectors
}); Removing is how you retire functionality, and it is also the escape hatch if a facet turns out
to be malicious — provided you still control diamondCut.
Initialisation
The _init and _calldata parameters let you run setup logic atomically with the structural change. The diamond delegatecalls _init, so the code runs against the diamond's own storage:
contract InitV2 {
function init() external {
AppStorage storage s = LibAppStorage.diamondStorage();
s.rate = 500; // seed the new field
s.version = 2;
}
}
IDiamondCut(diamond).diamondCut(
cut,
address(new InitV2()),
abi.encodeWithSignature("init()")
); Atomicity is the point. If you added a facet that reads s.rate and seeded that field
in a separate follow-up transaction, there would be a window — however brief — where the new function
is live and reading zero. On a public chain, someone will find that window.
Pass address(0) and empty calldata when no migration is needed. The reference
implementation requires that if _init is non-zero it must contain code, and it bubbles
up any revert from the init call.
Failure modes worth rehearsing
Removing diamondCut itself
diamondCut is a selector like any other, usually owned by DiamondCutFacet. Remove it and the diamond becomes permanently immutable. That is
occasionally a deliberate goal, but doing it by accident — for example by removing every
selector belonging to a facet without checking what is in it — is unrecoverable.
Removing the loupe
Less catastrophic but still bad: without facets(), external tooling can no longer
enumerate the diamond, and the contract stops being EIP-2535 compliant.
Init functions that can be re-run
An init that resets ownership or re-seeds balances, and is left registered and unguarded,
is a live takeover vector. Init contracts should be single-purpose, unregistered as diamond selectors,
and guarded if there is any chance of a second call.
Storage layout drift
diamondCut validates selectors. It knows nothing about storage. If your new facet
was compiled against a struct whose layout differs from the deployed one, the cut succeeds and
the data silently corrupts. See diamond storage patterns.
A pre-flight checklist
- Read the current selector table from chain — do not trust local deployment artifacts. Louper's facet view or its JSON export both work.
- Diff current selectors against the ones your new facets export, and classify each difference as Add, Replace or Remove explicitly.
- Confirm
diamondCutand all four loupe selectors survive the operation. - Diff the storage layout of every changed facet against the deployed version.
- Simulate the exact calldata against a mainnet fork, then read back known state values afterwards.
- Verify every new facet's source before the cut, so reviewers can see what was added.
After the upgrade, load the diamond in Louper and confirm the facet table matches what you intended. It reads live chain state, so it reflects what actually happened rather than what your script believed would happen.