Skip to content

SolidState Diamond Migration: Implementation Guide

Related ADR: SolidState Diamond MigrationScope: Replace hand-rolled Diamond + ERC721 with SolidState Solidity library


Overview

Migrate packages/nft-contracts/ from custom Diamond/ERC721 implementation to @solidstate/contracts. The migration replaces 8 files, creates 2 new files, modifies 9 files, and deletes 12 files.

Before vs After

MetricBeforeAfter
Deployed contracts per factory74
Factory constructor params7 addresses4 addresses
ERC721 featuresBase + MetadataBase + Metadata + Enumerable
Storage patternCustom keccak256ERC-7201 namespaced
ERC721 audit statusCustom (unaudited)SolidState (battle-tested)

Step 1: Install Dependency

bash
cd packages/nft-contracts
pnpm add @solidstate/contracts
pnpm build  # Verify HH3 resolves imports

Risk: HH3 import resolution may need configuration. If @solidstate/contracts imports fail, add solidity.remappings to hardhat.config.ts.


Step 2: Create EmPropsDiamond.sol

File: contracts/EmPropsDiamond.sol

New Diamond proxy contract that bakes in cut, loupe, ownership, and ERC165. Replaces the old Diamond.sol + 3 separate infrastructure facets.

Inherits from SolidState:

  • DiamondBase — fallback function routing
  • DiamondReadable — loupe introspection (facets, selectors)
  • DiamondWritable — diamondCut function
  • SafeOwnable — 2-step ownership transfer (initial owner set via CollectionInit)
  • ERC165Base — interface detection

Constructor:

  1. Registers 8 selectors to address(this): diamondCut, 4x loupe, supportsInterface, owner, transferOwnership
  2. Registers ERC-165 interfaces: IERC165, IERC2535DiamondCut, IERC2535DiamondLoupe, IERC173
  3. Sets msg.sender as owner (factory becomes temporary owner)

