Skip to content

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.sol with SolidState's SolidStateERC721 (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:

  1. Reduces audit risk — SolidState's ERC721 has been deployed in production by multiple projects
  2. ERC-7201 compliance — Formal namespaced storage (our custom keccak256("emprops.nft.erc721.storage") works but isn't ERC-7201 compliant)
  3. ERC721Enumerable — We get tokenByIndex() and tokenOfOwnerByIndex() without extra code, enabling better marketplace discoverability
  4. Maintenance — Bug fixes and optimizations come from the community, not our team

Why SolidState (Not OpenZeppelin)? ​

AspectOpenZeppelinSolidState
Diamond supportNone — designed for single-contract or UUPSBuilt specifically for Diamond (EIP-2535)
Storage patternSlot-per-variableERC-7201 namespaced structs (Diamond-compatible)
ERC721 + DiamondRequires manual storage refactoringWorks out of the box as facets
Facet compositionNot supportedLayered 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 facets

After (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 facets

Ownership 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 ​

ComponentBeforeAfter (SolidState)
Diamond routingkeccak256("diamond.standard.diamond.storage")SolidState's DiamondBaseStorage
OwnershipInside DiamondStoragekeccak256('solidstate.contracts.storage.Ownable')
ERC165Inside DiamondStoragekeccak256('solidstate.contracts.storage.ERC165Base')
ERC721 Basekeccak256("emprops.nft.erc721.storage")keccak256('solidstate.contracts.storage.ERC721Base')
ERC721 MetadataCombined in ERC721Storagekeccak256('solidstate.contracts.storage.ERC721Metadata')
Mintkeccak256("emprops.nft.mint.storage")Unchanged (custom)
ContractURIkeccak256("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.
  • payable ERC721 functions — SolidState makes transferFrom/approve payable per strict EIP-721 spec. They reject ETH by default, but ABI differs.
  • Dependency risk — We now depend on @solidstate/contracts package updates

Mitigations ​

  1. Gas overhead: Platform pays gas for custodial minting — 30k extra per mint is negligible
  2. totalSupply: We track currentIndex in LibMint for sequential token IDs regardless
  3. payable: No behavioral change; only ABI metadata differs
  4. 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

Released under the MIT License.