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
| Metric | Before | After |
|---|---|---|
| Deployed contracts per factory | 7 | 4 |
| Factory constructor params | 7 addresses | 4 addresses |
| ERC721 features | Base + Metadata | Base + Metadata + Enumerable |
| Storage pattern | Custom keccak256 | ERC-7201 namespaced |
| ERC721 audit status | Custom (unaudited) | SolidState (battle-tested) |
Step 1: Install Dependency
cd packages/nft-contracts
pnpm add @solidstate/contracts
pnpm build # Verify HH3 resolves importsRisk: 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 routingDiamondReadable— loupe introspection (facets, selectors)DiamondWritable— diamondCut functionSafeOwnable— 2-step ownership transfer (initial owner set via CollectionInit)ERC165Base— interface detection
Constructor:
- Registers 8 selectors to
address(this): diamondCut, 4x loupe, supportsInterface, owner, transferOwnership - Registers ERC-165 interfaces: IERC165, IERC2535DiamondCut, IERC2535DiamondLoupe, IERC173
- Sets
msg.senderas 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, ownerOfERC721Enumerable— totalSupply, tokenByIndex, tokenOfOwnerByIndexERC721Metadata— name, symbol, tokenURIERC165Base— 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:
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
ERC721BaseInternalfrom SolidState (provides_mint()) - Inherit
OwnableInternalfrom 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 inLibMint
Functions retained:
mintTo(address to)— mint single tokenbatchMintTo(address to, uint256 quantity)— batch mintmaxSupply(),mintActive(),minter()— view functionssetMintActive(bool),setMinter(address),setMaxSupply(uint256)— admin
Step 6: Update ContractURIFacet.sol
File: contracts/facets/ContractURIFacet.sol
Minimal change:
- Replace
import LibDiamond→import OwnableInternal - Replace
LibDiamond.enforceIsContractOwner()→require(msg.sender == _owner()) LibContractURIstays unchanged
Step 7: Update CollectionInit.sol
File: contracts/upgradeInitializers/CollectionInit.sol
Rewrite to initialize SolidState's storage layouts:
import { ERC721MetadataStorage } from "@solidstate/contracts/token/ERC721/metadata/ERC721MetadataStorage.sol";
import { ERC165BaseStorage } from "@solidstate/contracts/introspection/ERC165/base/ERC165BaseStorage.sol";Initialization targets:
- ERC165BaseStorage — register ERC721 (0x80ac58cd), ERC721Metadata (0x5b5e139f), ERC721Enumerable (0x780e9d63) interfaces
- ERC721MetadataStorage — set name, symbol, baseURI
- LibMint (custom) — set mintActive=true, minter, currentIndex=1, maxSupply
- LibContractURI (custom) — set contractURI
Step 8: Update DiamondFactory.sol
File: contracts/DiamondFactory.sol
Constructor simplified (4 params instead of 7):
constructor(
address _erc721Facet,
address _mintFacet,
address _contractURIFacet,
address _collectionInit
)createCollection() changes:
- Deploy
EmPropsDiamondvia CREATE2 (parameterless constructor — factory is temporary owner) - Call
diamondCutto add 3 external facets + run CollectionInit (init sets real owner viaOwnableStorage) - No
transferOwnershipneeded — 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
| File | Reason |
|---|---|
contracts/Diamond.sol | Replaced by EmPropsDiamond.sol |
contracts/libraries/LibDiamond.sol | Replaced by SolidState internals |
contracts/libraries/LibERC721.sol | Replaced by SolidState ERC721 |
contracts/facets/DiamondCutFacet.sol | Baked into EmPropsDiamond |
contracts/facets/DiamondLoupeFacet.sol | Baked into EmPropsDiamond |
contracts/facets/OwnershipFacet.sol | Baked into EmPropsDiamond |
contracts/interfaces/IDiamondCut.sol | SolidState provides IERC2535DiamondCut |
contracts/interfaces/IDiamondLoupe.sol | SolidState provides IERC2535DiamondLoupe |
contracts/interfaces/IERC165.sol | SolidState provides IERC165 |
contracts/interfaces/IERC173.sol | SolidState provides IERC173 |
contracts/interfaces/IERC721.sol | SolidState 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 mintingtokenOfOwnerByIndex()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
- HH3 import resolution: Verify
@solidstate/contractsresolves correctly. May need remappings. - FacetCut struct field names:
target/selectors(SolidState) vsfacetAddress/functionSelectors(Nick Mudge reference) payableERC721 functions: SolidState's transfer/approve are payable. ABI changes but behavior identical (rejects ETH by default).- Gas increase: ~80k gas/mint vs ~50k due to EnumerableMap. Acceptable for custodial minting.
- SolidState version: Use
0.0.61(stable). Avoid1.0.0-next.x(breaking API changes).
