Skip to main content

Soulbound NFTs

A soulbound non-fungible token (NFT) is an NFT that is non-transferable. After an NFT is minted to a Sui account, the NFT is bound to that account and cannot be transferred. This implementation uses the custom logic of the Sui framework transfer functions. The sui::transfer module contains 2 functions that transfer objects: transfer::transfer and transfer::public_transfer.

Typically, when you define new NFTs or object types on Sui, you do not need to create a transfer function because the Sui framework offers transfer::public_transfer, which anyone can use to transfer objects. transfer::public_transfer requires that transferred objects have the key and store abilities. Therefore, if you define a new NFT type that has the key ability (meaning it is a Sui object) but not the store ability, holders cannot use transfer::public_transfer. This results in a soulbound NFT.

You can also create custom transfer logic for NFTs on Sui. The transfer::transfer function has custom rules performed by the Sui Move bytecode verifier that ensure 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, you can still use transfer::transfer as long as you invoke it in the module that defined that object type. This allows the module owner to provide custom transfer logic for soulbound NFTs.

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

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

The TestnetSoulboundNFT struct has the key ability but not 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 stats or requiring a payment. Do not 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)
}

What happens when you try to transfer a soulbound NFT

If a user tries to transfer a soulbound NFT using transfer::public_transfer, the transaction aborts with an error because the object lacks the store ability. The Move bytecode verifier rejects the call at compile time if you try to use public_transfer on a type without store. If you attempt the transfer through a programmable transaction block, the transaction fails during execution.

Verify soulbound status

To check whether an NFT is soulbound, query the object's type and inspect its abilities. A struct with key but without store is soulbound (cannot be publicly transferred). You can verify this through the GraphQL RPC by querying the object's type information, or by inspecting the Move source code for the type definition.

Design considerations

When you design soulbound NFTs, consider the following:

  • Object wrapping does not bypass soulbound restrictions. A type without store cannot be wrapped inside another object. The Move type system enforces this at compile time (Security Best Practices).
  • Custom transfer logic must be deliberate. If you include a transfer::transfer call in your module, you are providing a way to move the NFT. Only include custom transfer logic if your use case requires controlled transfers (for example, resetting stats or requiring a payment). Omit it entirely for fully soulbound NFTs.
  • Module upgrades can add transfer functions. If your package is upgradeable, a future upgrade could add a transfer function. To guarantee permanent soulbound behavior, burn the UpgradeCap or apply a restrictive upgrade policy after publication (Security Best Practices).

View the full example on GitHub.