This is a proof of concept of what a smart contract development library for EIP-2535 diamonds could look like, using ERC721 as an example.
The "contracts/example" directory includes an example implementation using the library. The example shows an ERC721 with an allowlist gated mint, and special functionality that prevents transferring tokens to blocked addresses. This was chosen to showcase both inheritance and composability with the library.
I summarize some of the design choices below, would love any feedback and critiques!
Because we break facets down into various parts (external handlers, logic, storage, modifiers, interfaces), it constitutes having a separate folder for each facet rather than just a single file.
Diamond storage is used in this implementation, but storage is also kept in its own library with no other logic. This convention is useful because changes to the storage library can be treated separately and with more care than logic changes, sort of analogous to a migrations directory in a web server codebase.
All business logic goes in libraries with internal functions. This is important as diamond codebases grow, because multiple facets can share the same library functions with merely a JUMP
operator, more info on sharing functionality here. In my experience, this becomes a huge DRY win over time. Note By keeping all business logic in libraries, you might also future proof your facets in case pure library facets become the norm.
With the exception of modifiers, the facet contracts have no logic, they simply make calls to underlying logic libraries.
If they don't do anything meaningful, then why have contract facets at all? Why not just library facets? So glad you asked:
- Inheritance. If implementers need to override functionality in a standard contract, in many cases inheritance is still the cleanest way to do it. Libraries can't inherit. You can see an example of the inheritance use case in the
TransferBlocklistNFT
which inherits ERC721 and overrides transfer functionality. - ABI. The abi that is generated for libraries is different than contracts. As a result, many popular off chain tools and clients such as typechain, ethers etc... do not behave as you might expect with things like events and function signatures that encode struct arguments.
- Modifiers (See below)
I like modifiers a lot for concise access control checks, but because solidity libraries don't have inheritance, its not possible to share modifiers between libraries. As such, we declare separate modifier contracts which can be shared amongst the lightweight facets, see "OwnableModifiers.sol"
Public function signatures are all virtual, meaning implementers can choose to inherit facets should they need to. This gives a good amount of choice (perhaps too much?) to the implementer. They can choose to deploy the entire facet & turn off specific functions via diamond cut, or inherit and modify the facets.
I've found breaking the logic facets down into very small, granular functions is useful for code reusability / composability across facets, and tends to be good programming practice in general.
Unfortunately, events (and custom errors) declared / emitted in libraries do not show up in the calling contract's ABI. There are a few issues about this in the solidity language repo.
As a result, to get events to show up properly in the ABI, we redeclare events both in the library and the contract. To make the event declarations shareable between contract facets, it may be worth breaking them into a separate IFacetEvents interfaces, but there is no getting around redeclaring them in the library at this point.