Skip to content

Tezos NFT Implementation

Complete documentation of the production Tezos NFT system in emprops-studio.

Overview

The Tezos implementation is the production NFT system that enables users to:

  • Create NFT collections from AI-generated content
  • Mint NFTs with XTZ or fiat payments (Wert)
  • Manage collection settings (price, editions, status)
  • Configure revenue splits and royalties
  • Redeem collected funds

Contract Architecture

Two Contract Versions

The system supports two contract versions that coexist in production:

V1 - Projects Model (Legacy)

┌─────────────────────────────────────────────────────────────────┐
│                     Projects Contract                            │
├─────────────────────────────────────────────────────────────────┤
│ Storage:                                                         │
│   projects: BigMap<string, TezProject>                          │
│   allowlist: BigMap<string, Spot[]>                             │
│   freelist: BigMap<string, Spot[]>                              │
│                                                                  │
│ Entry Points:                                                    │
│   - create_project(params)                                       │
│   - mint_token(project_id, universal_id)                        │
│   - set_status(project_id, status)                              │
│   - set_price(project_id, price)                                │
│   - set_total_editions(project_id, editions)                    │
│   - split_funds(project_id)                                     │
└─────────────────────────────────────────────────────────────────┘

V1 Storage Structure (TezProject):

typescript
interface TezProject {
  project_name: string
  description: string
  cover_uri: string
  publish_date: string
  generator_entrypoint: string    // Code URL for generative art
  creator_address: string
  token_contract: string          // FA2 token contract
  primary_sales_split: SplitConfig
  secondary_sales_split: SplitConfig
  price: number                   // In mutez (1 XTZ = 1,000,000 mutez)
  editions: number
  mint_metadata: string           // IPFS metadata URL
  mode: MintMode                  // 0=ALLOWLIST, 1=FREELIST, 2=ALL
  status: Status                  // 0=OFF, 1=ON
  total_editions_minted: number
  funds_collected: number
  free_minter: string             // Address for free mints
  width: number
  height: number
  collection_type: TokenType
}

V2 - Collections Model (Current)

┌─────────────────────────────────────────────────────────────────┐
│                   Collections Contract                           │
├─────────────────────────────────────────────────────────────────┤
│ Storage:                                                         │
│   collections: BigMap<number, TezCollection>                    │
│   collectionsConfig: BigMap<number, CollectionConfig>           │
│   allowlist: BigMap<number, Spot[]>                             │
│   freelist: BigMap<number, Spot[]>                              │
│   fundsClaimed: BigMap<{collId, addr}, number>                  │
│   platformConfig: PlatformConfig                                │
│                                                                  │
│ Entry Points:                                                    │
│   - createCollection(params)                                     │
│   - mint(quantityToMint, collectionId, owner)                   │
│   - setStatus(collectionId, status)                             │
│   - setPrice(collectionId, price)                               │
│   - setTotalEditions(collectionId, editions)                    │
│   - withdrawFunds(collectionId)                                 │
└─────────────────────────────────────────────────────────────────┘

V2 Storage Structure (TezCollection):

typescript
interface TezCollection {
  author: string
  collectionMetadata: string      // IPFS metadata URL
  editions: number
  freeMinter: string
  fundsCollected: number
  mintMode: number                // 0=PUBLIC, 1=ALLOW_LIST, 2=FREE_LIST
  price: number                   // In mutez
  primarySales: Receiver[]        // { part_account, part_value }
  royalties: Receiver[]           // Secondary sales
  status: number                  // 0=OFF, 1=ON
  tokenContractAddress: string    // FA2 contract
  totalEditionsMinted: number
}

Contract Version Detection

The system automatically detects which contract version is deployed:

typescript
// utils/index.ts
async function getTezCollectionKey(
  contract: ContractAbstraction<Wallet>,
): Promise<TezosCollectionKey | null> {
  const storage = await contract.storage()
  if (storage?.projects) return "projects"      // V1
  if (storage?.collections) return "collections" // V2
  return null
}

FA2 Token Contract

Both versions interact with an FA2 (TZIP-012) token contract:

┌─────────────────────────────────────────────────────────────────┐
│                      FA2 Token Contract                          │
├─────────────────────────────────────────────────────────────────┤
│ Standard: TZIP-012                                               │
│                                                                  │
│ Entry Points:                                                    │
│   - mint(to, metadata_uri)    // Called by Projects/Collections │
│   - transfer(from, to, token_id)                                │
│   - balance_of(owner)                                           │
│                                                                  │
│ Storage:                                                         │
│   - ledger: BigMap<token_id, owner>                             │
│   - token_metadata: BigMap<token_id, metadata>                  │
└─────────────────────────────────────────────────────────────────┘

