NFT Operations Guide
How the Diamond NFT system works end-to-end — deploying infrastructure, creating collections, minting tokens, and serving metadata.
Environment Setup
The NFT system uses the monorepo's profile-based env system. All env vars are generated by pnpm env:build <profile>, which writes .env.<profile> files to each package directory. The deploy scripts pick up the right profile automatically.
| Variable | Service | Purpose |
|---|---|---|
NFT_DEPLOYER_PRIVATE_KEY | nft-contracts, emprops-api | Private key for the deployer/minter wallet |
EMPROPS_API_URL | monitor | URL to emprops-api (e.g., http://localhost:3335) |
ARBITRUM_SEPOLIA_RPC_URL | nft-contracts | RPC endpoint for Arbitrum Sepolia |
ARBITRUM_ONE_RPC_URL | nft-contracts | RPC endpoint for Arbitrum One |
For local development, the Hardhat node uses the default account 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 with the well-known private key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.
Infrastructure Deployment (One-Time Per Chain)
Infrastructure = DiamondFactory + all shared facets. This is deployed once per chain and rarely changes. The deploy command handles everything — compiling contracts, deploying them on-chain, and writing the deployment record.
Local (Hardhat Node)
# Terminal 1: Start the Hardhat node
pnpm nft:node
# Terminal 2: Deploy factory + facets
pnpm nft:deploy:localThis deploys all facets (ERC721, Mint, ContractURI, MetadataUpdate), the CollectionInit initializer, and the DiamondFactory. It writes all contract addresses to packages/nft-contracts/deployments/localhost.json.
Warning
The Hardhat node is in-memory. When you restart it, all contracts are gone. You'll need to redeploy and reset any stale collections in the DB (see Troubleshooting).
Arbitrum Sepolia (Testnet)
pnpm nft:deploy:sepoliaLoads .env.staging by default. To use a different profile:
pnpm nft:deploy:sepolia productionWrites packages/nft-contracts/deployments/arbitrum-sepolia.json.
Arbitrum One (Mainnet)
pnpm nft:deploy:mainnetLoads .env.staging by default. To use a different profile:
pnpm nft:deploy:mainnet productionWrites packages/nft-contracts/deployments/arbitrum-one.json.
What the Deploy Writes
Each deployment produces a JSON file at packages/nft-contracts/deployments/{network}.json:
{
"chainId": 421614,
"deployer": "0x...",
"timestamp": "2026-02-09T16:46:39.871Z",
"contracts": {
"DiamondCutFacet": "0x...",
"DiamondLoupeFacet": "0x...",
"OwnershipFacet": "0x...",
"ERC721Facet": "0x...",
"MintFacet": "0x...",
"ContractURIFacet": "0x...",
"CollectionInit": "0x...",
"DiamondFactory": "0x..."
}
}These files should be committed to the repo. They are static config — the factory address never changes once deployed. The monitor and emprops-api read them at runtime to know which factory to call when creating collections.
Docker Considerations
The monitor reads deployment JSONs from ../../packages/nft-contracts/deployments/ relative to process.cwd(). For Docker:
pnpm nft:build(part ofbuild:docker:all) compiles contracts and produces artifacts- Deployment JSON files are committed to the repo and included in the Docker build context
- No ENV vars needed for factory addresses — they come from the committed JSON files
Collection Lifecycle
1. Create a Collection (Deploy Diamond Proxy)
Collections are created through the Monitor NFT Admin UI (Collections tab). You enter an EmProps Collection UUID, and the monitor:
- Calls
GET /nft/collections/:idon emprops-api to fetch collection info - Pre-fills contract params from the collection record (name, symbol from title, baseURI, maxSupply from editions)
- On Deploy Collection, calls the DiamondFactory's
createCollection()on-chain - On success, calls
POST /nft/collections/:id/deploy-statuson emprops-api to write the contract address back to the DB
After deploy, the collection record in the DB has:
contract_address— the Diamond proxy addresschain_id— which chain it's ondeploy_tx_hash— the deployment transactionbase_token_uri— metadata base URLmint_status— "deployed"
2. Mint Tokens
Via emprops-api (metadata pipeline — the standard path):
POST /nft/collections/:collectionId/mint
{
"recipient": "0x...",
"chainId": 31337,
"outputId": "uuid" // optional — links to generated artwork
}This endpoint handles the full mint flow:
- Looks up the collection's contract address from DB
- Gets the next token ID from the contract (
totalSupply) - Builds ERC-721 metadata with trait attributes from
is_featurevariables - Calls
MintFacet.mintTo(recipient)on-chain - Creates/updates a deck record with mint state
- Stores
token_metadataon the output record
Via Monitor Admin UI (direct on-chain, no metadata):
Monitor → Mint tab → enter collection address, recipient, quantity → mint. This calls the contract directly, bypassing the metadata pipeline. Useful for testing.
3. Serve Metadata
Public endpoints (no auth — NFT marketplaces read these):
GET /nft/metadata/:collectionId/collection.json → ERC-7572 contract metadata
GET /nft/metadata/:collectionId/:tokenId → ERC-721 token metadataThe on-chain tokenURI() resolves to these URLs. Token metadata includes trait attributes extracted from is_feature variables in the collection's generation config.
4. Check Mint Status
GET /nft/decks/:deckId/mint-status
GET /nft/collections/:collectionId/mintsAPI Endpoint Reference
emprops-api (shared backend — port 3335)
All authenticated endpoints use jwtOrApiKeyMiddleware (passthrough when ENABLE_AUTH=false).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /nft/collections/:collectionId | Yes | Collection info + deployment status |
| POST | /nft/collections/:collectionId/deploy-status | Yes | Write-back after deploy |
| POST | /nft/collections/:collectionId/reset | Yes | Reset stale deployment |
| POST | /nft/collections/:collectionId/mint | Yes | Mint with metadata pipeline |
| GET | /nft/decks/:deckId/mint-status | Yes | Poll mint status |
| GET | /nft/collections/:collectionId/mints | Yes | List minted tokens |
| GET | /nft/metadata/:collectionId/collection.json | No | Contract metadata (ERC-7572) |
| GET | /nft/metadata/:collectionId/:tokenId | No | Token metadata (ERC-721) |
monitor (admin UI — port 3333)
Monitor routes proxy to emprops-api for DB operations. On-chain operations (deploy, direct mint, admin) use viem directly.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/nft/collections/lookup?collectionId=... | Proxy → emprops-api collection lookup |
| POST | /api/nft/collections/reset | Proxy → emprops-api reset |
| GET | /api/nft/collections?chainId=&addresses= | Read on-chain collection info |
| POST | /api/nft/collections | Deploy new collection (on-chain + DB writeback) |
| POST | /api/nft/mint | Direct on-chain mint (no metadata) |
| POST | /api/nft/admin | Admin operations (setMinter, setMintActive, etc.) |
Data Model
Collection (DB) Diamond Proxy (on-chain)
┌─────────────────────┐ ┌──────────────────────┐
│ id (UUID) │──────────→│ address │
│ title │ │ name() │
│ contract_address ───┼──────────→│ symbol() │
│ chain_id │ │ totalSupply() │
│ deploy_tx_hash │ │ maxSupply() │
│ base_token_uri │ │ mintActive() │
│ mint_status │ │ owner() │
│ editions │ │ minter() │
│ data.variables[] │ │ tokenURI(id) │
└─────────┬───────────┘ └──────────────────────┘
│ has many
▼
Deck (DB) = NFT Token
┌─────────────────────┐
│ id (UUID) │
│ collection_id │
│ token_id │
│ mint_tx_hash │
│ mint_recipient │
│ mint_status │
│ minted_at │
│ metadata_uri │
│ cover_output_id ────┼───→ Output.token_metadata (ERC-721 JSON)
└─────────────────────┘Troubleshooting
Stale Deployments
Symptom: Collection shows as "deployed" in the DB but the contract doesn't exist on-chain (e.g., after Hardhat node restart).
Fix — Monitor UI: Enter the collection UUID → the UI detects the stale deployment → click "Reset Collection".
Fix — API:
curl -X POST http://localhost:3335/nft/collections/{collectionId}/resetThis nulls out contract_address, chain_id, deploy_tx_hash, base_token_uri on the collection and resets all deck mint state.
Contract Artifacts Missing
Symptom: Deploy gives Unexpected token '<', "<!DOCTYPE"... error (HTML instead of JSON).
Cause: pnpm nft:clean deletes contract artifacts. The monitor imports bytecodes from these JSON files for ABI encoding.
Fix:
pnpm nft:build # clean + recompileDocker Image Not Found
Symptom: Docker image not found: emprops/emprops-api:latest-local-docker
Fix: Build the image with the matching profile:
pnpm build:docker:emprops-api local-dockerImages are tagged latest-{profile} (e.g., latest-local-docker, latest-production).
Monitor Can't Reach emprops-api
Symptom: Collection lookup returns EMPROPS_API_URL not configured.
Fix: Ensure EMPROPS_API_URL is set in the monitor's environment. For local dev: http://localhost:3335.
pnpm Scripts Reference
| Script | Purpose |
|---|---|
pnpm nft:build | Clean + compile contracts |
pnpm nft:test | Run contract tests (31 tests) |
pnpm nft:node | Start local Hardhat node |
pnpm nft:deploy:local [profile] | Deploy factory to localhost (default: local) |
pnpm nft:deploy:sepolia [profile] | Deploy factory to Arbitrum Sepolia (default: staging) |
pnpm nft:deploy:mainnet [profile] | Deploy factory to Arbitrum One (default: staging) |
pnpm nft:dev | Start node + monitor together |
pnpm nft:clean | Clean artifacts + delete deployment JSONs |
pnpm build:docker:all | Build all Docker images (includes nft:build) |
