Smart Contracts
Navigation
- Overview - The full story of what we're building
- Technical Architecture - System-wide technical details
- Development Plan - Phases and milestones
This document details the smart contract architecture for the Arbitrum NFT Launchpad—a factory-based system where each collection deploys as its own contract.
Architecture Overview
┌────────────────────────────────────────────────────────────────┐
│ NFTContractFactory (UUPS Proxy) │
│ ├── Owner: Platform Admin │
│ └── Upgradeable via UUPS │
└───────────────┬─────────────────────┬──────────────────────────┘
│ │
┌───────────▼───────────┐ ┌─────▼─────────────┐
│ Emerge_721 V1 (Impl) │ │ Future Versions │
│ └── ERC721A │ │ └── EditionApp │
└───────────┬───────────┘ │ └── AuctionApp │
│ └───────────────────┘
│
┌───────────────▼────────────────────────────────────────────────┐
│ ERC1167 Minimal Proxy │
│ 45-byte contract that delegates all calls to Emerge_721 │
└───────────────┬─────────────────────┬─────────────────┬────────┘
│ │ │
┌───────────▼───────────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Clone 1 │ │ Clone 2 │ │ Clone N │
│ (Collection A) │ │ (Collection B)│ │ (Collection Z)│
│ Own storage/tokens │ │Own storage/.. │ │ Own storage/..│
└───────────────────────┘ └───────────────┘ └───────────────┘Design Principles
| Principle | Implementation |
|---|---|
| Each collection = own contract | Factory deploys minimal proxy per collection |
| Gas efficient deployment | ERC1167 proxies - Significant gas reduction |
| Deterministic addresses | CREATE2 for predictable addresses before deployment |
| Upgradeable factory | UUPS pattern allows factory evolution; collections are immutable |
| Versioned implementations | New collection types without affecting existing collections |
MVP Approach: Centralized Mint-To
For the initial launch, we're using a centralized minting model:
User pays via Diamo/card/crypto
│
▼
┌─────────────────┐
│ EmProps API │ ← Receives payment, verifies
└────────┬────────┘
│
▼
┌─────────────────┐
│ Admin Wallet │ ← Platform-controlled EOA or multisig
└────────┬────────┘
│ calls mintTo(recipient, quantity)
▼
┌─────────────────┐
│ Emerge_721 │ ← Mints NFT(s) directly to user's wallet
│ (Clone) │
└─────────────────┘Why centralized for MVP:
- Users never need to sign blockchain transactions
- Payment flexibility (credit card, any crypto via Diamo)
- Simpler onboarding for non-crypto-native users
- Platform absorbs gas costs
Contract Inventory
| Contract | Type | Purpose | Upgradeable |
|---|---|---|---|
| NFTContractFactory | UUPS Proxy | Deploys collections, registers implementations | Yes |
| Emerge_721 V1 | Implementation | ERC721A collection template | No (immutable) |
| Emerge_721 Clone | ERC1167 Proxy | Individual collection instance | No |
NFTContractFactory Contract
The NFTContractFactory is the deployment engine. It creates new collections and manages implementation versions.
Deployment Flow
API requests collection creation
│
├──▶ 1. Factory deploys Emerge_721 clone (CREATE2)
│ └── Deterministic address from salt
│
└──▶ 2. Clone is initialized
└── Set name, symbol, maxSupply, owner, baseURIFunctions
// === Deployment ===
function createNFTContract(
bytes32 appType, // e.g., keccak256("SIMPLE_APP_V1")
string calldata appId, // Unique identifier for CREATE2
string calldata name, // Collection name
string calldata symbol, // Token symbol
bytes calldata initData // Encoded initialization params
) external onlyOwner returns (address app)
// === Implementation Management ===
function registerImplementation(
bytes32 appType,
address implementation
) external onlyOwner
function getImplementation(bytes32 appType) external view returns (address)
// === Address Prediction (CREATE2) ===
function generateAppSalt(
string calldata appId,
address deployer
) public pure returns (bytes32)
function predictAppAddress(
bytes32 appType,
bytes32 salt
) public view returns (address)
// === UUPS Upgrade ===
function upgradeTo(address newImplementation) external onlyOwner
function upgradeToAndCall(address newImplementation, bytes calldata data) external onlyOwnerEvents
event AppCreated(bytes32 indexed appType, address indexed app, string appId, bytes32 salt)
event ImplementationRegistered(bytes32 indexed appType, address implementation)CREATE2 Determinism
Collections get predictable addresses before deployment:
Benefits:
- Pre-publish metadata with correct contract address
- Same address across multiple chains (if we expand to other L2s)
- Share collection links before deployment completes
Emerge_721 V1 Contract
Emerge_721 is the actual NFT collection—an ERC721A contract deployed as a minimal proxy (clone). Each collection is a separate instance with its own address, storage, and token IDs.
Features
| Feature | Implementation |
|---|---|
| Batch minting | ERC721A for gas-efficient multi-mint |
| Admin minting | mintTo() for centralized minting to recipients |
| Configurable | Supply, baseURI |
| Pausable | Admin can pause/unpause minting |
| Token upgrades | Version tracking with EIP-712 approval signatures |
| Dynamic metadata | HTTP API serves version-aware metadata |
Functions
// === Initialization (called once via clone) ===
function initialize(
string calldata name,
string calldata symbol,
address owner,
uint256 maxSupply,
string calldata baseURI,
address royaltySplit, // ERC-2981 royalty receiver
uint96 royaltyBps // Royalty percentage (e.g., 500 = 5%)
) external initializer
// === Minting (admin only for MVP) ===
function mintTo(
address recipient,
uint256 quantity
) external onlyOwner
function mintBatchTo(
address[] calldata recipients,
uint256[] calldata quantities
) external onlyOwner
// === Configuration ===
function setMaxSupply(uint256 _maxSupply) external onlyOwner
function setBaseURI(string calldata _baseURI) external onlyOwner
function pause() external onlyOwner
function unpause() external onlyOwner
// === Token Upgrades ===
function initiateUpgrade() external onlyOwner
function upgradeTokenFor(uint256 tokenId) external onlyOwner
function upgradeTokensFor(uint256[] calldata tokenIds) external onlyOwner
function upgradeTokenWithSignature(
uint256 tokenId,
uint256 upgradeRound,
bytes calldata signature
) external onlyOwner
// === View Functions ===
function totalMinted() external view returns (uint256)
function maxSupply() external view returns (uint256)
function baseURI() external view returns (string memory)
function paused() external view returns (bool)
function currentUpgradeRound() external view returns (uint256)
function tokenVersion(uint256 tokenId) external view returns (uint256)
function nonces(address holder) external view returns (uint256)
// === ERC721A Standard (inherited) ===
function balanceOf(address owner) external view returns (uint256)
function ownerOf(uint256 tokenId) external view returns (address)
function safeTransferFrom(address from, address to, uint256 tokenId) external
function transferFrom(address from, address to, uint256 tokenId) external
function approve(address to, uint256 tokenId) external
function setApprovalForAll(address operator, bool approved) external
function getApproved(uint256 tokenId) external view returns (address)
function isApprovedForAll(address owner, address operator) external view returns (bool)
function tokenURI(uint256 tokenId) external view returns (string memory)
function name() external view returns (string memory)
function symbol() external view returns (string memory)
function totalSupply() external view returns (uint256)
// === Ownership ===
function owner() external view returns (address)
function transferOwnership(address newOwner) external onlyOwner
// === ERC-2981 Royalties ===
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount)
function setDefaultRoyalty(address receiver, uint96 royaltyBps) external onlyOwnerEvents
event TokensMinted(address indexed recipient, uint256 startTokenId, uint256 quantity)
event BaseURIUpdated(string newBaseURI)
event MaxSupplyUpdated(uint256 newMaxSupply)
event DefaultRoyaltyUpdated(address receiver, uint96 royaltyBps)
event UpgradeInitiated(uint256 indexed upgradeRound)
event TokenUpgraded(uint256 indexed tokenId, uint256 indexed upgradeRound)Metadata Architecture
On-Chain vs Off-Chain
The contract stores minimal on-chain data:
| On-Chain | Off-Chain |
|---|---|
baseURI (string) | Token images |
tokenVersion[tokenId] | Token attributes/traits |
| Collection name/symbol | Rarity calculations |
| Royalty config | Collection description |
How tokenURI Works
function tokenURI(uint256 tokenId) public view returns (string memory) {
return string(abi.encodePacked(baseURI, tokenId.toString()));
}
// Example: baseURI = "https://api.emerge.app/collections/ABC123/tokens/"
// tokenURI(42) → "https://api.emerge.app/collections/ABC123/tokens/42"Dynamic Metadata via HTTP API
For MVP, we serve metadata from our API (not IPFS):
┌───────────────────────────────────────────────────────────────┐
│ OpenSea/Marketplace │
│ └── Calls tokenURI(42) │
└──────────────────────────────┬────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ Emerge_721 Contract │
│ └── Returns "https://api.emerge.app/.../tokens/42" │
└──────────────────────────────┬────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────┐
│ EmProps API │
│ ├── Looks up token in database │
│ ├── Checks tokenVersion (upgrade status) │
│ └── Returns ERC721 metadata JSON │
└───────────────────────────────────────────────────────────────┘Why HTTP instead of IPFS for MVP:
- Dynamic content generation (on-demand, not pre-generated)
- Upgrade system requires metadata to change based on token version
- Simpler infrastructure (GCS + CDN)
- Can migrate to IPFS later for specific collections
ERC721 Metadata JSON
{
"name": "Collection Name #42",
"description": "Token description",
"image": "https://cdn.emerge.app/collections/ABC123/42.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Style", "value": "Abstract" },
{ "trait_type": "Version", "value": "2" }
]
}Collection-Level Metadata
function contractURI() public view returns (string memory) {
return string(abi.encodePacked(baseURI, "contract-metadata"));
}OpenSea and other marketplaces call contractURI() for collection-level information:
{
"name": "My Collection",
"description": "A generative art collection",
"image": "https://cdn.emerge.app/collections/ABC123/cover.png",
"external_link": "https://emerge.app/collections/ABC123",
"seller_fee_basis_points": 500,
"fee_recipient": "0xRoyaltySplitAddress..."
}Token Upgrade System
Creators can "upgrade" their collections by adding new content. Token holders opt-in to receive upgrades.
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ 1. Creator adds new content to existing collection │
│ └── New images, updated traits, enhanced versions │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Creator requests upgrade activation (can pay fee) │
│ └── Off-chain: payment via Diamo/card/crypto │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Platform admin calls initiateUpgrade() │
│ └── On-chain: increments currentUpgradeRound │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Token holders opt-in (can pay fee) │
│ └── Sign EIP-712 message (gasless approval) │
│ └── Platform submits signature to contract │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. API serves upgraded metadata │
│ └── Checks tokenVersion[tokenId] vs currentUpgradeRound │
│ └── Returns appropriate version content │
└─────────────────────────────────────────────────────────────────┘Upgrade State
// === Upgrade State ===
uint256 public currentUpgradeRound;
mapping(uint256 => uint256) public tokenVersion; // tokenId → version
// === Admin Functions ===
function initiateUpgrade() external onlyOwner {
currentUpgradeRound++;
emit UpgradeInitiated(currentUpgradeRound);
}
function upgradeTokenFor(uint256 tokenId) external onlyOwner {
require(tokenVersion[tokenId] < currentUpgradeRound, "Already upgraded");
tokenVersion[tokenId] = currentUpgradeRound;
emit TokenUpgraded(tokenId, currentUpgradeRound);
}
function upgradeTokensFor(uint256[] calldata tokenIds) external onlyOwner {
for (uint256 i = 0; i < tokenIds.length; i++) {
uint256 tokenId = tokenIds[i];
require(tokenVersion[tokenId] < currentUpgradeRound, "Already upgraded");
tokenVersion[tokenId] = currentUpgradeRound;
emit TokenUpgraded(tokenId, currentUpgradeRound);
}
}Upgrade Events
event UpgradeInitiated(uint256 indexed upgradeRound);
event TokenUpgraded(uint256 indexed tokenId, uint256 indexed upgradeRound);Monetization Opportunity
Upgrades can be monetized at two points:
| Who | Pays For | Example |
|---|---|---|
| Creator | Activating upgrade capability | 1 ETH to enable upgrades |
| Token Holder | Upgrading their specific token | 0.1 ETH per token upgrade |
All payments are handled off-chain (via Diamo, credit card, or crypto to platform wallet). The contract only tracks on-chain state.
EIP-712 Signatures (Gasless Approvals)
Token holders can cryptographically prove they approve an upgrade without paying gas. The platform submits the signature to the contract.
Domain Separator
bytes32 public DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("Emerge721")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
}Upgrade Approval Type
bytes32 constant UPGRADE_APPROVAL_TYPEHASH = keccak256(
"UpgradeApproval(uint256 tokenId,uint256 upgradeRound,uint256 nonce)"
);
mapping(address => uint256) public nonces;Signature Verification
function upgradeTokenWithSignature(
uint256 tokenId,
uint256 upgradeRound,
bytes calldata signature
) external onlyOwner {
address holder = ownerOf(tokenId);
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(
UPGRADE_APPROVAL_TYPEHASH,
tokenId,
upgradeRound,
nonces[holder]++
))
));
require(ECDSA.recover(digest, signature) == holder, "Invalid signature");
require(upgradeRound == currentUpgradeRound, "Wrong round");
require(tokenVersion[tokenId] < currentUpgradeRound, "Already upgraded");
tokenVersion[tokenId] = upgradeRound;
emit TokenUpgraded(tokenId, upgradeRound);
}Why EIP-712?
| Benefit | Description |
|---|---|
| Gasless for users | Holders sign a message, don't pay gas |
| Cryptographic proof | Signature proves holder approved the upgrade |
| Replay protection | Nonce prevents signature reuse |
| Human-readable | Wallets display what's being signed |
| Standard | Widely supported by wallets (MetaMask, Rainbow, etc.) |
Understanding ERC1167 (Clones)
ERC1167 is a deployment technique, not an NFT standard. Here's how it works with our architecture:
What is ERC1167?
A minimal proxy contract (45 bytes) that delegates all calls to a shared implementation. Think of it like a shortcut:
┌──────────────────────────────────────────────────────────┐
│ Without Clones │
│ │
│ Collection A: [Full ERC721A code] ~20KB │
│ Collection B: [Full ERC721A code] ~20KB (duplicate!) │
│ Collection C: [Full ERC721A code] ~20KB (duplicate!) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ With Clones │
│ │
│ Emerge_721 V1 Implementation: [Full ERC721A code] ~20KB │
│ │
│ Collection A: [45-byte pointer] → Implementation │
│ Collection B: [45-byte pointer] → Implementation │
│ Collection C: [45-byte pointer] → Implementation │
└──────────────────────────────────────────────────────────┘Key Points
- Clones ARE ERC721A contracts - they just share code with other clones
- Each clone has its own storage - token ownership, balances, metadata are independent
- Clones are immutable - once deployed, they always point to the same implementation
- Collectors see normal NFTs - the proxy is transparent to users and marketplaces
Versioned Implementations
When we improve Emerge_721, we deploy a new implementation:
Emerge_721 V1 Implementation
└── Clone 1 (Collection A) → forever on V1
└── Clone 2 (Collection B) → forever on V1
Emerge_721 V2 Implementation (new features!)
└── Clone 3 (Collection C) → uses V2
└── Clone 4 (Collection D) → uses V2Old collections keep working. New collections get improvements.
Revenue Distribution (0xSplits)
Revenue is distributed via 0xSplits—an audited, immutable payment splitting protocol.
Two Splits Per Collection
Each collection has two separate Split contracts with different recipient structures:
| Split | When | Recipients | Percentages |
|---|---|---|---|
| Primary Sales | User mints NFT | Creator, Arbitrum Foundation, Platform | TBD |
| Royalties | NFT resold on marketplace | Creator, Platform | 80% / 20% |
Why Different Splits?
- Primary sales include Arbitrum Foundation (grant requirement)
- Royalties are simpler: creator gets majority, platform takes small cut
- Arbitrum Foundation only participates in initial sales, not secondary market
Primary Sales Flow
User pays via Diamo/card/crypto
│
▼
┌─────────────────┐
│ Platform │ ← Receives payment, mints NFT
└────────┬────────┘
│ Sends revenue to Split
▼
┌─────────────────┐
│ 0xSplits │ ← Immutable split contract
│ (Primary) │
└────────┬────────┘
│ Auto-distributes:
├── X% → Creator wallet
├── Y% → Arbitrum Foundation
└── Z% → Platform (Emerge)Royalties Flow (Secondary Sales)
Emerge_721 implements ERC-2981 (NFT Royalty Standard):
// Returns royalty info for marketplaces
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount)
{
// receiver = royalty Split contract address
// royaltyAmount = salePrice * royaltyBps / 10000
}Collector sells NFT on OpenSea/Blur
│
▼
┌─────────────────┐
│ Marketplace │ ← Queries royaltyInfo()
└────────┬────────┘
│ Sends royalty to Split
▼
┌─────────────────┐
│ 0xSplits │ ← Royalty-specific split
│ (Royalties) │
└────────┬────────┘
│ Auto-distributes:
├── 80% → Creator wallet
└── 20% → Platform (Emerge)Emerge_721 Royalty Functions
// === ERC-2981 Royalties ===
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount)
function setRoyaltyInfo(address receiver, uint96 royaltyBps) external onlyOwner
function setDefaultRoyalty(address receiver, uint96 royaltyBps) external onlyOwnerWhy 0xSplits?
| Benefit | Description |
|---|---|
| Immutable | Split ratios cannot be changed after creation |
| Trustless | No admin can redirect funds |
| Audited | Extensively audited contracts |
| Gas efficient | Batch withdrawals, minimal storage |
| Standard | Widely used across NFT ecosystem |
Split Configuration
When a collection is deployed, we create two split contracts:
// === Primary Sales Split (includes Arbitrum) ===
address[] primaryRecipients = [creatorWallet, arbitrumWallet, platformWallet];
uint32[] primaryPercentages = [TBD, TBD, TBD]; // Percentages TBD
address primarySplit = splitMain.createSplit(
primaryRecipients, primaryPercentages, 0, address(0)
);
// === Royalties Split (creator + platform only) ===
address[] royaltyRecipients = [creatorWallet, platformWallet];
uint32[] royaltyPercentages = [800000, 200000]; // 80%, 20%
address royaltySplit = splitMain.createSplit(
royaltyRecipients, royaltyPercentages, 0, address(0)
);How each Split is used:
| Split | Stored In | Used By |
|---|---|---|
primarySplit | Platform database | Platform sends mint revenue here (off-chain decision) |
royaltySplit | Emerge_721 contract | Returned by royaltyInfo() for marketplaces |
The contract only knows about royaltySplit (via ERC-2981). The primarySplit address is stored off-chain and used by the platform when forwarding mint payments.
Withdrawal
Recipients can withdraw their accumulated balance at any time:
// Anyone can trigger distribution to all recipients
splitMain.distributeETH(split, recipients, percentages, 0, address(0));
// Or individual recipient can withdraw their share
splitMain.withdraw(recipient, ethAmount, tokens);Gas Optimization
ERC721A Batch Minting
| Tokens Minted | Standard ERC721 | ERC721A | Savings |
|---|---|---|---|
| 1 | ~51,000 gas | ~51,000 gas | 0% |
| 5 | ~255,000 gas | ~53,000 gas | 79% |
| 10 | ~510,000 gas | ~55,000 gas | 89% |
| 20 | ~1,020,000 gas | ~57,000 gas | 94% |
Clone Deployment
| Deployment Type | Gas Cost | USD on Arbitrum (est.) |
|---|---|---|
| Full contract | ~2,000,000 gas | ~$20 |
| ERC1167 clone | ~45,000 gas | ~$0.50 |
Access Control
The platform uses a centralized admin model where the platform admin wallet performs all operations on behalf of collection creators. This enables gasless UX for creators and holders.
| Action | Who Can Perform |
|---|---|
| Deploy collection | Platform admin |
| Mint NFTs (mintTo) | Platform admin |
| Pause/unpause minting | Platform admin |
| Initiate upgrade round | Platform admin |
| Upgrade token (with signature) | Platform admin (submits holder's signature) |
| Set base URI | Platform admin |
| Set royalty info | Platform admin |
| Upgrade factory | Platform admin |
| Register new implementations | Platform admin |
| Transfer ownership | Collection owner (only action available to owner) |
Why Centralized Admin?
- Gasless UX: Creators never pay gas fees
- Simplified operations: Single admin key manages all collections
- Revenue handling: Platform handles payments and 0xSplits distribution
- Future option: Ownership can be transferred to creators who want direct control
Security Considerations
UUPS Upgrade Safety
The Factory uses UUPS (Universal Upgradeable Proxy Standard):
// Implementation includes upgrade logic
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
// Only owner can upgrade
}
// Built-in protection against bricking
function proxiableUUID() external view returns (bytes32) {
return _IMPLEMENTATION_SLOT;
}Bricking protection: New implementations must include proxiableUUID() or the upgrade reverts.
Clone Immutability
Once deployed, a Emerge_721 clone cannot be modified:
- No upgrades possible (minimal proxy, not UUPS)
- Code is fixed forever
- Provides security guarantee to collectors
Reentrancy Protection
mintTo()andmintBatchTo()protected bynonReentrant- Follows checks-effects-interactions pattern
Deployment Addresses
Testnet Only
These are Arbitrum Sepolia (testnet) addresses. Mainnet addresses will be added after production deployment.
| Contract | Arbitrum Sepolia | Arbitrum One |
|---|---|---|
| NFTContractFactory (Proxy) | TBD | TBD |
| NFTContractFactory (Impl) | TBD | TBD |
| Emerge_721 V1 (Impl) | TBD | TBD |
MVP vs Future
MVP (Now)
| Feature | Description |
|---|---|
| Centralized deployment | Collections created via EmProps API |
| Admin-controlled minting | mintTo() and mintBatchTo() only |
| No gas for users | Platform pays all transaction costs |
| Off-chain payments | Diamo, credit cards, crypto to platform wallet |
| HTTP metadata | Dynamic API-served metadata (not IPFS) |
| Token upgrades | Version tracking with EIP-712 approvals |
| Two-split revenue | Primary sales + royalties via 0xSplits |
Future Enhancements
| Feature | Description |
|---|---|
| Permissionless minting | Public mint() function with price validation |
| Creator-controlled collections | Transfer ownership to creators |
| Direct on-chain payments | Users pay contract directly |
| IPFS metadata | Immutable metadata for static collections |
| Multiple collection types | EditionApp, AuctionApp, AllowlistApp |
New app types are registered with the factory:
factory.registerImplementation(
keccak256("EDITION_APP_V1"),
editionAppImplementation
);Existing collections are unaffected—they continue using their original implementation.
Related Documentation
- Technical Architecture - Full system design
- Development Plan - Implementation phases
- Hardhat Package - Full contract reference