Data Flow

Collection Creation Flow

1. User fills collection form in Studio

   ├─▶ usePublishCollection() hook triggered

   ├─▶ openApi.computeTezosCollectionMetadata()
   │   └─▶ Returns: { metadata_url, mint_metadata_url, cover_image_url }

   ├─▶ Version detection: getTezCollectionKey()

   ├─▶ V1: createTezosProjectVersion()
   │   └─▶ contract.methodsObject.create_project(params).send()

   └─▶ V2: createTezosCollectionVersion()
       └─▶ contract.methodsObject.createCollection(params).send()

V1 Create Parameters:

typescript
{
  project: {
    p_project_id: string,          // Metadata ID from API
    p_project_name: string,
    p_creator_address: string,
    p_token_contract: string,      // FA2 address
    p_price: number * 1_000_000,   // Convert XTZ to mutez
    p_editions: number,
    p_mint_metadata: string,       // IPFS URL
    p_mode: MintMode.ALL,
    p_status: Status.ON,
    p_free_minter: string,
    p_description: string,
    p_cover_uri: string,
    p_publish_date: number,        // Unix timestamp
    p_generator_entrypoint: string,
    p_width: number,
    p_height: number
  },
  p_primary_sales_split: SplitConfig,
  p_secondary_sales_split: SplitConfig,
  p_allowlist: [],
  p_freelist: []
}

V2 Create Parameters:

typescript
{
  collection: {
    metadata: string,              // IPFS URL
    tokenContractAddress: string,
    freeMinter: string,
    author: string,
    mintMode: CollectionMintMode.PUBLIC,
    status: Status.ON,
    editions: number,
    price: number * 1_000_000      // Mutez
  },
  collectionConfig: {
    enableBatchMint: boolean,
    maxBatchMintAllowed: number,
    startDate: number,             // Unix timestamp
    endDate: null
  },
  freelist: [],
  allowlist: [],
  royalties: Receiver[],
  primarySalesReceivers: Receiver[]
}

Minting Flow

1. User clicks Mint Button

   ├─▶ checkMintPhase() validates eligibility
   │   ├─▶ Fetches on-chain storage
   │   ├─▶ Checks allowlist/freelist membership
   │   └─▶ Sets mint state (isAllowed, isPremint, isFreelist)

   ├─▶ useMintTezosToken() hook builds batch

   ├─▶ V1: Multiple mint_token calls
   │   for (i = 0; i < quantity; i++) {
   │     batch.withContractCall(
   │       contract.methodsObject.mint_token({
   │         p_id: project.id,
   │         universal_id: uuid()
   │       }),
   │       { amount: price, mutez: true }
   │     )
   │   }

   └─▶ V2: Single mint call
       batch.withContractCall(
         contract.methodsObject.mint({
           quantityToMint: quantity,
           collectionId: projectId,
           owner: userAddress
         }),
         { amount: price * quantity, mutez: true }
       )

Fund Redemption Flow

1. Creator clicks "Redeem Funds"

   ├─▶ getRedeemableTezFunds() calculates available amount
   │   │
   │   ├─▶ V1: funds_collected * (creator_split / 10000)
   │   │
   │   └─▶ V2: (funds_collected * part_value / 10000) - funds_claimed

   ├─▶ V1: contract.methodsObject.split_funds(project_id)

   └─▶ V2: contract.methodsObject.withdrawFunds(collection_id)

Mint Modes & Access Control

MintMode Enum

typescript
enum MintMode {
  ALLOWLIST = 0,  // Only whitelisted addresses
  FREELIST = 1,   // Only freelist addresses (free mints)
  ALL = 2         // Public minting
}

Allowlist/Freelist Structure

typescript
interface Spot {
  addr: string    // Tezos address
  limit: number   // Maximum mints allowed
  count: number   // Already minted count
}

Mint Status Checking

typescript
// utils/mintStatus.ts
async function getTezMintStatus(
  project: Project,
  userAddress?: string
): Promise<MintState> {
  const storage = await contract.storage()
  const key = getTezCollectionKey(contract)

  if (key === "projects") {
    const projectData = await storage.projects.get(project.id)
    // Check mode and lists
  } else {
    const collectionData = await storage.collections.get(project.projectId)
    // Check mode and lists
  }

  return {
    isLoading: false,
    isAllowed: boolean,
    isPremint: boolean,     // In allowlist phase
    isFreelist: boolean,    // In freelist phase
    isSoldOut: boolean,
    isMintInProgress: false
  }
}

Revenue Splitting

Split Configuration

