User Update Example
This guide provides a complete Deno implementation for user-driven CIP-68 NFT metadata updates using spend validators, fee payments, and token ownership verification with the Anvil API.
Full Code Example
Implementation Overview
This example demonstrates user-driven metadata updates where token holders can update their own NFT metadata by:
Proving ownership of the user token (222)
Paying a 1 ADA fee to the admin
Updating limited metadata fields (e.g., nickname)
Following the spend validator's authorization rules
Prerequisites
This guide assumes you have completed the Minting Example and have:
Existing CIP-68 tokens - Reference and user tokens from a successful mint
User token ownership - The user token (222) must be in the customer's wallet
Reference token location - Current UTXO of the reference token at the script address
Blockfrost API access - For dynamic UTXO discovery
Understanding of user updates - Review update logic for validation details
Step-by-Step Implementation
Let's build a user-driven CIP-68 metadata update script step by step. Each section explains the concept and shows the corresponding code.
Step 1: Setup and Configuration
Create update file with the required imports and configuration:
import { Buffer } from "node:buffer";
import {
FixedTransaction,
PrivateKey,
} from "npm:@emurgo/[email protected]";
// Import wallet files and constants
import customer from "./wallet-customer.json" with { type: "json" };
import adminWallet from "./wallet-mint-sc-policy.json" with { type: "json" };
import { API_URL, HEADERS } from "../../../utils/constant.ts";
// Configuration
const CUSTOMER_ADDRESS = customer.base_address_preprod;
const ADMIN_KEY_HASH = adminWallet.key_hash;
const ADMIN_ADDRESS = adminWallet.base_address_preprod;
const assetName = "cip68_1753999309623"; // Your minted asset name
const nickname = "customer_updated"; // New nickname to set
// Import Blockfrost utility for dynamic UTXO fetching
import {
getUtxos,
findUserTokenUTXO,
findReferenceTokenUTXO
} from "../../../fetch-utxos-from-the-backend/utxos/blockfrost.ts";
// Blockfrost configuration
const BLOCKFROST_BASE_URL = "https://cardano-preprod.blockfrost.io/api/v0";
const BLOCKFROST_API_KEY = Deno.env.get("BLOCKFROST_PROJECT_ID");
if (!BLOCKFROST_API_KEY) {
console.error("❌ BLOCKFROST_PROJECT_ID environment variable is required");
Deno.exit(1);
}
Key differences from admin updates:
Customer wallet signs the transaction (not admin)
Admin address receives the 1 ADA fee payment
Blockfrost integration for dynamic UTXO discovery
Asset name must match the originally minted token
Step 2: Apply Parameters and Find UTXOs
Apply parameters to the blueprint and locate the required UTXOs:
// Load blueprint and apply parameters (same as other examples)
import blueprint from "../aiken-mint-cip-68/plutus.json" with { type: "json" };
console.log("🔄 Applying parameters to blueprint...");
const applyParamsResponse = await fetch(`${API_URL}/blueprints/apply-params`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
params: { [blueprint.validators[0].hash]: [ADMIN_KEY_HASH] },
blueprint: blueprint
}),
});
const applyParamsResult = await applyParamsResponse.json();
const parameterizedScript = applyParamsResult.preloadedScript;
const POLICY_ID = Object.keys(applyParamsResult.addresses)[0];
const SC_ADDRESS = applyParamsResult.addresses[POLICY_ID].bech32;
console.log("Parameterized script hash:", POLICY_ID);
console.log("Script address:", SC_ADDRESS);
// Find reference token UTXO at script address
const referenceTokenUTXO = await findReferenceTokenUTXO(
BLOCKFROST_BASE_URL,
BLOCKFROST_API_KEY,
POLICY_ID,
assetName,
SC_ADDRESS
);
// Find user token UTXO at customer address
const userTokenUTXO = await findUserTokenUTXO(
BLOCKFROST_BASE_URL,
BLOCKFROST_API_KEY,
POLICY_ID,
assetName,
CUSTOMER_ADDRESS
);
// Get user token UTXO in string format for requiredInputs
const utxos = await getUtxos(BLOCKFROST_BASE_URL, BLOCKFROST_API_KEY, CUSTOMER_ADDRESS);
const utxoString = `000de140${Buffer.from(assetName).toString("hex")}`;
const userUtxo = utxos.find((utxo) => utxo.includes(utxoString));
if (!userUtxo) {
console.error("❌ User token UTXO not found at customer address");
Deno.exit(1);
}
console.log(`Reference token: ${referenceTokenUTXO.transaction_id}#${referenceTokenUTXO.output_index}`);
console.log(`User token: ${userTokenUTXO.transaction_id}#${userTokenUTXO.output_index}`);
What's happening here:
Reference token discovery: Finds the current reference token UTXO at the script address
User token discovery: Locates the user token in the customer's wallet (proves ownership)
UTXO string format: Gets the hex-encoded UTXO string needed for
requiredInputs
Validation: Ensures both tokens exist before proceeding
Step 3: Build the User Update Transaction
Now we build the spend transaction with user authorization:
const input = {
changeAddress: CUSTOMER_ADDRESS,
message: "CIP-68 Customer Update Example",
// SCRIPT INTERACTIONS: Tell the validator how to spend the reference token
scriptInteractions: [
{
purpose: "spend", // Spending a UTXO locked at smart contract address
// REFERENCE TOKEN UTXO: The UTXO containing the reference token to update
outputRef: {
txHash: referenceTokenUTXO.transaction_id,
index: referenceTokenUTXO.output_index,
},
// REDEEMER: Authorization data for the spend validator
redeemer: {
type: "json",
value: {
output_index: 0, // Index of output containing updated reference token
update: {
// USER UPDATE: Triggers UserUpdate validation branch
nickname: Buffer.from(nickname).toString("hex"), // New nickname in hex
fee_output_index: 1, // Index of output paying 1 ADA fee to admin
// USER TOKEN REFERENCE: Validator uses this to verify ownership
output_ref_user_token: {
transaction_id: userTokenUTXO.transaction_id,
output_index: userTokenUTXO.output_index,
},
},
},
},
},
],
Key redeemer components:
nickname: The updated value in hex format (limited user permission)
fee_output_index: Points to the 1 ADA fee payment output
output_ref_user_token: References the user token for ownership verification
Step 4: Define Transaction Outputs
Create the outputs for the updated reference token and fee payment:
// TRANSACTION OUTPUTS: What the transaction creates
outputs: [
{
// OUTPUT 0: Updated reference token back to script address
address: SC_ADDRESS,
assets: [
{
assetName: { name: assetName, label: 100, format: "utf8" },
policyId: POLICY_ID,
quantity: 1,
},
],
// UPDATED DATUM: Contains new metadata with updated nickname
datum: {
type: "inline",
shape: {
validatorHash: POLICY_ID,
purpose: "spend",
},
value: {
metadata: [
["name", assetName], // Must match original minted asset name
["nickname", "customer_updated"], // Updated field
],
version: 1,
},
},
},
{
// OUTPUT 1: 1 ADA fee payment to admin
address: ADMIN_ADDRESS,
lovelace: "1000000", // Exactly 1 ADA - validator verifies this amount
},
],
requiredSigners: [], // No admin signature required for user updates
referenceInputs: [],
requiredInputs: [userUtxo!], // Include user token as input (proves ownership)
preloadedScripts: [parameterizedScript],
};
Critical output requirements:
Output 0: Updated reference token with new metadata at script address
Output 1: Exactly 1 ADA fee payment to admin address
Datum preservation: Original asset name must be preserved
User token input: Must be included via
requiredInputs
for ownership proof
Step 5: Build, Sign, and Submit Transaction
Build and submit the user update transaction:
// Build the transaction
const contractDeployed = await fetch(`${API_URL}/transactions/build`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(input),
});
if (!contractDeployed.ok) {
const errorText = await contractDeployed.text();
console.error("❌ Transaction build failed:", errorText);
Deno.exit(1);
}
const transaction = await contractDeployed.json();
console.log("✅ Transaction built successfully");
// Sign with customer's private key (not admin)
const txToSubmitOnChain = FixedTransaction.from_bytes(
Buffer.from(transaction.complete, "hex")
);
txToSubmitOnChain.sign_and_add_vkey_signature(
PrivateKey.from_bech32(customer.skey)
);
// Submit to network
const submitted = await fetch(`${API_URL}/transactions/submit`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ transaction: txToSubmitOnChain.to_hex() }),
});
const output = await submitted.json();
if (!submitted.ok) {
console.error("❌ Transaction submission failed:", output);
Deno.exit(1);
}
console.log("✅ Customer update transaction submitted successfully");
console.log("Transaction hash:", output.transactionId);
What's happening here:
Customer signature: Only the customer signs (proves they own the user token)
No admin signature: Admin authorization comes from fee payment, not signature
Validation on-chain: Smart contract validates ownership and fee payment
Running the Example
Prerequisites Setup
Complete the minting example first to have CIP-68 tokens available
Set up environment variables:
# PowerShell/Windows $env:BLOCKFROST_PROJECT_ID='your_blockfrost_api_key' # Bash/macOS/Linux export BLOCKFROST_PROJECT_ID='your_blockfrost_api_key'
Update configuration:
Set
assetName
to match your minted tokenModify
nickname
to your desired update valueEnsure wallet files are properly configured
Run the user update script:
deno run --allow-all user-update.ts
Expected Output
🔄 Applying parameters to blueprint...
✅ Parameters applied successfully
Parameterized script hash: 4983663c2b236161ad8e26c36dff9aee709a6adef53be2...
Script address: addr_test1wpycxe3u9v3kzcdd3cnvxm0lnth8pxn2mm6nhch9f6pagxq...
Reference token: 1750a1c191646ade084c911fd9fd8a7c6b8372923e2305c4f4...#0
User token: 1750a1c191646ade084c911fd9fd8a7c6b8372923e2305c4f4...#1
✅ Transaction built successfully
✅ Customer update transaction submitted successfully
Transaction hash: b2cb92868e58238edb5646775847fca911c46c4d2206bb66b64c4df0af06187b
User vs Admin Updates
This user update approach differs from admin updates in three key ways:
Authorization
Token ownership + 1 ADA fee
Admin signature only
Permissions
Limited fields (e.g., nickname)
Full metadata access
Cost
1 ADA fee to admin
Free for admin
The economic barrier prevents spam while giving users control over their metadata.
Troubleshooting
Common Validation Failures
If your transaction fails validation, check these common issues:
user_token_quantity != 1
- User token not included in transaction inputslovelace_paid != 1000000
- Fee amount is not exactly 1 ADA!is_paid_to_admin
- Fee payment sent to wrong addresscurrent_datum != expected_datum
- Attempting to update restricted metadata fields
Key Requirements
Include user token in
requiredInputs
for ownership proofPay exactly 1 ADA (1,000,000 lovelace) to admin address
Update only permitted fields as defined by the smart contract
Track UTXO locations dynamically (tokens move with each update)
Next Steps
You now have a complete user-driven update implementation. For production applications, consider:
Batch Updates: Multiple metadata fields in one transaction
Update History: Track metadata changes for audit trails
Fee Estimation: Display costs before transaction submission
User Interface: Build a frontend for non-technical users
References
Update Logic Guide - Deep dive into Aiken implementation
Admin Update Example - Admin-driven updates
CIP-68 Specification - Official standard
Working Implementation - Complete code
Last updated
Was this helpful?