Skip to main content

Design for Composition

Here are the guidelines and rules for creating composable facets.

Compose replaces source-code inheritance with on-chain composition. Facets are the building blocks; diamonds wire them together.

We focus on building small, independent, and easy-to-read facets. Each facet is deployed once, then reused and combined with other facets to form complete, modular smart contract systems.

Writing Facets

  1. A facet is a set of external functions that represent a single, self-contained unit of functionality.
  2. Each facet is a self-contained, conceptual unit.
  3. A facet is designed for all of its functions to be added, not just some of them.
  4. The facet is our smallest building block.
  5. The source code of a facet should only contain the code (including storage variables, if possible) that it actually uses.
  6. Facets are fully self-contained. They do not import anything.

Writing Facet Libraries

  1. Facet libraries are self-contained code units. They do not import anything.
  2. Each facet should have one corresponding facet library.
  3. Facet libraries are used to initialize facets on deployment and during upgrades.
  4. Facet libraries are also used to integrate custom facets with Compose facets.
  5. Facet libraries have an initialize function which is used to initialize storage variables during deployment.

Facets & Libraries

  1. Facets and facet libraries should not contain owner/admin authorization checks unless absolutely required or fundamental to the functionality being implemented. If permission or authorization checks are required, the facet should integrate with AccessControlFacet.

Extending Facets

  1. Every extension of a standard or facet should be implemented as a new facet.
  2. A facet should only be extended with a new facet that composes with it.
  3. Composition is done by facets sharing the same storage structs and containing complementary external functions.
  4. Two facets do not compose if they both have one or more of the same external function signatures (function name and parameter types).
  5. When reusing a struct from an existing facet, store it at the original diamond storage location and remove unused variables from the end of it. Variables must never be removed from the beginning or middle, as this would break storage compatibility.
  6. Storage structs should be designed so that removable variables (unused by some facets) appear at the end of the struct.
  7. If an unused variable cannot be removed from the end of a struct, it must remain to preserve compatibility.
  8. A facet that adds new storage variables must define its own diamond storage struct.
  9. Never add new variables to an existing struct.
Important

Maintain the same order of variables in structs when reusing them across facets or libraries. Unused variables may only be removed from the end of a struct.

Exceptions

There may be reasonable exceptions to these rules. If you believe one applies, please discuss it on
– Discord: https://discord.gg/DCBD2UKbxc
– GitHub Issues: https://github.com/Perfect-Abstractions/Compose/issues
– GitHub Discussions: https://github.com/Perfect-Abstractions/Compose/discussions

For example, ERC721EnumerableFacet does not extend ERC721Facet because enumeration requires re-implementing transfer and mint/burn logic, making it incompatible with ERC721Facet.


Example: Implementing Storage in ERC20PermitFacet

The ERC20PermitFacet extends ERC20Facet by adding permit functionality (gasless approvals).

To do this, it must:

  1. Access existing ERC20 data.
  2. Add new storage for permit-specific data.

This requires reusing the ERC20Storage struct from ERC20Facet and defining a separate struct for the nonces variable used by the permit function.

Here is the full ERC20Storage struct from ERC20Facet:

/// @notice Storage struct for ERC20.
/// @custom:storage-location erc8042:compose.erc20
struct ERC20Storage {
mapping(address owner => uint256 balance) balanceOf;
uint256 totalSupply;
mapping(address owner => mapping(address spender => uint256 allowance)) allowance;
uint8 decimals;
string name;
string symbol;
}

When reusing this struct in ERC20PermitFacet, the Extending Facets rules require removing unused variables at the end of the struct.

ERC20PermitFacet only uses the variables allowance and name from this struct. However, balanceOf, totalSupply, and decimals cannot be removed, even though they are unused, because they appear before the name variable, which is used. Removing them would shift the storage slot used by the name variable which would make it refer to something else.

Only unused variables at the end of a struct may be safely removed. In this case, symbol is the only trailing variable that is unused by ERC20PermitFacet, so it is the only one removed.

Here is the final struct storage code for ERC20PermitFacet:

/// @notice Storage slot identifier for ERC20 (reused to access token data).
bytes32 constant ERC20_STORAGE_POSITION = keccak256("compose.erc20");