typescript
interface Receiver {
  part_account: string  // Tezos address
  part_value: number    // Basis points (1 = 0.01%, 10000 = 100%)
}

interface SplitConfig {
  platform?: Receiver   // Platform fee (optional)
  creator: number       // Creator's percentage in basis points
  additional: Receiver[] // Additional receivers
}

Primary vs Secondary Sales

  • Primary Sales: Initial mint revenue split
  • Secondary Sales: Royalties on resales (stored as royalties)

Example Split

typescript
// 90% creator, 10% collaborator
{
  primarySales: [
    { part_account: "tz1Creator...", part_value: 9000 },
    { part_account: "tz1Collab...", part_value: 1000 }
  ],
  royalties: [
    { part_account: "tz1Creator...", part_value: 500 }  // 5% royalty
  ]
}

Price Handling

Mutez Conversion

All prices are stored in mutez (1 XTZ = 1,000,000 mutez):

typescript
// Converting for storage
const priceInMutez = priceInXTZ * 1_000_000

// Converting for display
const priceInXTZ = priceInMutez / 1_000_000

// Sending payment
batch.withContractCall(method, { amount: priceInMutez, mutez: true })

Price Update

typescript
// contract-client.ts
async function updateTezCollectionPrice(
  collection: Collection,
  newPrice: number  // In XTZ
) {
  const key = await getTezCollectionKey(contract)
  const priceInMutez = newPrice * 1_000_000

  if (key === "projects") {
    await contract.methodsObject.set_price(collection.id, priceInMutez).send()
  } else {
    await contract.methodsObject.setPrice(collection.projectId, priceInMutez).send()
  }
}

Network Configuration

Chain IDs

typescript
// types/wallet.ts
enum ChainId {
  TezosMainnet = "NetXdQprcVkpaWU",
  TezosGhostnet = "NetXnHfVqm9iesp"  // Testnet
}

RPC Selection

typescript
// utils/index.ts
function getRPCFromRegistry(chainId: ChainId): string {
  const registry = JSON.parse(process.env.NEXT_PUBLIC_NETWORKS_REGISTRY)
  const rpcs = registry[chainId]
  // Return random RPC to distribute load
  return rpcs[Math.floor(Math.random() * rpcs.length)]
}

Environment Variables

bash
# Contract Addresses
NEXT_PUBLIC_PROJECT_CONTRACT_ADDRESS=KT1...    # Projects/Collections contract
NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS=KT1...       # FA2 token contract

# Network
NEXT_PUBLIC_NETWORK=mainnet                     # mainnet or ghostnet
NEXT_PUBLIC_RPC_URL=https://mainnet.tezos.marigold.dev
NEXT_PUBLIC_NETWORKS_REGISTRY='{...}'           # JSON of RPC endpoints

# Batch Minting
NEXT_PUBLIC_TEZOS_BATCH_CONFIGURATION='{...}'   # Per-project overrides

Transaction Confirmation

Taquito Confirmation Handling

typescript
// All write operations wait for confirmation
const operation = await contract.methodsObject.mint(params).send()
await operation.confirmation(3)  // Wait for 3 block confirmations

// Batch operations
const batch = wallet.batch()
batch.withContractCall(...)
const batchOp = await batch.send()
await batchOp.confirmation()

Transaction Details Extraction

After minting, extract token details from transaction:

typescript
// transactionDetails.ts
function txToTokenIds(tx: Transaction) {
  // Filter for mint operations
  const mintOps = tx.filter(op =>
    op.parameter?.entrypoint === "mint_token" ||
    (op.parameter?.entrypoint === "mint" && op.parameter?.value?.collectionId)
  )

  // Extract token IDs from storage diffs
  const tokens = mintOps.map(op => ({
    universal_id: op.parameter.value.universal_id,
    editionNumber: extractFromDiffs(op, 'total_editions_minted')
  }))

  return tokens
}

Error Handling

Common Errors

ErrorCauseResolution
NOT_ENOUGH_EDITIONSMax supply reachedCheck totalEditionsLeft
NOT_ON_ALLOWLISTUser not whitelistedCheck mint mode
COLLECTION_PAUSEDStatus is OFFWait for creator to enable
WRONG_AMOUNTIncorrect paymentSend exact price in mutez
MAX_BATCH_EXCEEDEDToo many in one txReduce quantity

Validation Before Mint

typescript
// checkMintPhase() validates:
// 1. Collection exists and is active
// 2. User has minting rights (allowlist/freelist check)
// 3. Editions remaining > 0
// 4. Batch size within limits
// 5. User hasn't exceeded their allowance

Released under the MIT License.