Soulbound NFTs
A soulbound non-fungible token (NFT) is an NFT that is non-transferable. After you mint an NFT to a Sui account, the NFT belongs to that account and no one can transfer it. 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.
Two struct abilities control this behavior: key makes a struct a Sui object, and store is required by transfer::public_transfer and for wrapping an object inside another object.
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. For what happens at compile time and during execution when code tries to transfer a soulbound NFT, see What happens when you try to transfer a soulbound NFT.
The following table compares the 2 functions:
| Function | Requirement | Use with an NFT that has key but not store |
|---|---|---|
transfer::public_transfer | The object has the key and store abilities. Anyone can call it. | Not available, so holders cannot transfer the NFT. |
transfer::transfer | The object type is defined in the module that invokes it (enforced by the Sui Move bytecode verifier). | Available only inside the defining module, for custom transfer logic. |
To make an NFT soulbound:
- Define the NFT struct with the
keyability and without thestoreability. - If your use case requires controlled transfers, add a function in the same module that calls
transfer::transfer. Omit it for a fully soulbound NFT. - If your package is upgradeable, burn the
UpgradeCapor apply a restrictive upgrade policy after publication so a future upgrade cannot add a transfer function. - Confirm that the type has
keybut notstore, as described in Verify soulbound status.
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)
}
Because this function is defined in the same module as TestnetSoulboundNFT, it can call transfer::transfer even though the struct lacks the store ability.
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. Wrapping an object inside another object requires the
storeability, so a type withoutstorecannot be wrapped inside another object. This is a structural property of the type, not an extra rule: the Move type system enforces it at compile time (Security Best Practices). - Custom transfer logic must be deliberate. If you include a
transfer::transfercall 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
UpgradeCapor apply a restrictive upgrade policy after publication (Security Best Practices).