Skip to main content

Soulbound NFT Example

A soulbound NFT is an NFT that is non-transferable. After an NFT is minted to a Sui account, the NFT is "bounded" to that account and cannot be transferred. This implementation leverages the custom logic of the Sui framework's transfer functions. The sui::transfer module contains two functions that transfers objects: transfer::transfer and transfer::public_transfer.

Typically, when defining new NFTs or object types on Sui, you don't need to create a transfer function because the Sui Framework offers transfer::public_transfer which anyone can use to transfer objects. However, transfer::public_transfer requires transferred objects have the key and store ability. Therefore, if you define a new NFT type that has the key ability, meaning it is a Sui object, but not the store ability, the holders won't be able to use transfer::public_transfer. This results in a soulbound NFT.

It is also possible to create custom transfer logic for NFTs on Sui. The transfer::transfer function has custom rules performed by the Sui Move bytecode verifier that ensures that the transferred objects are defined in the module where transfer is invoked. While removing the store ability from a struct definition makes transfer::public_transfer unusable, transfer::transfer can still be used as long as it's used in the module that defined that object's type. This allows for the module owner to provide custom transfer logic for their soulbound NFTs.

Example

The following example creates a basic soulbound NFT on Sui. The TestnetSoulboundNFT struct defines the NFT with an id, name, description, and url fields.

public struct TestnetSoulboundNFT has key {
id: UID,
name: string::String,
description: string::String,
url: Url,
}

This TestnetSoulboundNFT struct is defined with the key ability but without the store ability. This means you cannot transfer it with transfer::public_transfer. Instead, use transfer::transfer with custom transfer logic implemented in the same module.

This example also shows how to provide custom transfer logic using the transfer::transfer function. This is where you can add additional logic, such as resetting the NFT's stats or requiring a payment. Don't provide this functionality if the NFT is fully soulbound.

/// Transfer `nft` to `recipient`
/// Do not include this if you want the NFT fully soulbound
public fun transfer(nft: TestnetSoulboundNFT, recipient: address, _: &mut TxContext) {
// Add custom logic for transferring the NFT
transfer::transfer(nft, recipient)
}
Click to open

testnet_soulbound_nft.move

module examples::testnet_soulbound_nft;

use std::string;
use sui::event;
use sui::url::{Self, Url};

/// An example soulbound NFT that can be minted by anybody
///
/// Removing the `store` ablity prevents this NFT
/// from being transferred unless this module provides
/// a transfer function.
public struct TestnetSoulboundNFT has key {
id: UID,
/// Name for the token
name: string::String,
/// Description of the token
description: string::String,
/// URL for the token
url: Url,
// TODO: allow custom attributes
}

// ===== Events =====

public struct NFTMinted has copy, drop {
// The Object ID of the NFT
object_id: ID,
// The creator of the NFT
creator: address,
// The name of the NFT
name: string::String,
}

// ===== Public view functions =====

/// Get the NFT's `name`
public fun name(nft: &TestnetSoulboundNFT): &string::String {
&nft.name
}

/// Get the NFT's `description`
public fun description(nft: &TestnetSoulboundNFT): &string::String {
&nft.description
}

/// Get the NFT's `url`
public fun url(nft: &TestnetSoulboundNFT): &Url {
&nft.url
}

// ===== Entrypoints =====

#[allow(lint(self_transfer))]
/// Create a new devnet_nft
public fun mint_to_sender(
name: vector<u8>,
description: vector<u8>,
url: vector<u8>,
ctx: &mut TxContext,
) {
let sender = ctx.sender();
let nft = TestnetSoulboundNFT {
id: object::new(ctx),
name: string::utf8(name),
description: string::utf8(description),
url: url::new_unsafe_from_bytes(url),
};

event::emit(NFTMinted {
object_id: object::id(&nft),
creator: sender,
name: nft.name,
});

transfer::transfer(nft, sender);
}

/// Transfer `nft` to `recipient`
/// Do not include this if you want the NFT fully soulbound
public fun transfer(nft: TestnetSoulboundNFT, recipient: address, _: &mut TxContext) {
// Add custom logic for transferring the NFT
transfer::transfer(nft, recipient)
}

/// Update the `description` of `nft` to `new_description`
public fun update_description(
nft: &mut TestnetSoulboundNFT,
new_description: vector<u8>,
_: &mut TxContext,
) {
nft.description = string::utf8(new_description)
}

/// Permanently delete `nft`
public fun burn(nft: TestnetSoulboundNFT, _: &mut TxContext) {
let TestnetSoulboundNFT { id, name: _, description: _, url: _ } = nft;
id.delete()
}