Hey fellow devs! Today I want to share how we implemented EIP-2535 Diamond Pattern in our Proof of Peacemaking (POP) protocol. If you're not familiar with Diamond Pattern, it's like microservices for smart contracts, with its API gateway - but way cooler!
Visit the GitHub repo to see the full implementation.
🤔 Why Diamond Pattern?
Before diving into the implementation, let's talk about why we chose Diamonds:
Complex Functionality Split: Our protocol handles expressions, acknowledgments, and NFTs - that's a lot of functionality to pack into a single contract. Each of these components needs its own storage and logic.
-
Future Upgrades: We have several planned upgrades that need flexible implementation:
- Moving gas subsidization logic off-chain
- Implementing more sophisticated allowlist mechanisms
- Adding new expression and acknowledgment types
- Enhancing NFT metadata and rendering
-
Storage Management: We're dealing with multiple storage-heavy features:
- Gas subsidization mappings for operators and users
- Allowlist tracking for different permission levels
- Expression and acknowledgment content (which might move to IPFS later)
- NFT metadata and verification data
Each contract in Solidity has 24kb limit. In our case, components will need its own storage and logic and diamond is there to save the day.
-
Modular Development: We wanted our facets to be reusable (DRY principle FTW!). For example:
- The gas subsidization logic could be reused in other projects
- The NFT facet could be integrated into other diamonds
- Expression and acknowledgment patterns might be useful for other social protocols
-
Clean Upgrade Path: Unlike proxy patterns, Diamond Pattern gives us:
- Ability to upgrade specific functionality without touching other parts
- Clear separation of concerns for each component
- Easy way to add new features without size limitations
- No complex proxy delegation logic to manage
💡 Storage Layout: The Fun Part
The most interesting part of our implementation is how we handle storage. Instead of using the traditional AppStorage pattern, we went full Diamond Storage. Here's how:
library LibStorage {
// Each component gets its own storage namespace
bytes32 constant EXPRESSION_STORAGE_POSITION = keccak256("pop.v1.expression.storage");
bytes32 constant ACKNOWLEDGEMENT_STORAGE_POSITION = keccak256("pop.v1.acknowledgement.storage");
bytes32 constant NFT_METADATA_STORAGE_POSITION = keccak256("pop.v1.nft.metadata.storage");
bytes32 constant GAS_COST_STORAGE_POSITION = keccak256("pop.v1.gas.cost.storage");
// More code...
}
See those storage positions? Each one is like a unique apartment in the blockchain for our data. We namespace them with pop.v1
to avoid any roommate disputes (storage collisions) 😉
To learn more about the storage types, check out this blog post. written by @mudgen, the creator of the EIP-2535 Diamonds.
🏗️ Architecture Breakdown
Our diamond has several facets, each with its own storage layout:
1. Expression Facet
struct ExpressionStorage {
mapping(uint256 => Expression) expressions;
uint256 expressionCount;
mapping(uint256 => address[]) expressionAcknowledgers;
}
2. Acknowledgement Facet
struct AcknowledgementStorage {
// expressionId => acknowledger => Acknowledgement
mapping(uint256 => mapping(address => Acknowledgement)) acknowledgements;
uint256 acknowledgementCount;
}
3. NFT Facet
struct POPNFTStorage {
// Core ERC721 storage
mapping(uint256 => address) owners;
mapping(address => uint256) balances;
mapping(uint256 => string) tokenURIs;
// ... more fields
}
🧩 Helper Structs vs Storage Structs
Here's a cool pattern we used: We separate our data structures into two categories:
- Helper Structs: Just data definitions, no storage position needed
struct Expression {
address creator;
MediaContent content;
uint256 timestamp;
string ipfsHash;
}
- Storage Structs: The actual storage layout with a unique position
struct ExpressionStorage {
mapping(uint256 => Expression) expressions;
// ... more fields
}
🔍 Accessing Storage
Each storage struct gets its own getter function:
function expressionStorage() internal pure returns (ExpressionStorage storage es) {
bytes32 position = EXPRESSION_STORAGE_POSITION;
assembly {
es.slot := position
}
}
In our facets, we access storage like this:
function createExpression(...) external {
LibStorage.ExpressionStorage storage es = LibStorage.expressionStorage();
LibStorage.GasCostStorage storage gs = LibStorage.gasCostStorage();
// Now we can use es and gs!
}
🚀 Benefits We've Seen
- Clean Separation: Each component has its own storage namespace
-
Versioning Ready: Our
v1
naming makes future upgrades cleaner - Reusable Facets: Any diamond can use our facets without storage conflicts
- Gas Efficient: Direct storage access, no proxy overhead
🎯 Tips for Your Own Implementation
- Keep your helper structs separate from storage structs
- Use consistent naming for storage positions (we use
project.version.component.storage
) - Think about future upgrades when designing storage layout
- Use descriptive variable names in storage getters (
es
for expression storage, etc.)
🤓 Final Thoughts
Diamond Pattern might seem complex at first (I definitely scratched my head a few times), but once you get the hang of it, it's like LEGO for smart contracts. Our implementation in POP protocol shows how you can build complex functionality while keeping your code modular and upgradeable.
Remember: With great power comes great responsibility... to write clean, maintainable code! 😎
Still want to check out the full implementation? Visit our GitHub repo!
Top comments (0)