/// @notice Storage struct for ERC20 but with `symbol` removed.
/// @dev Reused struct definition with unused variables at the end removed
/// @custom:storage-location erc8042:compose.erc20
struct ERC20Storage {
mapping(address owner => uint256 balance) balanceOf;
uint256 totalSupply;
mapping(address owner => mapping(address spender => uint256 allowance)) allowance;
uint8 decimals;
string name;
}

/// @notice Returns the storage for ERC20.
/// @return s The ERC20 storage struct.
function getERC20Storage() internal pure returns (ERC20Storage storage s) {
bytes32 position = ERC20_STORAGE_POSITION;
assembly {
s.slot := position
}
}

/// @notice Storage slot identifier for Permit functionality.
bytes32 constant STORAGE_POSITION = keccak256("compose.erc20.permit");

/// @notice Storage struct for ERC20Permit.
/// @custom:storage-location erc8042:compose.erc20.permit
struct ERC20PermitStorage {
mapping(address owner => uint256) nonces;
}

/// @notice Returns the storage for ERC20Permit.
/// @return s The ERC20Permit storage struct.
function getStorage() internal pure returns (ERC20PermitStorage storage s) {
bytes32 position = STORAGE_POSITION;
assembly {
s.slot := position
}
}

Summary: How This Example Follows the Guide

  • Reusing storage struct: The ERC20Storage struct is copied from ERC20Facet and reused at the same location in storage keccak256("compose.erc20"), ensuring both facets access the same ERC20 token data. This demonstrates how facets can share storage.

  • Maintaining variable order: All variables in the reused ERC20Storage struct maintain the same order as the original struct.

  • Removing unused variables: The symbol variable is removed from the end of the struct since permit functionality doesn't use that variable. This follows the rule that unused storage variables at the end of a struct should be removed.

  • Adding custom storage: A new ERC20PermitStorage struct is defined with its own storage slot keccak256("compose.erc20.permit") for the nonces variable. This follows the principle that a facet adding new storage variables must define its own diamond storage struct.


Example: Extending ERC20Facet with Staking Functionality

Here's a complete example showing how to correctly extend ERC20Facet by creating a new ERC20StakingFacet that adds staking functionality:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.30;