Key design choice: Uses SafeOwnable (SolidState's default 2-step ownership transfer). The factory does NOT call transferOwnership() — instead, CollectionInit.init() writes the collection owner directly to OwnableStorage via delegatecall. This sets the owner atomically during creation. SafeOwnable's 2-step protection then applies to all subsequent ownership transfers.


Step 3: Create New ERC721Facet.sol

File: contracts/facets/ERC721Facet.sol

Extends SolidStateERC721 which combines:

  • ERC721Base — transfer, approve, balanceOf, ownerOf
  • ERC721Enumerable — totalSupply, tokenByIndex, tokenOfOwnerByIndex
  • ERC721Metadata — name, symbol, tokenURI
  • ERC165Base — interface detection

Only custom addition: setBaseURI(string) (owner-only admin function).

Note: totalSupply() now lives here (from ERC721Enumerable) instead of on MintFacet.


Step 4: Update LibMint.sol

File: contracts/libraries/LibMint.sol

Add fields moved from the deleted LibERC721:

solidity
struct MintStorage {
    bool mintActive;
    address minter;
    uint256 currentIndex;   // NEW: next token ID (starts at 1)
    uint256 maxSupply;      // NEW: 0 = unlimited
}

Storage slot remains custom: keccak256("emprops.nft.mint.storage").


Step 5: Update MintFacet.sol

File: contracts/facets/MintFacet.sol

Changes:

  • Inherit ERC721BaseInternal from SolidState (provides _mint())
  • Inherit OwnableInternal from SolidState (provides _owner())
  • Replace LibERC721._mint() → SolidState's _mint(to, tokenId)
  • Replace LibDiamond.enforceIsContractOwner()require(msg.sender == _owner())
  • Remove totalSupply() (now on ERC721Enumerable via ERC721Facet)
  • Keep currentIndex-based sequential token ID assignment in LibMint

Functions retained:

  • mintTo(address to) — mint single token
  • batchMintTo(address to, uint256 quantity) — batch mint
  • maxSupply(), mintActive(), minter() — view functions
  • setMintActive(bool), setMinter(address), setMaxSupply(uint256) — admin

Step 6: Update ContractURIFacet.sol

File: contracts/facets/ContractURIFacet.sol

Minimal change:

  • Replace import LibDiamondimport OwnableInternal
  • Replace LibDiamond.enforceIsContractOwner()require(msg.sender == _owner())
  • LibContractURI stays unchanged

Step 7: Update CollectionInit.sol

File: contracts/upgradeInitializers/CollectionInit.sol

Rewrite to initialize SolidState's storage layouts:

solidity
import { ERC721MetadataStorage } from "@solidstate/contracts/token/ERC721/metadata/ERC721MetadataStorage.sol";
import { ERC165BaseStorage } from "@solidstate/contracts/introspection/ERC165/base/ERC165BaseStorage.sol";

Initialization targets:

  1. ERC165BaseStorage — register ERC721 (0x80ac58cd), ERC721Metadata (0x5b5e139f), ERC721Enumerable (0x780e9d63) interfaces
  2. ERC721MetadataStorage — set name, symbol, baseURI
  3. LibMint (custom) — set mintActive=true, minter, currentIndex=1, maxSupply
  4. LibContractURI (custom) — set contractURI

Step 8: Update DiamondFactory.sol

File: contracts/DiamondFactory.sol

Constructor simplified (4 params instead of 7):

solidity
constructor(
    address _erc721Facet,
    address _mintFacet,
    address _contractURIFacet,
    address _collectionInit
)

createCollection() changes:

  1. Deploy EmPropsDiamond via CREATE2 (parameterless constructor — factory is temporary owner)
  2. Call diamondCut to add 3 external facets + run CollectionInit (init sets real owner via OwnableStorage)
  3. No transferOwnership needed — owner is set atomically in init

FacetCut struct change: SolidState uses { target, action, selectors } instead of { facetAddress, action, functionSelectors }.

_buildFacetCuts() changes:

  • Remove loupe/ownership selectors (baked into EmPropsDiamond)
  • ERC721 selectors gain: totalSupply, tokenByIndex, tokenOfOwnerByIndex
  • MintFacet selectors lose: totalSupply

predictAddress() changes: EmPropsDiamond has no constructor params, so creation code changes.


Step 9: Delete Old Files

FileReason
contracts/Diamond.solReplaced by EmPropsDiamond.sol
contracts/libraries/LibDiamond.solReplaced by SolidState internals
contracts/libraries/LibERC721.solReplaced by SolidState ERC721
contracts/facets/DiamondCutFacet.solBaked into EmPropsDiamond
contracts/facets/DiamondLoupeFacet.solBaked into EmPropsDiamond
contracts/facets/OwnershipFacet.solBaked into EmPropsDiamond
contracts/interfaces/IDiamondCut.solSolidState provides IERC2535DiamondCut
contracts/interfaces/IDiamondLoupe.solSolidState provides IERC2535DiamondLoupe
contracts/interfaces/IERC165.solSolidState provides IERC165
contracts/interfaces/IERC173.solSolidState provides IERC173
contracts/interfaces/IERC721.solSolidState provides IERC721

Step 10: Update Tests

File: test/Diamond.test.ts

Fixture changes:

  • Deploy 4 contracts: ERC721Facet, MintFacet, ContractURIFacet, CollectionInit
  • Factory constructor takes 4 addresses (not 7)

Assertion changes:

  • Diamond Loupe: expect 4 facet addresses (self + ERC721 + Mint + ContractURI)
  • totalSupply() tested via ERC721 facet interface, not MintFacet
  • ERC165: add test for ERC721Enumerable (0x780e9d63)

New tests:

  • tokenByIndex() returns correct token IDs after minting
  • tokenOfOwnerByIndex() returns tokens owned by address
  • Multiple collections remain independent (isolation test)

Step 11: Update Deployment Scripts

Files: ignition/modules/DeployFactory.ts, scripts/deploy-local.ts

Deploy 4 contracts instead of 7. Factory constructor takes 4 addresses.


Step 12: Update Monitor App

apps/monitor/src/lib/nft/abis.ts

  • Factory ABI: constructor takes 4 params. Remove diamondCutFacet(), diamondLoupeFacet(), ownershipFacet() getter functions.
  • ERC721 ABI: add totalSupply(), tokenByIndex(uint256), tokenOfOwnerByIndex(address, uint256)
  • MintFacet ABI: remove totalSupply()

apps/monitor/src/lib/nft/types.ts

Remove DiamondCutFacet, DiamondLoupeFacet, OwnershipFacet from deployment types.

apps/monitor/src/app/api/nft/deploy/route.ts

Update factory constructor call and deployment JSON structure.


Verification Checklist

  • [ ] pnpm build — all contracts compile with SolidState
  • [ ] pnpm test — all tests pass (31+ existing + new Enumerable tests)
  • [ ] Deploy factory + create collection on local Hardhat node
  • [ ] Mint tokens → verify ownership, totalSupply, tokenByIndex
  • [ ] Verify contractURI and tokenURI work correctly
  • [ ] Monitor app deploys collection successfully
  • [ ] Monitor metadata analyzer reads on-chain data correctly
  • [ ] Diamond Loupe reports correct facets (4 total)

Key Gotchas

  1. HH3 import resolution: Verify @solidstate/contracts resolves correctly. May need remappings.
  2. FacetCut struct field names: target/selectors (SolidState) vs facetAddress/functionSelectors (Nick Mudge reference)
  3. payable ERC721 functions: SolidState's transfer/approve are payable. ABI changes but behavior identical (rejects ETH by default).
  4. Gas increase: ~80k gas/mint vs ~50k due to EnumerableMap. Acceptable for custodial minting.
  5. SolidState version: Use 0.0.61 (stable). Avoid 1.0.0-next.x (breaking API changes).

Released under the MIT License.