ADR: Migrate NFT Diamond Contracts to SolidState Solidity
Date: 2026-02-09 Status: Accepted Supersedes: Portions of ADR 2026-02-06 (NFT Diamond Standard on Arbitrum) Decision Makers: Architecture Team
Executive Summary
Migrate the hand-rolled Diamond (EIP-2535) + ERC721 implementation in packages/nft-contracts/ to use SolidState Solidity (@solidstate/contracts) as the audited foundation. This replaces our custom Diamond infrastructure and ERC721 implementation with battle-tested, community-maintained contracts while retaining our project-specific facets (DiamondFactory, MintFacet, ContractURIFacet).
Key Changes:
- ERC721: Replace custom
LibERC721.sol+ERC721Facet.solwith SolidState'sSolidStateERC721(base + enumerable + metadata) - Diamond Infrastructure: Bake cut/loupe/ownership/ERC165 into the Diamond proxy (SolidState's pattern) — eliminates 3 separately-deployed facets
- Storage: Adopt ERC-7201 namespaced storage via SolidState's storage libraries
- Bonus: Gain
ERC721Enumerable(totalSupply,tokenByIndex,tokenOfOwnerByIndex) for free
Context
Why Migrate?
Our current implementation works but uses custom ERC721 transfer/approve/ownership logic. These are the most audited, most attacked surfaces in smart contracts. Using a battle-tested library:
- Reduces audit risk — SolidState's ERC721 has been deployed in production by multiple projects
- ERC-7201 compliance — Formal namespaced storage (our custom
keccak256("emprops.nft.erc721.storage")works but isn't ERC-7201 compliant) - ERC721Enumerable — We get
tokenByIndex()andtokenOfOwnerByIndex()without extra code, enabling better marketplace discoverability - Maintenance — Bug fixes and optimizations come from the community, not our team
Why SolidState (Not OpenZeppelin)?
| Aspect | OpenZeppelin | SolidState |
|---|---|---|
| Diamond support | None — designed for single-contract or UUPS | Built specifically for Diamond (EIP-2535) |
| Storage pattern | Slot-per-variable | ERC-7201 namespaced structs (Diamond-compatible) |
| ERC721 + Diamond | Requires manual storage refactoring | Works out of the box as facets |
| Facet composition | Not supported | Layered internals designed for facet reuse |
OpenZeppelin assumes a single-contract or UUPS proxy model. Their storage layout collides when used across Diamond facets. SolidState was built from the ground up for Diamond architecture.
Why Now?
No contracts deployed to any live chain yet (only local Hardhat). Storage slot positions will change during migration — this would be a breaking change for deployed contracts but is a non-issue today.
Decision
Architecture: Bake Diamond Infrastructure into Proxy
SolidState's SolidStateDiamond bakes cut/loupe/ownership/ERC165 into the Diamond proxy contract itself. We adopt this pattern:
Before (7 deployed contracts):
DiamondCutFacet → deployed once
DiamondLoupeFacet → deployed once
OwnershipFacet → deployed once
ERC721Facet → deployed once
MintFacet → deployed once
ContractURIFacet → deployed once
CollectionInit → deployed once
DiamondFactory → deploys Diamonds, links all 6 facetsAfter (4 deployed contracts):
ERC721Facet → deployed once (extends SolidStateERC721)
MintFacet → deployed once (uses SolidState's _mint)
ContractURIFacet → deployed once
CollectionInit → deployed once
EmPropsDiamond → proxy with cut/loupe/ownership/ERC165 baked in
DiamondFactory → deploys EmPropsDiamonds, links 3 facetsOwnership Model: SafeOwnable with Init-time Owner Set
SolidState's default Diamond uses SafeOwnable (2-step ownership transfer: nominate → accept). We keep this for all post-deployment ownership transfers — it prevents accidentally transferring a Diamond to a wrong/dead address.
For the initial owner set during factory deployment, CollectionInit.init() writes directly to OwnableStorage.layout().owner via delegatecall. This sets the owner atomically during Diamond creation without calling transferOwnership, so SafeOwnable's 2-step protection isn't bypassed — it simply doesn't apply to initialization.
Storage Slot Changes
| Component | Before | After (SolidState) |
|---|---|---|
| Diamond routing | keccak256("diamond.standard.diamond.storage") | SolidState's DiamondBaseStorage |
| Ownership | Inside DiamondStorage | keccak256('solidstate.contracts.storage.Ownable') |
| ERC165 | Inside DiamondStorage | keccak256('solidstate.contracts.storage.ERC165Base') |
| ERC721 Base | keccak256("emprops.nft.erc721.storage") | keccak256('solidstate.contracts.storage.ERC721Base') |
| ERC721 Metadata | Combined in ERC721Storage | keccak256('solidstate.contracts.storage.ERC721Metadata') |
| Mint | keccak256("emprops.nft.mint.storage") | Unchanged (custom) |
| ContractURI | keccak256("emprops.nft.contracturi.storage") | Unchanged (custom) |
What SolidState Does NOT Provide
These remain custom:
- DiamondFactory — SolidState has no factory pattern
- MintFacet — Minting logic is always project-specific
- ContractURIFacet — ERC-7572 is too new for SolidState
- CollectionInit — Initialization is project-specific
Consequences
Positive
- Audited ERC721 — Transfer, approve, ownership checks from a battle-tested library
- ERC721Enumerable —
totalSupply(),tokenByIndex(),tokenOfOwnerByIndex()included - Fewer deployed contracts — 4 instead of 7 (simpler factory, lower deployment gas)
- ERC-7201 compliance — Proper namespaced storage pattern
- Community maintenance — Bug fixes from SolidState ecosystem
Negative
- Gas overhead — SolidState's ERC721 uses
EnumerableMap/EnumerableSet, costing ~80k gas/mint vs ~50k (acceptable for custodial minting where platform pays) totalSupply()semantics — SolidState counts currently-existing tokens (decreases on burn). Our current implementation counts all-time mints (currentIndex - 1). Equivalent today since we don't burn, but differs if burn is added later.payableERC721 functions — SolidState makestransferFrom/approvepayable per strict EIP-721 spec. They reject ETH by default, but ABI differs.- Dependency risk — We now depend on
@solidstate/contractspackage updates
Mitigations
- Gas overhead: Platform pays gas for custodial minting — 30k extra per mint is negligible
totalSupply: We trackcurrentIndexinLibMintfor sequential token IDs regardlesspayable: No behavioral change; only ABI metadata differs- Dependency: Pin version, review updates before upgrading
Alternatives Considered
1. Keep Custom Implementation
- Pro: No migration effort, full control
- Con: Unaudited ERC721 core, no enumerable, maintenance burden
- Rejected: The risk of a custom ERC721 implementation outweighs the migration cost
2. OpenZeppelin Contracts
- Pro: Most widely used, heavily audited
- Con: Not designed for Diamond pattern, storage layout conflicts
- Rejected: Would require significant refactoring to work with Diamond storage
3. Diamond Bond (diamond-bond)
- Pro: Also Diamond-focused
- Con: Much smaller community, less audited, fewer features
- Rejected: SolidState has a larger ecosystem and better ERC721 integration
Related Documentation
- NFT Diamond Standard on Arbitrum — Original architecture decision
- Studio Revival — Phase B studio reconnection
- Implementation Guide — Step-by-step migration plan
- SolidState Solidity GitHub
- ERC-7201: Namespaced Storage Layout
