Skip to main content

Mint an NFT

An NFT is just a native token with a quantity of 1, minted under a policy that closes after a set slot, which fixes the supply once that slot passes. The name, image, and description are attached to the minting transaction as CIP-25 metadata (label 721). This page mints one and sends it to a wallet, pick your tool below.

New to policies and what makes a token "non-fungible"? Read Minting policies and What are native tokens first. This page is the hands-on version.

What you'll build​

  • A minting policy only you can mint from (time-locked, so the supply is provably fixed once the lock slot passes)
  • One NFT (quantity 1) carrying CIP-25 metadata
  • A transaction that mints it, attaches the metadata, and pays it to a recipient

Prerequisites​

  • Test ADA on Preview or Pre-Production (faucet)
  • A provider key (Blockfrost) for the SDK tabs, or a running node for cardano-cli
  • An image pinned to IPFS (the ipfs://... URI goes in the metadata)
CIP-25 or CIP-68?

CIP-25 stores metadata in the minting transaction (label 721). Simplest, and what this page uses. CIP-68 stores metadata in an on-chain datum that a smart contract can read and update later. Choose CIP-68 only if your NFT's metadata needs to change or be read on-chain. See Token metadata & registry.

Mint it​

import {
Address, Assets, NativeScripts, ScriptHash, Time, TransactionMetadatum,
preprod, Client
} from "@evolution-sdk/evolution"

const client = Client.make(preprod)
.withBlockfrost({
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
projectId: process.env.BLOCKFROST_API_KEY!,
})
.withSeed({ mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 })

// Time-locked policy: your key signs, and minting closes at lockSlot, one hour from now
const { paymentCredential } = await client.address()
const lockSlot = Time.getSlotAt(60 * 60 * 1000, "Preprod")
const nativeScript = NativeScripts.makeScriptAll([
NativeScripts.makeInvalidHereafter(lockSlot).script, // "before": lockSlot
NativeScripts.makeScriptPubKey(paymentCredential.hash).script,
])

const policyId = ScriptHash.toHex(ScriptHash.fromScript(nativeScript))
const assetName = "4d794e4654303031" // "MyNFT001" in hex

let mintAssets = Assets.fromLovelace(0n)
mintAssets = Assets.addByHex(mintAssets, policyId, assetName, 1n)

let sendAssets = Assets.fromLovelace(2_000_000n) // min ADA travels with the NFT
sendAssets = Assets.addByHex(sendAssets, policyId, assetName, 1n)

const nftMetadata = new Map([
[policyId, new Map([
["MyNFT001", new Map([ // CIP-25 v1: the asset name as UTF-8 text
["name", "My First NFT"],
["image", "ipfs://QmYourImageHashHere"],
["mediaType", "image/png"],
["description", "Minted with Evolution SDK"],
])]
])]
])

const tx = await client
.newTx()
.mintAssets({ assets: mintAssets })
.attachScript({ script: nativeScript })
.attachMetadata({ label: 721n, metadata: nftMetadata }) // 721n, bigint
.payToAddress({ address: Address.fromBech32("addr_test1..."), assets: sendAssets })
.setValidity({ to: Time.slotToUnixTime(lockSlot, preprod.slotConfig) }) // upper bound = lockSlot
.build()

const signed = await tx.sign()
const txHash = await signed.submit()

The builder handles fees, coin selection, and change. mintAssets with quantity 1n is what makes it non-fungible; attachMetadata under 721n is the CIP-25 standard.

Make it a true one-of-one​

An NFT derives value from guaranteed scarcity. A time-locked policy (the before slot in every tab above) means no more tokens can ever be minted under that policy once the deadline passes, enforced at the protocol level. Each tab sets the transaction's upper bound to the lock slot, because the policy rejects any transaction without one. Buyers can verify it by inspecting the policy. Before the deadline the key holder can still mint more; a one-shot policy rules that out from the first mint. See Validity intervals.

Updatable metadata: CIP-68​

CIP-25 writes the metadata into the minting transaction, where it is permanent and readable only off-chain. CIP-68 instead stores it in an inline datum on a reference token, so it can be updated later and read on-chain by smart contracts through reference inputs. Each asset becomes a pair: a reference token (asset-name label 100) held at a script address carrying the metadata datum, and a user token (label 222) that lives in the holder's wallet. For when to choose it over CIP-25, see Token metadata & registry.

CIP-68 needs no Plutus minting policy: the native policy above can mint the pair, as long as both tokens share its policy ID and carry the CIP-67 prefixes, one reference token per user token. What needs a script is the reference token's holder, because updating the metadata spends that output: an always-succeed holder lets anyone rewrite the metadata or take the token. The minimal holder accepts only the issuer's signature:

use aiken/collection/list
use aiken/crypto.{VerificationKeyHash}
use cardano/transaction.{OutputReference, Transaction}

validator cip68_holder(issuer: VerificationKeyHash) {
spend(
_datum: Option<Data>,
_redeemer: Data,
_own_ref: OutputReference,
tx: Transaction,
) {
list.has(tx.extra_signatories, issuer)
}

else(_) {
fail @"unsupported purpose"
}
}

Save it as validators/cip68_holder.ak; each tab applies your key hash to it and locks the (100) token at the resulting address, with the metadata as its inline datum:

// setup from the CIP-25 example above, its imports through `policyId`
import { Bytes, Data, InlineDatum, Label, PlutusV3, Text, UPLC } from "@evolution-sdk/evolution"
import blueprint from "./plutus.json" with { type: "json" } // from `aiken build`

// Metadata lives on the reference token as a CIP-68 datum: Constr 0 [metadata, version, extra]
const metadata = Data.map([
[Text.toBytes("name"), Text.toBytes("CIP-68 Token")],
[Text.toBytes("image"), Text.toBytes("ipfs://QmYourImageHashHere")],
])
const referenceDatum = Data.constr(0n, [metadata, 1n, Data.constr(0n, [])]) // extra: Unit when unused

// Asset names carry the CIP-67 label prefix: (100) reference, (222) user
const name = Text.toHex("MyCIP68Token")
const refNameHex = Label.toLabel(100) + name // 000643b0...
const userNameHex = Label.toLabel(222) + name // 000de140...

// The cip68_holder validator, applied to your key hash, holds the reference token
const holderCode = blueprint.validators.find((v) => v.title === "cip68_holder.cip68_holder.spend")!.compiledCode
const holder = new PlutusV3.PlutusV3({
bytes: Bytes.fromHex(UPLC.applySingleCborEncoding(UPLC.applyParamsToScript(holderCode, [paymentCredential.hash]))),
})
const scriptAddress = new Address.Address({ networkId: 0, paymentCredential: ScriptHash.fromScript(holder) })

let mintAssets = Assets.fromLovelace(0n)
mintAssets = Assets.addByHex(mintAssets, policyId, refNameHex, 1n)
mintAssets = Assets.addByHex(mintAssets, policyId, userNameHex, 1n)

let refOutput = Assets.fromLovelace(2_000_000n)
refOutput = Assets.addByHex(refOutput, policyId, refNameHex, 1n)

const tx = await client
.newTx()
.mintAssets({ assets: mintAssets })
.attachScript({ script: nativeScript })
// reference token (100) -> holder script, metadata as its inline datum (the user token goes to change)
.payToAddress({
address: scriptAddress,
assets: refOutput,
datum: new InlineDatum.InlineDatum({ data: referenceDatum }),
})
.setValidity({ to: Time.slotToUnixTime(lockSlot, preprod.slotConfig) })
.build()

const signed = await tx.sign()
const txHash = await signed.submit()

To update the metadata, the issuer spends that output and re-creates it with the new datum, adding lovelace if the larger datum raises the minimum ADA.

Royalties: CIP-27​

A royalty is recorded as a single token (empty asset name) under metadata label 777, carrying a rate and a recipient address, minted once under the same policy as the NFTs it covers. Marketplaces that honor CIP-27 read it from the first asset minted under a policy to route a cut of secondary sales to the creator, so mint it first, under a fresh policy whose lock slot you reuse for the NFTs. Metadata strings are capped at 64 bytes, so the address is split into an array.

Evolution has no royalty-specific helper, so you attach the CIP-27 structure as plain metadata under label 777n:

// setup from the CIP-25 example above, its imports through `policyId`
const royaltyAddress = "addr_test1qz..." // royalty recipient
const royaltyMetadata = TransactionMetadatum.fromEntries([
["rate", "0.05"], // 5%
["addr", royaltyAddress.match(/.{1,64}/g)!], // split into strings of at most 64 bytes
])

let royaltyToken = Assets.fromLovelace(0n)
royaltyToken = Assets.addByHex(royaltyToken, policyId, "", 1n) // empty asset name

const tx = await client
.newTx()
.mintAssets({ assets: royaltyToken })
.attachScript({ script: nativeScript })
.attachMetadata({ label: 777n, metadata: royaltyMetadata })
.setValidity({ to: Time.slotToUnixTime(lockSlot, preprod.slotConfig) })
.build()

const signed = await tx.sign()
const txHash = await signed.submit()

Common pitfalls​

ProblemCauseFix
NFT not showing in walletmetadata structure mismatchpolicy ID and asset name in metadata must exactly match the minted token
"Minting not allowed"wrong key signedthe signing key's hash must match the policy
Type error on label (Evolution)721 instead of 721nuse the bigint 721n
Min UTxO too lownot enough ADA with the NFTinclude 2 ADA in the NFT output, comfortably above the floor

Next steps​