Examples

Taproot: NFTs with State

Overview

Taproot's optional 32-byte state parameter enables on-chain NFT creation with metadata commitments. The state is pushed onto the script stack before execution, allowing for provable ownership and efficient transfers.

Key Benefits:

  • Compact metadata commitments (32 bytes on-chain)
  • Privacy via key path transfers (NFT details hidden)
  • Flexible spending conditions (trading, escrow, royalties)
  • Compatible with existing Taproot infrastructure

State Size: 32 bytes (required for NFT metadata hash)

Lotus Units: All examples use 1 XPI = 1,000,000 satoshis. NFT values range from 0.001 XPI (1,000 sats) to custom amounts.


NFT Structure

Taproot Output Format

OP_SCRIPTTYPE OP_1 <33-byte commitment> <32-byte state>

Components:

  • Script size: 69 bytes total (36 + 33 for state)
  • State: Hash of NFT metadata (IPFS CID, JSON metadata, etc.)
  • Ownership: Controlled by commitment public key

Metadata Schema

The 32-byte state typically contains a hash of off-chain metadata:

interface NFTMetadata {
  name: string
  description: string
  image: string // IPFS CID or URL
  attributes?: { trait_type: string; value: string }[]
  collection?: string
  creator?: string
}

// Commit to metadata
const metadata = {
  name: 'Lotus Genesis NFT #1',
  description: 'First NFT on Lotus Taproot',
  image: 'ipfs://Qm...',
  attributes: [
    { trait_type: 'Rarity', value: 'Legendary' },
    { trait_type: 'Series', value: 'Genesis' },
  ],
}

const metadataJSON = JSON.stringify(metadata)
const metadataHash = Hash.sha256(Buffer.from(metadataJSON))
// metadataHash is 32 bytes and will be used as the state parameter

// metadataHash goes into the 32-byte state parameter

Creating NFTs

Minting a Single NFT

import {
  PrivateKey,
  buildKeyPathTaproot,
  Hash,
  Transaction,
  Output,
  Script,
  UnspentOutput,
} from 'lotus-sdk'

// Generate creator's key
const creatorKey = new PrivateKey()

console.log('Creator public key:', creatorKey.publicKey.toString())
console.log('Creator address:', creatorKey.toAddress().toString())

// NFT metadata
const nftMetadata = {
  name: 'Lotus Taproot NFT #001',
  description: 'A unique digital collectible',
  image: 'ipfs://QmXyz123...',
  attributes: [
    { trait_type: 'Color', value: 'Gold' },
    { trait_type: 'Edition', value: '1/100' },
  ],
  collection: 'Lotus Genesis Collection',
  creator: creatorKey.toAddress().toString(),
}

// Hash the metadata (this becomes the 32-byte state parameter)
const metadataJSON = JSON.stringify(nftMetadata)
const metadataHash = Hash.sha256(Buffer.from(metadataJSON, 'utf8'))

console.log('Metadata hash (state):', metadataHash.toString('hex'))
console.log('Metadata size:', metadataJSON.length, 'bytes (stored off-chain)')
console.log('State parameter:', metadataHash.length, 'bytes (on-chain)')

// Create Taproot output WITH state parameter for NFT
// buildKeyPathTaproot accepts an optional state parameter (32 bytes)
const nftScript = buildKeyPathTaproot(creatorKey.publicKey, metadataHash)

console.log('NFT script size:', nftScript.toBuffer().length, 'bytes') // 69 bytes (36 + 33 for state)
console.log('NFT script:', nftScript.toString())
console.log('NFT address:', nftScript.toAddress()?.toString())

// Create dummy UTXO for funding
const fundingUtxo = {
  txId: 'c'.repeat(64),
  outputIndex: 0,
  script: Script.buildPublicKeyHashOut(creatorKey.publicKey),
  satoshis: 10000,
  address: creatorKey.toAddress(),
}

// Mint NFT transaction
const mintTx = new Transaction()
  .from(new UnspentOutput(fundingUtxo))
  .addOutput(
    new Output({
      script: nftScript,
      satoshis: 1000, // Minimal value (0.001 XPI)
    }),
  )
  .change(creatorKey.toAddress())
  .sign(creatorKey)

