Arbitrum NFT Integration — Diamond Standard
Status
Architecture: ERC-2535 Diamond Standard Tooling: Hardhat 3 + Solidity ^0.8.0 Target Chain: Arbitrum Sepolia (testnet) → Arbitrum One (mainnet) Contracts: packages/nft-contracts/ — 28 tests passing
What We're Building
Each NFT collection deploys as its own Diamond proxy (ERC-2535). The Diamond pattern lets collections share logic via facets while keeping storage isolated per collection. Facets can be added, replaced, or removed post-deployment — meaning collections can evolve without redeploying.
Why Diamond over ERC1167 Minimal Proxies
| Aspect | ERC1167 (Old Plan) | ERC-2535 Diamond (Current) |
|---|---|---|
| Upgradeability | None per collection | Individual facets upgradeable per collection |
| Feature composition | Fixed at deploy | Add/remove capabilities post-deployment |
| Shared logic | Shared via delegatecall | Shared via facets with selector routing |
| Storage | Proxy's own storage | Diamond storage pattern (namespaced slots) |
| Introspection | None | DiamondLoupe + ERC-165 |
| Future extensibility | Deploy new implementation | Add new facet to existing collections |
Smart Contract Architecture
DiamondFactory
│
│ createCollection(CollectionParams)
│ → deploys Diamond proxy via CREATE2
│ → configures facet selectors
│ → initializes collection state
│ → transfers ownership to collection owner
│
├── Diamond Instance A (Collection "Cosmic Dreams")
│ ├── DiamondCutFacet → manage facets
│ ├── DiamondLoupeFacet → introspection + ERC-165
│ ├── ERC721Facet → ERC-721 + Metadata (name, symbol, tokenURI, transfers)
│ ├── MintFacet → mintTo(address), batchMintTo(address, qty)
│ └── OwnershipFacet → ERC-173 ownership
│
├── Diamond Instance B (Collection "Neon Horizons")
│ └── (same shared facets, own storage)
│
└── Diamond Instance N ...Contract Inventory
| Contract | File | Purpose |
|---|---|---|
| Diamond | Diamond.sol | Base Diamond proxy — receives calls, routes to facets |
| DiamondFactory | DiamondFactory.sol | Deploys collections via CREATE2, configures facets |
| DiamondCutFacet | facets/DiamondCutFacet.sol | Add/replace/remove facets on a Diamond |
| DiamondLoupeFacet | facets/DiamondLoupeFacet.sol | Introspection — facets(), facetAddress(), supportsInterface() |
| ERC721Facet | facets/ERC721Facet.sol | ERC-721 + Metadata — transfers, approvals, tokenURI(), setBaseURI() |
| MintFacet | facets/MintFacet.sol | Custodial minting — mintTo(), batchMintTo(), supply management |
| OwnershipFacet | facets/OwnershipFacet.sol | ERC-173 — owner(), transferOwnership() |
| CollectionInit | upgradeInitializers/CollectionInit.sol | One-time initializer — sets name, symbol, baseURI, maxSupply, minter |
Storage Libraries
| Library | Purpose |
|---|---|
| LibDiamond | Diamond storage — facet routing, ownership, diamondCut logic |
| LibERC721 | ERC-721 storage — token ownership, balances, approvals, metadata |
| LibMint | Mint storage — minter address, mint active flag |
Key Design Decisions
Minter role vs Owner role: The owner (collection creator) controls facets and admin functions. A separate minter address (platform wallet) controls minting. This lets us run custodial minting without giving the platform full collection ownership.
No ERC721A: We use standard ERC-721 (not ERC721A) because Diamond storage pattern is incompatible with ERC721A's consecutive-ownership optimization. Standard ERC-721 works cleanly with Diamond's namespaced storage slots.
No RoyaltyFacet yet: ERC-2981 royalties will be added as a facet in a future phase — the Diamond pattern lets us add it to existing collections without redeployment.
DiamondFactory
The factory deploys new collection Diamonds with all facets pre-configured.
CollectionParams
struct CollectionParams {
bytes32 salt; // CREATE2 salt for deterministic addresses
string name; // Collection name (e.g., "Cosmic Dreams")
string symbol; // Token symbol (e.g., "COSMIC")
string baseURI; // CDN base URI for metadata
uint256 maxSupply; // Max tokens (0 = unlimited)
address collectionOwner; // Collection creator address
address minter; // Platform wallet authorized to mint
}Functions
| Function | Access | Purpose |
|---|---|---|
createCollection(CollectionParams) | Owner only | Deploy new Diamond proxy |
predictAddress(bytes32 salt) | View | Predict deployment address before deploying |
transferOwnership(address) | Owner only | Transfer factory ownership |
Deployment Flow
Platform API calls createCollection(params)
│
├── 1. Deploy Diamond proxy via CREATE2 (deterministic address)
│ └── Factory is temporary owner
│
├── 2. diamondCut — add all facets + run CollectionInit
│ ├── DiamondLoupeFacet (5 selectors)
│ ├── OwnershipFacet (2 selectors)
│ ├── ERC721Facet (13 selectors)
│ └── MintFacet (9 selectors)
│
└── 3. Transfer ownership to collectionOwnerFacets
ERC721Facet (13 functions)
Combines ERC-721 core + metadata in one facet.
| Function | Type | Purpose |
|---|---|---|
name() | View | Collection name |
symbol() | View | Token symbol |
tokenURI(uint256) | View | Returns baseURI + tokenId |
balanceOf(address) | View | Token count for address |
ownerOf(uint256) | View | Token owner |
approve(address, uint256) | Write | Approve transfer |
getApproved(uint256) | View | Get approved address |
setApprovalForAll(address, bool) | Write | Operator approval |
isApprovedForAll(address, address) | View | Check operator approval |
transferFrom(address, address, uint256) | Write | Transfer token |
safeTransferFrom (2 variants) | Write | Safe transfer with receiver check |
setBaseURI(string) | Admin | Update metadata base URI |
MintFacet (9 functions)
Custodial minting — only the authorized minter address can mint.
| Function | Type | Purpose |
|---|---|---|
mintTo(address) | Minter | Mint next token to recipient |
batchMintTo(address, uint256) | Minter | Batch mint to recipient |
totalSupply() | View | Current token count |
maxSupply() | View | Maximum supply (0 = unlimited) |
mintActive() | View | Is minting enabled |
minter() | View | Authorized minter address |
setMintActive(bool) | Owner | Toggle minting |
setMinter(address) | Owner | Change minter address |
setMaxSupply(uint256) | Owner | Update max supply |
Metadata Pattern
Token URI: https://cdn.emerge.pizza/nft-metadata/{collection_id}/{tokenId}
metadata.json at that URL:
{
"name": "Cosmic Dreams #1",
"description": "AI-generated artwork from Emerge",
"image": "https://cdn.emerge.pizza/generations/{collection_id}/1.png",
"external_url": "https://emerge.pizza/nft/{collection_id}/1",
"attributes": [
{ "trait_type": "Generator", "value": "ComfyUI" },
{ "trait_type": "Collection", "value": "Cosmic Dreams" }
]
}tokenURI(tokenId)on-chain returnsbaseURI + tokenId- Metadata JSON served from GCS via CDN
- Image/video assets stored in existing GCS buckets
- Metadata is updatable (just overwrite the JSON file)
- No IPFS for v1 — GCS + CDN is fast, cheap, and updateable
Minting Flow
1. User triggers generation in Emerge miniapp
2. API generates artwork via job queue (ComfyUI pipeline)
3. Asset stored in GCS: generations/{collection_id}/{tokenId}.png
4. Metadata JSON written to GCS: nft-metadata/{collection_id}/{tokenId}
5. API calls MintFacet.mintTo(recipientAddress) on collection Diamond
6. Token minted on Arbitrum, tokenURI points to CDN metadata
7. DB updated: output record linked to collection + tokenCustodial minting: Users never pay gas. The platform minter wallet submits all transactions. Payments happen off-chain via the miniapp.
What's Not Included (vs Old Plan)
| Feature | Old Plan | Current Status |
|---|---|---|
| Ponder indexer | Real-time blockchain indexing | Skipped — custodial mintTo means we track all mints server-side |
| 0xSplits | Trustless revenue distribution | Not in v1 — revenue handled off-chain |
| Token upgrades | EIP-712 version tracking | Not in v1 — can be added as a new facet later |
| ERC721A | Gas-optimized batch minting | Standard ERC-721 — incompatible with Diamond storage |
| IPFS metadata | Immutable metadata via Pinata | GCS + CDN instead |
All of these can be added later through new facets or infrastructure — that's the advantage of the Diamond pattern.
Monorepo Structure
packages/nft-contracts/
├── contracts/
│ ├── Diamond.sol # Base Diamond proxy
│ ├── DiamondFactory.sol # Factory deploys collections via CREATE2
│ ├── facets/
│ │ ├── DiamondCutFacet.sol # Manage facets
│ │ ├── DiamondLoupeFacet.sol # Introspection
│ │ ├── ERC721Facet.sol # ERC-721 + Metadata
│ │ ├── MintFacet.sol # Custodial minting
│ │ └── OwnershipFacet.sol # ERC-173 ownership
│ ├── libraries/
│ │ ├── LibDiamond.sol # Diamond storage + cut logic
│ │ ├── LibERC721.sol # ERC-721 storage
│ │ └── LibMint.sol # Mint storage
│ ├── interfaces/
│ │ ├── IDiamondCut.sol
│ │ ├── IDiamondLoupe.sol
│ │ ├── IERC165.sol
│ │ ├── IERC173.sol
│ │ └── IERC721.sol
│ └── upgradeInitializers/
│ └── CollectionInit.sol # One-time initializer
├── test/ # 28 tests passing
├── hardhat.config.ts # Hardhat 3
└── package.jsonNext Steps
- NFT API endpoints in emprops-api — setup, deploy, generate, mint, metadata
- Database schema migration — NFT fields on collection model
- Deploy to Arbitrum Sepolia — testnet validation
- RoyaltyFacet — ERC-2981 royalties as a new facet
See the Implementation Plan for the full 6-phase breakdown.
Related Documentation
- Diamond ADR — Full architecture decision record
- Implementation Plan — 6-phase breakdown
- Studio Revival ADR — Phase B: reconnect studio NFT code
- Legacy Arbitrum Docs — Superseded ERC721A + ERC1167 + Ponder approach