contract ERC20StakingFacet {
/// @notice Standard ERC20 Transfer event.
event Transfer(address indexed _from, address indexed _to, uint256 _value);

/// @notice Event emitted when tokens are staked.
/// @param _account The account staking tokens.
/// @param _amount The amount of tokens staked.
event TokensStaked(address indexed _account, uint256 _amount);

/// @notice Event emitted when tokens are unstaked.
/// @param _account The account unstaking tokens.
/// @param _amount The amount of tokens unstaked.
event TokensUnstaked(address indexed _account, uint256 _amount);

/// @notice Thrown when an account has insufficient balance for a stake operation.
/// @param _sender Address attempting to stake.
/// @param _balance Current balance of the sender.
/// @param _needed Amount required to complete the operation.
error ERC20InsufficientBalance(address _sender, uint256 _balance, uint256 _needed);

/// @notice Thrown when attempting to unstake more tokens than are staked.
/// @param _account The account attempting to unstake.
/// @param _requested The amount requested to unstake.
/// @param _staked The amount currently staked.
error InsufficientStakedBalance(address _account, uint256 _requested, uint256 _staked);

/// @notice Storage slot identifier for ERC20 (reused to access token data).
bytes32 constant ERC20_STORAGE_POSITION = keccak256("compose.erc20");

/// @notice Storage struct for ERC20
/// @dev This struct is from ERC20Facet.
/// `balanceOf` is the only variable used in this struct.
/// All variables after it are removed.
/// @custom:storage-location erc8042:compose.erc20
struct ERC20Storage {
mapping(address owner => uint256 balance) balanceOf;
}

/// @notice Returns the storage for ERC20.
/// @return s The ERC20 storage struct.
function getERC20Storage() internal pure returns (ERC20Storage storage s) {
bytes32 position = ERC20_STORAGE_POSITION;
assembly {
s.slot := position
}
}

/// @notice Storage slot identifier for Staking functionality.
bytes32 constant STAKING_STORAGE_POSITION = keccak256("compose.erc20.staking");

/// @notice Storage struct for ERC20Staking.
/// @custom:storage-location erc8042:compose.erc20.staking
struct ERC20StakingStorage {
mapping(address account => uint256 stakedBalance) stakedBalances;
mapping(address account => uint256 stakingStartTime) stakingStartTimes;
}

/// @notice Returns the storage for ERC20Staking.
/// @return s The ERC20Staking storage struct.
function getStorage() internal pure returns (ERC20StakingStorage storage s) {
bytes32 position = STAKING_STORAGE_POSITION;
assembly {
s.slot := position
}
}

/// @notice Stakes tokens from the caller's balance.
/// @param _amount The amount of tokens to stake.
function stake(uint256 _amount) external {
ERC20Storage storage erc20s = getERC20Storage();
ERC20StakingStorage storage s = getStorage();

// Check sufficient balance
if (erc20s.balanceOf[msg.sender] < _amount) {
revert ERC20InsufficientBalance(msg.sender, erc20s.balanceOf[msg.sender], _amount);
}

// Transfer tokens from user's balance to staked balance
erc20s.balanceOf[msg.sender] -= _amount;
erc20s.balanceOf[address(this)] += _amount;
emit Transfer(msg.sender, address(this), _amount);

s.stakedBalances[msg.sender] += _amount;

// Record staking start time if this is the first stake
if (s.stakingStartTimes[msg.sender] == 0) {
s.stakingStartTimes[msg.sender] = block.timestamp;
}

emit TokensStaked(msg.sender, _amount);
}

/// @notice Unstakes tokens and returns them to the caller's balance.
/// @param _amount The amount of tokens to unstake.
function unstake(uint256 _amount) external {
ERC20Storage storage erc20s = getERC20Storage();
ERC20StakingStorage storage s = getStorage();

// Check sufficient staked balance
if (s.stakedBalances[msg.sender] < _amount) {
revert InsufficientStakedBalance(msg.sender, _amount, s.stakedBalances[msg.sender]);
}

// Transfer tokens from staked balance back to user's balance
s.stakedBalances[msg.sender] -= _amount;

erc20s.balanceOf[address(this)] -= _amount;
erc20s.balanceOf[msg.sender] += _amount;
emit Transfer(address(this), msg.sender, _amount);

// Clear staking start time if all tokens are unstaked
if (s.stakedBalances[msg.sender] == 0) {
s.stakingStartTimes[msg.sender] = 0;
}

emit TokensUnstaked(msg.sender, _amount);
}

/// @notice Returns the staked balance for an account.
/// @param _account The account to check.
/// @return The amount of tokens staked by the account.
function getStakedBalance(address _account) external view returns (uint256) {
return getStorage().stakedBalances[_account];
}

/// @notice Returns the staking start time for an account.
/// @param _account The account to check.
/// @return The timestamp when the account first staked tokens.
function getStakingStartTime(address _account) external view returns (uint256) {
return getStorage().stakingStartTimes[_account];
}
}

Summary: How This Example Follows the Guide

This example demonstrates proper facet extension by:

  • Extending as a new facet: ERC20StakingFacet is a separate, self-contained facet that composes with ERC20Facet. This follows the principle that every extension should be implemented as a new facet.

  • Reusing storage struct: The ERC20Storage struct is copied from ERC20Facet and reused at the same storage location keccak256("compose.erc20"), ensuring both facets access the same token data. This demonstrates how facets can share storage.

  • Maintaining variable order: The balanceOf variable remains in the first position of the reused ERC20Storage struct, exactly as it appears in the original struct, preserving the order of storage variables.

  • Removing unused variables: All variables except balanceOf are removed from the reused ERC20Storage struct since they are unused by ERC20StakingFacet. This follows the rule that storage structs should be designed so removable variables appear at the end, and removal is only done from the end of a struct.

  • Adding custom storage: A new ERC20StakingStorage struct is defined with its own storage slot keccak256("compose.erc20.staking") for staking-specific data. This follows the principle that a facet adding new storage variables must define its own diamond storage struct.

  • Self-contained design: The facet contains all necessary code (events, errors, storage definitions, and functions) without imports, making it fully self-contained.

  • Composable functionality: This facet can be deployed once and added to any diamond that includes ERC20Facet, demonstrating true on-chain composition where facets work together without inheritance.


Conclusion

This level of composability strikes the right balance: it enables organized, modular, and understandable on-chain smart contract systems.