console.log('NFT minted!')
console.log('Transaction ID:', mintTx.id)
console.log('Outputs:', mintTx.outputs.length)
console.log('  Output 0: NFT (1,000 sats, 69-byte script with state)')
console.log('  Output 1: Change')

Important Notes on State Parameter:

  • The state parameter is automatically pushed onto the script stack before execution in script path spending
  • For key path spending, the state is stored but not used (just metadata commitment)
  • The state must be exactly 32 bytes (use Hash.sha256() to create it)
  • When spending via script path, the revealed script can access the state from the stack

Minting Transaction

JSON Format:

{
  "version": 2,
  "inputs": [
    {
      "prevTxId": "creator_utxo_1234567890abcdef1234567890abcdef1234567890abcdef12345678",
      "outputIndex": 0,
      "scriptSig": "483045022100...",
      "sequence": 4294967295
    }
  ],
  "outputs": [
    {
      "satoshis": 1000,
      "script": "62512102abc123...20def456..." // 69-byte NFT script
    },
    {
      "satoshis": 999000,
      "script": "76a914creator...88ac"
    }
  ],
  "lockTime": 0
}

Output 0 Breakdown (NFT):

  • Value: 1,000 sats (0.001 XPI)
  • Script (69 bytes):
    • 62 = OP_SCRIPTTYPE
    • 51 = OP_1
    • 21 = 33 (push commitment)
    • 02abc123... = 33-byte commitment pubkey
    • 20 = 32 (push state)
    • def456... = 32-byte metadata hash

NFTs with Smart Contracts (Script Path + State)

For advanced NFTs with royalties, trading logic, or other smart contracts, use script paths with the state parameter:

import {
  PrivateKey,
  Script,
  Opcode,
  buildScriptPathTaproot,
  TapNode,
  Hash,
} from 'lotus-sdk'

// NFT metadata (stored off-chain, hash on-chain)
const nftMetadata = {
  name: 'Smart NFT #001',
  royaltyAddress: 'lotus_...',
  royaltyPercent: 5,
}
const metadataHash = Hash.sha256(Buffer.from(JSON.stringify(nftMetadata)))

