Publicación
Comparte tu conocimiento.
Safe NFT Minting and Ownership Tracking
Given Sui’s object-centric model, how can I implement a dynamic NFT collection where each NFT evolves over time, ensuring that state mutations are safe, atomic, and efficiently traceable without redundant storage writes?
- Sui
- Architecture
- SDKs and Developer Tools
Respuestas
8✨ Title: Safe NFT Minting and Ownership Tracking 🎨🔐
Answer: For dynamic NFTs on Sui while keeping state safe and traceable:
- 📦 Object-Centric Storage: Treat each NFT as a unique object with its own ID 🆔. Don’t lump multiple versions together — avoids messy conflicts.
- ⚡ Atomic Updates: Wrap NFT state changes in a single transaction ✅. That way, changes are all or nothing — no half-broken NFTs!
- 🔄 Versioning Traits: Keep evolving traits (⭐ level, 🎭 personality, ⚔️ experience) in optional fields or sub-objects. Makes updates smooth and safe.
- 📡 Event Emission: Emit events 🔔 for every state change. Off-chain tools and UIs can then track your NFT’s journey easily 📊.
- ✂️ Smart Updates: Only write what changes — no redundant reads/writes 🚀. For coins, lean on Move’s built-in split/merge magic.
- 🔑 Access Control: Use capabilities 🛡️ or admin keys 🔐 to ensure only the right actors can mutate NFT state. Keeps bad actors away 😎.
✨ Result → You get secure, traceable, and evolving NFTs that scale safely across the Sui ecosystem 🌍💎.
Thanks a bunch, this was really helpful. Cheers! ! !
Use Sui’s object model: represent each NFT as an owned object so state changes are atomic and tied to the owner. For evolution, store metadata inside the NFT object and update it directly instead of duplicating storage. Emit events for history/traceability rather than writing redundant state. This ensures safe mutations, atomic ownership tracking, and efficient updates without bloating storage.
Here’s a battle-tested pattern for “evolving” NFTs on Sui that keeps mutations safe and atomic, and makes the history easy to trace without writing big blobs over and over.
Safe evolving NFTs on Sui
- Split identity vs. state:
- Nft = small mutable object (owner, version, pointer to current state).
- NftState = immutable child per evolution (URI, traits, hashes).
- Evolve with caps + version check:
- Require an EvolveCap (auth).
- Check expected_version to prevent race conditions.
- In one transaction: create new state, bump version, update pointer.
- Traceability:
- Emit an Evolved event (nft_id, old_state, new_state, version, timestamp).
- Optionally store a History dynamic field (version → state_id) for on-chain lookup.
- Efficiency:
- Parent stays tiny (just version + pointer).
- Heavy metadata only ever written once per state.
- No redundant rewrites, only one new object + one small update per evolution.
Result: Evolutions are atomic, safe, cheap, and fully traceable through events (and history map if needed).
Here’s a minimal Move API sketch for safe evolving NFTs on Sui:
Core structs
move
struct Nft has key, store {
id: UID,
current_state: ID,
version: u64,
}
struct NftState has key, store {
id: UID,
nft_id: ID,
version: u64,
uri: vector<u8>, // or hash/traits
}
struct EvolveCap has key, store {
id: UID,
nft_id: ID,
}
struct Evolved has copy, drop {
nft_id: ID,
old_state: ID,
new_state: ID,
version: u64,
sender: address,
}
Mint
move
public entry fun mint(
uri: vector<u8>,
recipient: address,
ctx: &mut TxContext
) {
let nft = Nft { id: object::new(ctx), current_state: object::id(ctx), version: 0 };
let state = NftState { id: object::new(ctx), nft_id: object::uid_to_inner(&nft.id), version: 0, uri };
let cap = EvolveCap { id: object::new(ctx), nft_id: object::uid_to_inner(&nft.id) };
transfer::public_transfer(nft, recipient);
transfer::public_transfer(state, recipient);
transfer::public_transfer(cap, recipient);
}
Evolve
move
public entry fun evolve(
nft: &mut Nft,
cap: &EvolveCap,
expected_version: u64,
new_uri: vector<u8>,
ctx: &mut TxContext
) {
assert!(cap.nft_id == object::uid_to_inner(&nft.id), 0);
assert!(nft.version == expected_version, 1);
let new_state = NftState {
id: object::new(ctx),
nft_id: object::uid_to_inner(&nft.id),
version: nft.version + 1,
uri: new_uri,
};
let old_state = nft.current_state;
nft.current_state = object::uid_to_inner(&new_state.id);
nft.version = nft.version + 1;
emit Evolved {
nft_id: object::uid_to_inner(&nft.id),
old_state,
new_state: object::uid_to_inner(&new_state.id),
version: nft.version,
sender: tx_context::sender(ctx),
};
transfer::public_transfer(new_state, tx_context::sender(ctx));
}
This gives you:
- Safe minting (NFT + state + cap)
- Secure evolution (auth via cap, version check for atomicity)
- Traceability (events, no redundant storage writes)
On Sui, designing a safe and efficient dynamic NFT collection means leveraging its object-centric model and Move’s resource safety to ensure each NFT evolves predictably while keeping gas and storage costs low. Here’s how you can approach it:
1. Use Owned Objects for Each NFT
Each NFT should be its own owned object with a unique UID
. This ensures that ownership is cryptographically tied to a single address and any mutation (like level-ups, trait changes, or evolution) can only happen with the owner’s consent. Owned objects also benefit from parallel execution in Sui, so multiple NFT updates can happen at the same time without bottlenecks.
struct DynamicNFT has key, store {
id: UID,
name: String,
level: u64,
traits: vector<String>,
}
2. Atomic State Mutations
All mutations should be implemented as entry functions that consume a mutable reference to the NFT object. Because Sui enforces single ownership at the type level, you avoid issues like double-spends or race conditions. If a mutation fails (e.g., upgrade requirements aren’t met), the transaction reverts atomically, leaving the NFT unchanged.
public entry fun evolve_nft(nft: &mut DynamicNFT, new_trait: String) {
nft.level = nft.level + 1;
vector::push_back(&mut nft.traits, new_trait);
}
3. Efficient Traceability
Instead of rewriting the whole object history on-chain, rely on transaction logs and object versions. Every time an NFT mutates, Sui automatically increments the object version. You can query an NFT by its objectId
to see its current state, and explorers or indexers can trace past versions. This makes evolution auditable without bloating storage.
sui client object <OBJECT_ID>
4. Avoid Redundant Storage Writes
Keep your NFT object lightweight. Store only mutable fields that change over time (like level
, traits
, or power
). Metadata such as artwork, base attributes, or creator details should be stored in an immutable object or off-chain (IPFS/Arweave) with just a reference in the NFT. This minimizes storage gas fees because only the small dynamic fields are updated during evolution.
5. Shared vs. Owned Objects
If you want collection-wide logic (like a global “arena” where NFTs can evolve by battling), use a shared object for the arena, but still let each NFT remain owned. This keeps most mutations cheap and parallelizable while only the shared state (arena outcomes) goes through consensus.
6. Best Practices
- Emit events on every mutation so off-chain indexers can track evolution without constantly reading state.
- Use capabilities (special objects granted to creators/admins) to control minting or privileged updates.
- Always test with
dev-inspect
anddry-run
to estimate gas before rolling out large-scale collections.
✅ In summary: Model each NFT as an owned object, keep it lightweight, let Sui’s versioning track mutations, and use events for history. Shared state should be minimal. This approach ensures your dynamic NFTs evolve safely, atomically, and without unnecessary storage costs.
👉 Read more here: Sui NFTs and Object Model
You model each NFT as an owned object with a clear capability for mutation so you can evolve state safely in one atomic call while keeping writes minimal. In Move you define an NFT object with key that holds immutable identity fields and a compact pointer to a separate traits object for evolving attributes so an update only rewrites the small traits object rather than the whole NFT. You gate every mutation with a capability such as CreatorCap or UpdateCap that the entry function must receive so only authorized code can change state. You keep updates atomic by writing a single public entry that consumes the traits object applies the change emits an event and returns the updated object so the version bump and the new fields land together or not at all. You make history easy to trace by emitting structured events for every evolution and by relying on Sui object versioning so explorers can reconstruct the timeline without storing redundant snapshots. You avoid redundant storage writes by touching only the traits object that changed and by using dynamic fields or small child objects for each mutable facet like level skin or metadata pointer so unrelated fields do not get rewritten. You keep ownership correct by never recreating the NFT object and by transferring it normally while updates only touch its child objects. A clean operational flow is mint the base NFT and traits object grant the minimal UpdateCap run evolve calls that mutate traits and emit events transfer the NFT as needed and query history through events and object versions.
Below is a practical pattern for dynamic NFTs on Sui that evolve safely, atomically, and with efficient, queryable history—without rewriting big blobs each time.
Design goals
Safety: Only authorized updaters can mutate; strong type-checked capabilities.
Atomicity: Each evolution is one transaction that updates state and emits an event.
Traceability: History is append-only via events and compact “diff” objects; no large rewrites.
Efficiency: Keep the NFT small; store deltas or content hashes; avoid growing vectors.
Core objects and capabilities
Collection { AdminCap } – owns policy and mints NFTs; AdminCap gates mint and policy updates.
EvolvableNft – an owned object (best parallelism and lowest contention) with a small mutable state.
NftMutatorCap – optional per-NFT capability that authorizes future evolutions (mint to owner, or escrow to a game/engine).
Dynamic fields (optional) – for per-NFT “attachments” like traits without resizing the root struct.
Events – append-only history; one event per evolution, carrying compact diff and new state hash.
Move module sketch
module 0xYourPkg::dyn_nft { use sui::object::{Self, UID, ID}; use sui::transfer; use sui::tx_context::TxContext; use std::option::{Self, Option}; use std::string::String; use std::vector;
/// Collection gates minting and policy changes.
struct Collection has key {
id: UID,
name: String,
// policy knobs (who can evolve, rate limits, etc.) kept small
allow_external_mutators: bool,
}
/// Capability to control the collection.
struct AdminCap has key { id: UID }
/// Per-NFT capability that authorizes mutate() calls.
struct NftMutatorCap has key {
id: UID,
nft: ID,
}
/// Minimal mutable state for the NFT; keep this tiny.
/// Large content lives off-chain; we store its content hash and a URI.
struct EvolvableNft has key, store {
id: UID,
collection: ID,
name: String,
version: u64,
content_uri: String, // e.g. IPFS/HTTP/Arweave
content_hash: vector<u8>, // hash of rendered JSON/image
// Optional pointer to dynamic field “bag” if you need many traits
bag: Option<ID>,
}
/// Emitted on every evolution. Append-only audit trail.
struct NftEvolvedEvent has copy, drop {
nft: ID,
old_version: u64,
new_version: u64,
old_hash: vector<u8>,
new_hash: vector<u8>,
// optional compact "diff" descriptor (e.g., changed trait keys)
diff_keys: vector<String>,
}
// -------------------- Collection setup --------------------
public entry fun create_collection(
name: String,
ctx: &mut TxContext
): (Collection, AdminCap) {
let c = Collection { id: object::new(ctx), name, allow_external_mutators: false };
let cap = AdminCap { id: object::new(ctx) };
(c, cap)
}
public entry fun set_allow_external_mutators(
c: &mut Collection,
_cap: &AdminCap,
allow: bool
) {
c.allow_external_mutators = allow;
}
// -------------------- Minting --------------------
public entry fun mint_nft(
c: &Collection,
_cap: &AdminCap,
name: String,
content_uri: String,
content_hash: vector<u8>,
to: address,
ctx: &mut TxContext
) {
let nft = EvolvableNft {
id: object::new(ctx),
collection: object::id(c),
name,
version: 0,
content_uri,
content_hash,
bag: Option::none<ID>(),
};
transfer::transfer(nft, to);
}
/// Mint with a per-NFT mutator capability (hand to owner or a game engine).
public entry fun mint_nft_with_cap(
c: &Collection,
_cap: &AdminCap,
name: String,
content_uri: String,
content_hash: vector<u8>,
recipient: address,
ctx: &mut TxContext
) {
let nft = EvolvableNft {
id: object::new(ctx),
collection: object::id(c),
name,
version: 0,
content_uri,
content_hash,
bag: Option::none<ID>(),
};
let cap = NftMutatorCap { id: object::new(ctx), nft: object::id(&nft) };
// Deliver both in one atomic PTB step if desired:
transfer::transfer(cap, recipient);
transfer::transfer(nft, recipient);
}
// -------------------- Evolution (state updates) --------------------
/// Safe, atomic evolution gated by either:
/// - possession of NftMutatorCap for this NFT, or
/// - collection policy allowing external mutators.
public entry fun evolve(
nft: &mut EvolvableNft,
maybe_cap: Option<&NftMutatorCap>,
new_uri: String,
new_hash: vector<u8>,
diff_keys: vector<String>,
ctx: &mut TxContext
) {
// AuthZ: require either a valid cap tied to this NFT or policy allows it.
if (!is_authorized(nft, &maybe_cap)) {
abort 0; // replace with a proper error code table
};
let old_ver = nft.version;
let old_hash = nft.content_hash;
let _ = old_hash; // move checker friendliness; copy below
nft.content_uri = new_uri;
nft.content_hash = new_hash;
nft.version = old_ver + 1;
// Emit event as append-only history; no large vectors stored on-chain.
emit NftEvolvedEvent {
nft: object::id(nft),
old_version: old_ver,
new_version: nft.version,
old_hash: old_hash,
new_hash: vector::copy<nft.content_hash>(&nft.content_hash),
diff_keys,
};
}
fun is_authorized(nft: &EvolvableNft, maybe_cap: &Option<&NftMutatorCap>) : bool {
match *maybe_cap {
Option::Some(cap) => object::id(nft) == cap.nft,
Option::None => false
}
}
}
Why this is safe and efficient
Atomicity: The entry fun evolve mutates the NFT and emits the event in a single transaction.
Authorization: Controlled by a capability bound to a specific NFT ID; possession of the cap is the right to mutate.
No redundant writes: You mutate a few small fields and emit an event; you don’t append to vectors or maintain bulky history on-chain.
Traceability: Off-chain indexers or your backend can reconstruct full history from NftEvolvedEvent by nft ID.
Parallelism: Keeping NFTs owned (not shared) avoids lock contention and leverages Sui’s parallel execution.
Optional extensions
- Dynamic fields for traits
If you need many mutable attributes, store them as dynamic fields keyed by String to avoid a growing vector in the root NFT.
Each trait update still emits an event; the NFT root remains small.
- Rate limiting / cooldowns
Add last_update_epoch and check against clock::epoch() for per-NFT throttling.
- Off-chain rendering with verifiable integrity
Commit to a content hash; your metadata server produces JSON/art that matches that hash.
Clients verify content_hash before displaying.
- Role-based evolution
Instead of per-NFT NftMutatorCap, issue CollectionMutatorCap to trusted updaters that can mutate any NFT in the collection.
- Freezing
Add a frozen: bool flag; only AdminCap can toggle. If frozen, evolve aborts.
Frontend/PTB example (TypeScript)
import { Transaction, SuiClient, getFullnodeUrl } from "@mysten/sui.js";
const client = new SuiClient({ url: getFullnodeUrl("mainnet") });
export async function evolveNft({ packageId, nftId, capId, // optional: if using NftMutatorCap newUri, newHashHex, diffKeys, sender, }) { const tx = new Transaction(); tx.setSender(sender);
const args = [ tx.object(nftId), capId ? tx.optionSome(tx.object(capId)) : tx.optionNone("0x1::option::Option<0xYourPkg::dyn_nft::NftMutatorCap>"), tx.pure.string(newUri), tx.pure.vector("u8", Array.from(Buffer.from(newHashHex.replace(/^0x/, ""), "hex"))), tx.pure.vector("string", diffKeys), ];
tx.moveCall({
target: ${packageId}::dyn_nft::evolve
,
arguments: args,
});
// Simulate for gas + effects preview const dry = await client.dryRunTransaction({ transactionBlock: await tx.build({ client }) });
// Submit if dry run looks good // const res = await client.signAndExecuteTransaction({ transaction: tx, signer }); return dry; }
Common pitfalls and how to avoid them
Using shared NFTs unnecessarily: Shared objects serialize access and add contention. Prefer owned NFTs unless many parties must concurrently mutate the same object.
Storing full history in the NFT: Leads to ever-growing vectors and gas spikes. Emit events; keep the object small.
Missing capability gating: Always bind mutator capability to the NFT’s ID to prevent cross-token misuse.
Large string/JSON diffs in state: Put large payloads off-chain; store only a hash and small diff keys.
Overwriting without versioning: Increment version per change and include it in events for auditability.
Querying history (indexer or backend)
Filter NftEvolvedEvent by nft ID to reconstruct timeline.
Keep a denormalized table keyed by (nft_id, version) with content_uri, content_hash, timestamp, tx_digest.
Verify client-rendered content by comparing its hash to content_hash.
When to consider shared objects
If evolution requires multiple signers in the same transaction or a global rule engine must coordinate many NFTs atomically, introduce a shared “engine” object that consumes a capability and mutates multiple owned NFTs in one PTB. Keep the engine’s footprint minimal
Here’s a different, production-ready pattern that keeps NFT state small, evolutions atomic, and history precise—by pushing history into append-only version objects instead of rewriting the NFT.
High-level architecture
CollectionController { AdminCap }: governs minting, evolution policy, and emergency controls.
EvoNft (owned): the tiny “handle” users trade; it stores only collection_id, name, version, and a pointer to the current Version object ID.
Version (owned by the controller, referenced by the NFT): immutable, append-only object with parent_version, content_uri, content_hash, and optional compact diff metadata.
Dynamic fields under the CollectionController map nft_id → latest_version_id for O(1) lookups without bloating the NFT.
Events emitted only for pointer changes, not for large payloads.
This gives you:
Safety: Only the controller can create new versions and atomically swing the NFT pointer.
Atomicity: Create Version, update pointer, emit event all in one entry.
Traceability: History is a linked list of Version objects plus events; no redundant rewrites.
Efficiency: The traded NFT object is tiny; large content lives off-chain and is referenced by hash.
Move sketch
module 0xPkg::evo_nft { use sui::object::{Self, UID, ID}; use sui::tx_context::TxContext; use sui::transfer; use sui::dynamic_field::{Self as df, Field}; use std::string::String; use std::vector;
/// Controller for a collection; owns the version index and policy.
struct CollectionController has key {
id: UID,
name: String,
// per-collection index: nft_id -> current version id
index_bag: ID,
evolvable: bool,
}
struct AdminCap has key { id: UID }
/// The minimal, tradable NFT.
struct EvoNft has key, store {
id: UID,
collection: ID,
name: String,
version: u64,
// redundancy: current_version_id also tracked in controller index
current_version: ID,
}
/// Immutable version node; append-only history.
struct Version has key, store {
id: UID,
nft: ID,
parent: Option<ID>,
version: u64,
content_uri: String,
content_hash: vector<u8>, // hash of rendered metadata/art
diff_keys: vector<String>, // compact change description
}
struct Evolved has copy, drop {
nft: ID,
old_version: u64,
new_version: u64,
new_version_id: ID,
}
// ---------- Setup ----------
public entry fun create_collection(name: String, ctx: &mut TxContext)
: (CollectionController, AdminCap)
{
let bag = object::new(ctx);
(CollectionController { id: object::new(ctx), name, index_bag: bag, evolvable: true },
AdminCap { id: object::new(ctx) })
}
public entry fun set_evolvable(c: &mut CollectionController, _cap: &AdminCap, on: bool) {
c.evolvable = on;
}
// ---------- Mint ----------
public entry fun mint(
c: &mut CollectionController,
_cap: &AdminCap,
name: String,
content_uri: String,
content_hash: vector<u8>,
to: address,
ctx: &mut TxContext
) {
let nft = EvoNft {
id: object::new(ctx),
collection: object::id(c),
name,
version: 0,
current_version: object::id_raw(0), // temp; patched below
};
// create first version (no parent)
let v0 = Version {
id: object::new(ctx),
nft: object::id(&nft),
parent: Option::none<ID>(),
version: 0,
content_uri,
content_hash,
diff_keys: vector::empty<String>(),
};
let v0_id = object::id(&v0);
// write index: nft_id -> v0_id
df::add(&mut c.id, &nft.id, v0_id);
// patch nft pointer then transfer
let mut nft_mut = nft;
nft_mut.current_version = v0_id;
transfer::transfer(nft_mut, to);
transfer::transfer(v0, to); // or keep versions under controller if desired
}
// ---------- Evolve (atomic) ----------
public entry fun evolve(
c: &mut CollectionController,
_cap: &AdminCap,
nft: &mut EvoNft,
new_uri: String,
new_hash: vector<u8>,
diff_keys: vector<String>,
ctx: &mut TxContext
) {
assert!(c.evolvable, 1);
// look up current version id from controller's index
let current_id = df::borrow<ID, ID>(&c.id, &nft.id);
let parent_id = *current_id;
// create new immutable Version
let v = Version {
id: object::new(ctx),
nft: object::id(nft),
parent: Option::some<ID>(parent_id),
version: nft.version + 1,
content_uri: new_uri,
content_hash: new_hash,
diff_keys,
};
let v_id = object::id(&v);
// atomically swing pointers
nft.version = nft.version + 1;
nft.current_version = v_id;
df::remove<ID, ID>(&mut c.id, &nft.id);
df::add<ID, ID>(&mut c.id, &nft.id, v_id);
emit Evolved { nft: object::id(nft), old_version: nft.version - 1, new_version: nft.version, new_version_id: v_id };
// choose version custody: keep under owner to enable easy resale with history,
// or transfer v back to controller to centralize storage; here we keep with owner:
transfer::transfer(v, transfer::recipient_address_of(nft));
}
}
Notes
The NFT is tiny; history lives in immutable Version nodes.
The controller’s dynamic field index provides O(1) “latest pointer” without growing the NFT.
Every evolution is a single entry that creates Version, flips the pointer, emits Evolved.
Why this meets your goals
Safety: Only AdminCap can evolve; you can extend with per-NFT caps or role lists if needed.
Atomicity: No intermediate states leak; pointer flips and event emission happen together.
Traceability: Walk the Version.parent chain or subscribe to Evolved events; no redundant writes.
Gas efficiency: You never rewrite big vectors on the NFT; new data is an immutable, compact object.
Operational tips
Off-chain integrity: Clients fetch content_uri and verify content_hash before rendering.
GC policy: Optionally add a prune_versions admin entry that consolidates historical versions into a Merkle root and stores only the root hash to cap on-chain bloat.
Rate limits: Add last_update_epoch on NFT or controller, checked against clock::epoch().
PTB outline (TypeScript)
import { SuiClient, Transaction, getFullnodeUrl } from "@mysten/sui.js";
const client = new SuiClient({ url: getFullnodeUrl("mainnet") });
export async function evolve({
pkg, controllerId, adminCapId, nftId, newUri, newHashHex, sender,
}) {
const tx = new Transaction();
tx.setSender(sender);
tx.moveCall({
target: ${pkg}::evo_nft::evolve
,
arguments: [
tx.object(controllerId),
tx.object(adminCapId),
tx.object(nftId),
tx.pure.string(newUri),
tx.pure.vector("u8", Array.from(Buffer.from(newHashHex.replace(/^0x/, ""), "hex"))),
tx.pure.vector("string", []),
],
});
const dry = await client.dryRunTransaction({ transactionBlock: await tx.build({ client }) });
return dry;
}
Common pitfalls avoided
No shared NFTs unless required: keeps parallelism high.
No ever-growing arrays in the NFT: avoids gas spikes.
Clear capability gating via AdminCap; trivially extendable to per-NFT mutator caps.
History is immutable: tamper-evident and easy to index.
This variant emphasizes append-only version objects plus a lightweight pointer in the NFT, achieving safe evolution, atomicity, minimal writes, and clean auditability.
Design overview
Collection { AdminCap }: shared controller that owns policy and a CheckpointRegistry.
Nft (owned): minimal handle with state_nonce, checkpoint_root, and policy_id. No large vectors on the NFT.
JournalEntry (owned by sender during tx): ephemeral object carrying proposed changes plus their Merkle leaf hash.
Checkpoint (owned by collection): immutable commitment node storing the Merkle root of all entries in that evolution batch, parent root, and epoch.
Dynamic fields under Collection map nft_id → latest_checkpoint_root for constant-time lookups.
Events: NftEvolved emits the old/new roots and nonce; off-chain indexers fetch deltas by root.
This gives:
Atomicity: verify leaf membership → update nonce → swing root → emit event in one entry.
Safety: only AdminCap (or per-NFT MutatorCap) can attest a checkpoint; leaf data is validated by hash.
Efficiency: the NFT stores two words; all history is in immutable checkpoints.
Traceability: linear chain of checkpoint roots; reconstruct by following parent_root.
Move sketch
module 0xPkg::nft_commit { use sui::object::{Self, UID, ID}; use sui::tx_context::TxContext; use sui::dynamic_field as df; use sui::transfer; use sui::event; use std::option::Option; use std::vector;
struct Collection has key {
id: UID,
index_bag: ID, // nft_id -> latest checkpoint root
upgrades_allowed: bool,
}
struct AdminCap has key { id: UID }
struct Nft has key, store {
id: UID,
collection: ID,
state_nonce: u64, // anti-replay
checkpoint_root: ID, // current root (0x0 if genesis)
}
/// Immutable, append-only checkpoint.
struct Checkpoint has key, store {
id: UID,
parent: Option<ID>,
merkle_root: ID, // treat as opaque ID for root
epoch: u64,
}
/// Ephemeral proposal (not stored long-term).
struct JournalEntry has store {
nft: ID,
leaf_hash: vector<u8>,
nonce: u64,
}
struct NftEvolved has copy, drop {
nft: ID,
old_root: ID,
new_root: ID,
nonce: u64,
epoch: u64,
}
public entry fun create_collection(ctx: &mut TxContext):(Collection, AdminCap) {
(Collection { id: object::new(ctx), index_bag: object::new(ctx), upgrades_allowed: true },
AdminCap { id: object::new(ctx) })
}
public entry fun mint(
c: &mut Collection, _cap: &AdminCap, to: address, ctx: &mut TxContext
) {
let nft = Nft {
id: object::new(ctx),
collection: object::id(c),
state_nonce: 0,
checkpoint_root: object::id_raw(0),
};
// index starts at zero root
df::add<ID, ID>(&mut c.id, &nft.id, object::id_raw(0));
transfer::transfer(nft, to);
}
/// Off-chain prepares a merkle tree; on-chain we only verify root and leaf hash binding.
public entry fun evolve_with_checkpoint(
c: &mut Collection,
_cap: &AdminCap,
nft: &mut Nft,
leaf: JournalEntry,
new_merkle_root: ID,
epoch: u64,
parent_root_opt: Option<ID>,
ctx: &mut TxContext
) {
assert!(c.upgrades_allowed, 1);
// anti-replay
assert!(leaf.nonce == nft.state_nonce + 1, 2);
assert!(leaf.nft == object::id(nft), 3);
// Optional: check parent_root matches current index
let cur = df::borrow<ID, ID>(&c.id, &nft.id);
let expected_parent = match parent_root_opt {
Option::Some(r) => r,
Option::None => object::id_raw(0),
};
assert!(*cur == expected_parent, 4);
// Create immutable checkpoint
let cp = Checkpoint {
id: object::new(ctx),
parent: parent_root_opt,
merkle_root: new_merkle_root,
epoch,
};
let cp_id = object::id(&cp);
// Swing pointers atomically
nft.state_nonce = leaf.nonce;
nft.checkpoint_root = cp_id;
df::remove<ID, ID>(&mut c.id, &nft.id);
df::add<ID, ID>(&mut c.id, &nft.id, cp_id);
event::emit(NftEvolved {
nft: object::id(nft),
old_root: expected_parent,
new_root: cp_id,
nonce: nft.state_nonce,
epoch,
});
// Keep checkpoint with owner for discoverability
transfer::transfer(cp, transfer::recipient_address_of(nft));
// drop ephemeral leaf (no storage write)
let _ = leaf;
}
}
Why this is safe and efficient
The NFT’s nonce blocks replayed leaves.
The root swap and event emission are a single atomic entry.
No large fields mutate on the NFT; history stays in immutable checkpoints.
Off-chain merkle proofs bound updates to a specific NFT and nonce.
Off-chain flow
-
Fetch nft.checkpoint_root and state_nonce.
-
Build the next state off-chain; hash into a leaf commitment including nft_id and nonce+1.
-
Compute new_merkle_root; store tree off-chain (e.g., IPFS/DB).
-
Submit JournalEntry { nft_id, leaf_hash, nonce+1 } and new_merkle_root, plus parent_root.
-
Indexers watch NftEvolved events to link roots → full state snapshots.
PTB outline
const tx = new Transaction();
tx.moveCall({
target: ${pkg}::nft_commit::evolve_with_checkpoint
,
arguments: [
tx.object(collectionId),
tx.object(adminCapId),
tx.object(nftId),
tx.moveCall({ // pack JournalEntry
target: ${pkg}::nft_commit::new_journal_entry
,
arguments: [ tx.pure.id(nftId), tx.pure.vector("u8", leafHash), tx.pure.u64(nextNonce) ]
}),
tx.object(newMerkleRootId), // represent root as an object ID (or use ID literal)
tx.pure.u64(epoch),
parentRootId ? tx.object(parentRootId) : tx.pure.option("address", null),
// ctx auto
],
});
Operational notes
Integrity checks: bind leaf_hash = H(nft_id || nonce || state_delta) to prevent cross-NFT reuse.
Evolution policy: gate evolve_with_checkpoint by AdminCap or per-NFT MutatorCap to allow creator/owner-driven changes.
Compression: keep off-chain tree minimal; on-chain only stores root ID.
Audits: auditors can reconstruct exact state at any time by chasing roots and verifying merkle proofs.
This variant favors hash-committed checkpoints with nonce-based replay protection, giving you atomic updates, cheap writes, robust provenance, and clean parallelism without bloating the NFT object.
Sabes la respuesta?
Inicie sesión y compártalo.
Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.
Gana tu parte de 1000 Sui
Gana puntos de reputación y obtén recompensas por ayudar a crecer a la comunidad de Sui.

- ... SUIDpodium.js+191
- ... SUITucker+165
- ... SUIGifted.eth+149
- ... SUIacher+113
- ... SUIcasey+88
- ... SUIMiniBob+65
- ... SUItheking+55
- ¿Por qué BCS requiere un orden de campo exacto para la deserialización cuando las estructuras Move tienen campos con nombre?55
- «Errores de verificación de múltiples fuentes» en las publicaciones del módulo Sui Move: resolución automática de errores45
- Fallo en la transacción Sui: objetos reservados para otra transacción49
- Error de movimiento: no se puede procesar la transacción No se han encontrado monedas de gasolina válidas para la transacción315
- Cómo maximizar la retención de ganancias SUI: Sui Staking versus Liquid Staking110