// Create a script that enforces royalty payments
// This script can access the state (metadata hash) from the stack
const royaltyScript = new Script()
  // State will be on stack: [32-byte metadata hash]
  // Script can verify metadata hash matches expected value
  .add(creatorKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

const scriptTree: TapNode = { script: royaltyScript }

// Build Taproot with script path AND state parameter
const nftResult = buildScriptPathTaproot(
  creatorKey.publicKey,
  scriptTree,
  metadataHash, // State parameter!
)

console.log('Smart NFT created:')
console.log('  Script size:', nftResult.script.toBuffer().length, 'bytes') // 69 bytes
console.log('  Has state:', nftResult.script.toBuffer().length === 69)
console.log('  Number of leaves:', nftResult.leaves.length)

How State Works in Script Path Spending:

  1. When spending via script path, verifyTaprootSpend() automatically pushes the state onto the stack
  2. The revealed script executes with the state already on the stack
  3. The script can use the state for verification (e.g., checking metadata hash)
  4. Reference: lotusd/src/script/interpreter.cpp lines 2136-2140

Transferring NFTs

Simple Transfer (Key Path)

import { Transaction, TaprootInput, Output, Signature } from 'lotus-sdk'

// Transfer NFT to new owner (key path - state not used, just carried along)
const transferTx = new Transaction()

// Input: Current NFT UTXO
transferTx.addInput(
  new TaprootInput({
    prevTxId: Buffer.from(mintTxId, 'hex'),
    outputIndex: 0,
    output: new Output({
      script: nftScript,
      satoshis: 1000,
    }),
    script: new Script(),
  }),
)

// Create new NFT output for recipient (same metadata state)
const recipientKey = new PrivateKey()
const newCommitment = tweakPublicKey(recipientKey.publicKey, merkleRoot)
const newNFTScript = buildPayToTaproot(newCommitment, metadataHash) // Same state!

// Output: NFT to new owner
transferTx.addOutput(
  new Output({
    script: newNFTScript,
    satoshis: 1000, // Same value
  }),
)

// Sign with current owner's key
transferTx.sign(
  creatorKey,
  Signature.SIGHASH_ALL | Signature.SIGHASH_LOTUS,
  'schnorr',
)

console.log('NFT transferred!')
console.log('New owner address:', newNFTScript.toAddress().toString())

Transfer Transaction (JSON):

{
  "version": 2,
  "inputs": [
    {
      "prevTxId": "nft_mint_tx_id_1234567890abcdef...",
      "outputIndex": 0,
      "scriptSig": "41abc123def456...", // 65-byte Schnorr signature
      "sequence": 4294967295
    }
  ],
  "outputs": [
    {
      "satoshis": 1000,
      "script": "62512102xyz789...20def456..." // New owner, same metadata
    }
  ],
  "lockTime": 0
}

Privacy: Transfer via key path hides any alternative trading mechanisms in the script tree.


Trading NFTs

NFT Sale with Escrow

Use script tree to enable secure trading:

import { Script, Opcode, buildScriptPathTaproot } from 'lotus-sdk'

const sellerKey = new PrivateKey()
const buyerKey = new PrivateKey()
const escrowKey = new PrivateKey()
const salePrice = 10000000 // 10 XPI

// Script 1: Buyer pays seller directly (cooperative)
const saleScript = new Script()
  .add(Opcode.OP_2)
  .add(sellerKey.publicKey.toBuffer())
  .add(buyerKey.publicKey.toBuffer())
  .add(Opcode.OP_2)
  .add(Opcode.OP_CHECKMULTISIG)

// Script 2: Escrow resolution
const escrowScript = new Script()
  .add(escrowKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

// Script 3: Refund after timeout
const refundHeight = currentHeight + 1440 // ~48 hours
const refundScript = new Script()
  .add(refundHeight)
  .add(Opcode.OP_CHECKLOCKTIMEVERIFY)
  .add(Opcode.OP_DROP)
  .add(sellerKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

// Build trading NFT with escrow protection
const tradingTree = {
  left: { script: saleScript },
  right: {
    left: { script: escrowScript },
    right: { script: refundScript },
  },
}

const { script: tradingNFT } = buildScriptPathTaproot(
  sellerKey.publicKey,
  tradingTree,
  metadataHash, // NFT metadata in state
)

console.log('Trading NFT address:', tradingNFT.toAddress().toString())

Sale Transaction (Buyer Pays)

Step 1: Seller creates NFT with sale terms:

{
  "version": 2,
  "inputs": [{ "prevTxId": "seller_nft_utxo...", "outputIndex": 0 }],
  "outputs": [
    {
      "satoshis": 1000,
      "script": "62512102trading_commitment...20metadata_hash..."
    }
  ]
}

Step 2: Buyer pays seller (key path - cooperative):

{
  "version": 2,
  "inputs": [
    {
      "prevTxId": "trading_nft_tx...",
      "outputIndex": 0,
      "scriptSig": "41musig2_signature..." // Seller + Buyer cooperate
    }
  ],
  "outputs": [
    {
      "satoshis": 10000000,
      "script": "76a914seller_address...88ac"
    }
  ]
}

Result:

  • Seller receives 10 XPI payment
  • Buyer receives NFT ownership
  • Transaction ~110 bytes (escrow mechanism hidden)

NFT Collections

Minting a Collection

import { Hash } from 'lotus-sdk'

// Collection metadata
const collectionInfo = {
  name: 'Lotus Legends',
  description: '100 unique legendary items',
  totalSupply: 100,
  creator: creatorAddress,
  royalty: 5, // 5% royalty
}

const collectionHash = Hash.sha256(Buffer.from(JSON.stringify(collectionInfo)))

// Mint NFTs in batch
const nfts = []
for (let i = 1; i <= 100; i++) {
  const nftMetadata = {
    ...collectionInfo,
    tokenId: i,
    name: `Lotus Legend #${i}`,
    image: `ipfs://Qm.../${i}.png`,
    attributes: generateAttributes(i), // Unique per NFT
  }

  // Hash includes both collection and individual NFT data
  const combinedData = {
    collection: collectionHash.toString('hex'),
    nft: nftMetadata,
  }
  const nftHash = Hash.sha256(Buffer.from(JSON.stringify(combinedData)))

  // Create NFT with state
  const commitment = tweakPublicKey(creatorKey.publicKey, merkleRoot)
  const nftScript = buildPayToTaproot(commitment, nftHash)

  nfts.push({
    tokenId: i,
    script: nftScript,
    address: nftScript.toAddress().toString(),
    metadata: nftMetadata,
  })
}

console.log(`Minted ${nfts.length} NFTs`)

Batch Minting Transaction:

{
  "version": 2,
  "inputs": [
    {
      "prevTxId": "creator_utxo...",
      "outputIndex": 0,
      "scriptSig": "483045...",
      "sequence": 4294967295
    }
  ],
  "outputs": [
    { "satoshis": 1000, "script": "62512102nft1...20hash1..." },
    { "satoshis": 1000, "script": "62512102nft2...20hash2..." },
    { "satoshis": 1000, "script": "62512102nft3...20hash3..." },
    // ... up to 100 NFTs
    { "satoshis": 900000, "script": "76a914creator...88ac" }
  ],
  "lockTime": 0
}

Cost: ~100,000 sats for 100 NFTs (~1,000 sats per NFT)


NFT Marketplace

Listing NFT for Sale

import { buildScriptPathTaproot, Script, Opcode } from 'lotus-sdk'

const ownerKey = new PrivateKey()
const salePrice = 5000000 // 5 XPI

// Script 1: Sale (anyone can buy by paying sale price)
// Note: Requires OP_CHECKTEMPLATEVERIFY for proper covenant
// This is simplified - full implementation needs additional opcodes
const saleScript = new Script()
  .add(ownerKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

// Script 2: Owner can cancel listing
const cancelScript = new Script()
  .add(ownerKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

const listingTree = {
  left: { script: saleScript },
  right: { script: cancelScript },
}

const { script: listedNFT } = buildScriptPathTaproot(
  ownerKey.publicKey,
  listingTree,
  metadataHash, // NFT state
)

console.log('Listed NFT address:', listedNFT.toAddress().toString())
console.log('Sale price: 5 XPI')

Purchase Transaction

Buyer purchases via key path (cooperative with seller):

{
  "version": 2,
  "inputs": [
    {
      "prevTxId": "listed_nft_tx...",
      "outputIndex": 0,
      "scriptSig": "41musig_signature..." // Buyer + Seller cooperate
    },
    {
      "prevTxId": "buyer_payment_utxo...",
      "outputIndex": 0,
      "scriptSig": "483045..."
    }
  ],
  "outputs": [
    {
      "satoshis": 1000,
      "script": "62512102buyer_nft...20same_metadata_hash..."
    },
    {
      "satoshis": 4750000,
      "script": "76a914seller_address...88ac"
    },
    {
      "satoshis": 250000,
      "script": "76a914creator_address...88ac"
    }
  ],
  "lockTime": 0
}

Breakdown:

  • Input 0: NFT from seller (1,000 sats)
  • Input 1: Payment from buyer (5,000,000 sats)
  • Output 0: NFT to buyer (1,000 sats, same metadata)
  • Output 1: Payment to seller (4,750,000 sats = 4.75 XPI, 95% of sale)
  • Output 2: Royalty to creator (250,000 sats = 0.25 XPI, 5% royalty)

Privacy: Sale mechanism hidden via key path


Advanced: NFT Rentals

Time-Limited NFT Access

import { buildScriptPathTaproot, Script, Opcode } from 'lotus-sdk'

const ownerKey = new PrivateKey()
const renterKey = new PrivateKey()
const rentalEnd = currentHeight + 2160 // ~3 days
const rentalPrice = 100000 // 0.1 XPI

// Script 1: Renter can use until rental expires
const rentalScript = new Script()
  .add(rentalEnd)
  .add(Opcode.OP_CHECKLOCKTIMEVERIFY)
  .add(Opcode.OP_DROP)
  .add(renterKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

// Script 2: Owner reclaims after expiry
const reclaimScript = new Script()
  .add(rentalEnd)
  .add(Opcode.OP_CHECKLOCKTIMEVERIFY)
  .add(Opcode.OP_DROP)
  .add(ownerKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)

const rentalTree = {
  left: { script: rentalScript },
  right: { script: reclaimScript },
}

const { script: rentalNFT } = buildScriptPathTaproot(
  ownerKey.publicKey,
  rentalTree,
  metadataHash, // Same NFT metadata
)

console.log('Rental NFT address:', rentalNFT.toAddress().toString())
console.log('Rental expires at block:', rentalEnd)

Rental Flow:

  1. Owner creates rental NFT (locked for 3 days)
  2. Renter pays rental fee
  3. Renter can use NFT features for 3 days
  4. After expiry, NFT returns to owner

NFT Provenance

Tracking NFT History

Each transfer updates the commitment while preserving the state:

// Original mint
const mint: NFTTransfer = {
  txid: 'abc123...',
  from: null, // Minted
  to: creatorKey.toAddress().toString(),
  metadataHash: metadataHash.toString('hex'),
  timestamp: Date.now(),
}

// First transfer
const transfer1: NFTTransfer = {
  txid: 'def456...',
  from: creatorKey.toAddress().toString(),
  to: buyer1Address.toString(),
  metadataHash: metadataHash.toString('hex'), // Same!
  timestamp: Date.now(),
}

// Second transfer
const transfer2: NFTTransfer = {
  txid: 'ghi789...',
  from: buyer1Address.toString(),
  to: buyer2Address.toString(),
  metadataHash: metadataHash.toString('hex'), // Same!
  timestamp: Date.now(),
}

// Verify provenance: All transfers have same metadataHash
const isLegitimate =
  mint.metadataHash === transfer1.metadataHash &&
  transfer1.metadataHash === transfer2.metadataHash

console.log('NFT provenance verified:', isLegitimate)

Key Insight: The 32-byte state (metadata hash) remains constant across all transfers, proving authenticity.


Metadata Verification

Verifying NFT Authenticity

import { extractTaprootState, Script, Hash } from 'lotus-sdk'

// Given an NFT transaction
const nftTxScript = Script.fromBuffer(nftScriptHex)

// Extract the 32-byte state
const stateHash = extractTaprootState(nftTxScript)

if (!stateHash) {
  console.error('No state found - not a valid NFT')
  process.exit(1)
}

console.log('NFT metadata hash:', stateHash.toString('hex'))

// Fetch metadata from off-chain storage (IPFS, Arweave, etc.)
const metadata = await fetchMetadata('ipfs://Qm...')

// Verify hash matches
const computedHash = Hash.sha256(Buffer.from(JSON.stringify(metadata)))
const isValid = computedHash.equals(stateHash)

console.log('Metadata valid:', isValid)

if (isValid) {
  console.log('NFT Name:', metadata.name)
  console.log('NFT Description:', metadata.description)
  console.log('NFT Image:', metadata.image)
  console.log('NFT Attributes:', metadata.attributes)
}

Use Cases

Digital Art

// Art NFT with high-res image on IPFS
const artNFT = {
  name: 'Lotus Sunset #42',
  description: 'A beautiful sunset over Lotus mountains',
  image: 'ipfs://QmArtwork123...',
  creator: artistAddress,
  created: '2025-10-28',
  attributes: [
    { trait_type: 'Style', value: 'Digital Painting' },
    { trait_type: 'Resolution', value: '4K' },
    { trait_type: 'Signed', value: 'Yes' },
  ],
}

const artHash = Hash.sha256(Buffer.from(JSON.stringify(artNFT)))
const artScript = buildPayToTaproot(commitment, artHash)

Gaming Items

// In-game item NFT
const gameItem = {
  name: 'Legendary Sword of Lotus',
  description: 'Rare weapon with +100 attack',
  image: 'ipfs://QmGameItem456...',
  attributes: [
    { trait_type: 'Type', value: 'Weapon' },
    { trait_type: 'Rarity', value: 'Legendary' },
    { trait_type: 'Attack', value: '100' },
    { trait_type: 'Durability', value: '1000' },
  ],
  game: 'Lotus Quest',
}

const itemHash = Hash.sha256(Buffer.from(JSON.stringify(gameItem)))
const itemScript = buildPayToTaproot(commitment, itemHash)

Membership Cards

// VIP membership NFT
const membership = {
  name: 'Lotus DAO Founder',
  description: 'Founding member of Lotus DAO',
  image: 'ipfs://QmMemberCard789...',
  attributes: [
    { trait_type: 'Tier', value: 'Founder' },
    { trait_type: 'Member ID', value: '0042' },
    { trait_type: 'Joined', value: '2025-01-01' },
    { trait_type: 'Benefits', value: 'Governance + Early Access' },
  ],
  issuer: 'Lotus DAO',
  expires: '2026-01-01',
}

const memberHash = Hash.sha256(Buffer.from(JSON.stringify(membership)))
const memberScript = buildPayToTaproot(commitment, memberHash)

Event Tickets

// Event ticket NFT
const ticket = {
  name: 'Lotus Conference 2025 - VIP Pass',
  description: 'Access to all sessions + VIP lounge',
  image: 'ipfs://QmTicket123...',
  attributes: [
    { trait_type: 'Event', value: 'Lotus Conference 2025' },
    { trait_type: 'Date', value: '2025-12-15' },
    { trait_type: 'Seat', value: 'VIP-042' },
    { trait_type: 'Access', value: 'All Sessions + VIP' },
  ],
  venue: 'Convention Center',
  validUntil: '2025-12-16',
}

const ticketHash = Hash.sha256(Buffer.from(JSON.stringify(ticket)))
const ticketScript = buildPayToTaproot(commitment, ticketHash)

Security Considerations

Metadata Storage

DO:

  • ✅ Store metadata on decentralized storage (IPFS, Arweave)
  • ✅ Include metadata hash in state parameter
  • ✅ Verify hash before trusting metadata
  • ✅ Keep backup of metadata JSON

DON'T:

  • ❌ Store metadata only on centralized servers
  • ❌ Change metadata after minting (breaks hash)
  • ❌ Use mutable URLs (use IPFS CID)
  • ❌ Forget to validate metadata hash

State Parameter Validation

// ✅ CORRECT: Verify state matches expected metadata
const extractedState = extractTaprootState(nftScript)
const expectedHash = Hash.sha256(Buffer.from(JSON.stringify(metadata)))

if (!extractedState.equals(expectedHash)) {
  throw new Error('Metadata hash mismatch - NFT may be counterfeit!')
}

// ❌ WRONG: Trust metadata without verification
const metadata = await fetchMetadata(url) // Could be fake!

Transfer Validation

Critical: When transferring NFT, the state MUST remain identical:

// ✅ CORRECT: Same state in new output
const newNFT = buildPayToTaproot(newCommitment, originalStateHash)

// ❌ WRONG: Different state (creates different NFT!)
const wrongNFT = buildPayToTaproot(newCommitment, differentHash)

Counterfeit Prevention

// Verify NFT authenticity
function verifyNFT(txid: string, outputIndex: number): boolean {
  // 1. Fetch transaction
  const tx = await getTransaction(txid)
  const output = tx.outputs[outputIndex]

  // 2. Extract state
  const state = extractTaprootState(Script.fromBuffer(output.script))
  if (!state) return false

  // 3. Fetch claimed metadata
  const metadata = await fetchMetadata(metadataURL)

  // 4. Verify hash
  const computedHash = Hash.sha256(Buffer.from(JSON.stringify(metadata)))
  if (!state.equals(computedHash)) return false

  // 5. Trace provenance back to original mint
  const provenance = await traceNFTHistory(txid, outputIndex)
  const originalMint = provenance[0]

  // 6. Verify creator signature on original mint
  return verifyCreatorSignature(originalMint, metadata.creator)
}

Advanced: Dynamic NFTs

NFTs with Updatable Attributes

Use script tree to enable controlled updates:

// NFT with update capability
const updateScript = new Script()
  .add(creatorKey.publicKey.toBuffer())
  .add(Opcode.OP_CHECKSIG)
// Creator can spend and create new NFT with updated state

const burnScript = new Script().add(Opcode.OP_RETURN)
// Anyone can burn NFT (for redemption, etc.)

const dynamicTree = {
  left: { script: updateScript },
  right: { script: burnScript },
}

// Initial NFT state
let currentMetadata = { level: 1, experience: 0, items: [] }
let currentHash = Hash.sha256(Buffer.from(JSON.stringify(currentMetadata)))

const { script: dynamicNFT } = buildScriptPathTaproot(
  creatorKey.publicKey,
  dynamicTree,
  currentHash,
)

// Update NFT (level up, gain items, etc.)
function updateNFT(newAttributes: any) {
  const updatedMetadata = { ...currentMetadata, ...newAttributes }
  const newHash = Hash.sha256(Buffer.from(JSON.stringify(updatedMetadata)))

  // Create new NFT output with updated state
  const updated = buildScriptPathTaproot(
    creatorKey.publicKey,
    dynamicTree,
    newHash, // New state!
  )

  return updated
}

Size and Cost Analysis

NFT TypeScript SizeCostPrivacy
Simple NFT (key-only)69 bytes1,000 satsHigh
Trading NFT (with escrow)69 bytes1,000 satsHigh (if key path)
Collection NFT69 bytes1,000 sats eachHigh

Comparison with Other Methods:

MethodMetadataOn-Chain SizeCost
Taproot StateHash only69 bytes~1,000 sats
OP_RETURNFull metadata223+ bytes~2,000+ sats
Multiple outputsChunked data500+ bytes~5,000+ sats

Advantage: Taproot NFTs are 3-5x smaller and cheaper than alternatives


Testing

Regtest Example

import { Networks, Hash } from 'lotus-sdk'

// Create test NFT on regtest
const testKey = new PrivateKey(undefined, Networks.regtest)
const testMetadata = {
  name: 'Test NFT',
  description: 'Testing Taproot NFTs',
  image: 'ipfs://QmTest...',
}

const testHash = Hash.sha256(Buffer.from(JSON.stringify(testMetadata)))
const testCommitment = tweakPublicKey(testKey.publicKey, Buffer.alloc(32))
const testNFT = buildPayToTaproot(testCommitment, testHash)

console.log('Test NFT address:', testNFT.toAddress().toString())
// Example: lotusR...

// Verify state extraction
const extractedState = extractTaprootState(testNFT)
console.log('State matches:', extractedState.equals(testHash))

Best Practices

Metadata Design

DO:

  • ✅ Use standard metadata schemas (OpenSea-compatible)
  • ✅ Store images on IPFS/Arweave
  • ✅ Include creator attribution
  • ✅ Version your metadata schema

DON'T:

  • ❌ Use centralized image hosting
  • ❌ Exceed reasonable JSON sizes (keep < 10KB)
  • ❌ Include sensitive/private data
  • ❌ Use non-deterministic fields (timestamps in hash)

Collection Management

// Good: Deterministic collection ID
const collectionId = Hash.sha256(Buffer.from('LotusLegends'))

// Good: Consistent metadata structure
const nftMetadata = {
  name: `Item #${tokenId}`,
  collection: collectionId.toString('hex'),
  tokenId,
  ...standardFields,
}

// Bad: Non-deterministic
const badMetadata = {
  name: 'NFT',
  timestamp: Date.now(), // Changes every time!
}

Transfer Validation

// Validate transfer preserves NFT identity
function validateTransfer(inputScript: Script, outputScript: Script): boolean {
  const inputState = extractTaprootState(inputScript)
  const outputState = extractTaprootState(outputScript)

  if (!inputState || !outputState) {
    return false // Missing state
  }

  // State MUST be identical
  return inputState.equals(outputState)
}

Summary

Benefits:

  • ✅ Compact on-chain storage (69 bytes)
  • ✅ Provable metadata commitments
  • ✅ Privacy via key path transfers
  • ✅ Flexible trading mechanisms
  • ✅ Low minting cost (~1,000 sats per NFT)
  • ✅ Collection support
  • ✅ Rental and escrow capabilities

Trade-offs:

  • Requires off-chain metadata storage
  • State parameter is visible (not fully private)
  • Need to maintain metadata availability
  • More complex than simple token transfers

When to Use:

  • Digital art and collectibles
  • Gaming items and achievements
  • Membership cards and tickets
  • Proof of ownership for anything
  • Collections with shared attributes

When NOT to Use:

  • Fully on-chain metadata needed (use OP_RETURN)
  • Fungible tokens (use different protocol)
  • Frequently changing metadata (state is immutable per output)
  • Privacy is critical (state is visible on-chain)


Last Modified: October 28, 2025

Copyright © Lotusia 2021-2025. All rights reserved.