Sui - Soul-bound Object

Sui - Soul-bound Object


3 min read

Hello Everyone...

Welcome to another day in exploring Web3 Engineering. In today's article, let us understand the concept of Soul-bound objects and how to implement them in our contracts. So, without any further ado, let's get started.

What is a Soul-bound object ?

Soul bound objects are the objects that are directly created for the user but can't be transferred. In the broader context of blockchain technology, soul-bound tokens are a type of non-fungible token (NFT) that is specifically designed to be non-transferable. They are issued by and stored within accounts called "Souls," which can represent individuals, organisations, or companies. These tokens are used to establish provenance and reputation, and they cannot be bought or sold on the market.

These are useful where we are creating objects that either represent real world assets like Certificates, Awards etc which are once given to a user, they are not supposed to be transferred. Another use case is when we are creating identifier objects for a user to our ecosystem.

Sui Implementation

The Soul Bound Objects are a regular NFT objects without store capability. The store capability allows the object to be stored on the blockchain and they allow to transfer using public_transfer method.

But without store, objects can be transferred only using transfer method and this method can only transfer objects created on that module itself only.


Let us create a new move project which contains 2 modules.

  1. Registry - The registry contract is responsible for creating proxy accounts (soul-bound objects) for the users which can used as identifiers for interacting with other modules in our package.

  2. Counter - A counter contract that which will create new counters by users with AuthProxy.

Full code can be accessed here:

The Registry contract is as follows:

/// Module: Registry
module soul_bound_object::registry {
    public struct AuthProxy has key {
        id: UID
    public entry fun register(ctx: &mut TxContext) {
        let authProxy = AuthProxy {
            id: object::new(ctx),
        transfer::transfer(authProxy, tx_context::sender(ctx))
  • The entry function will generated a new AuthProxy object for the caller which is used as Identifier.

The Counter contract looks as follows:

/// Module: Counter
module soul_bound_object::counter {
    use soul_bound_object::registry::{AuthProxy};

    public struct Counter has key, store {
        id: UID,
        val: u64

    public entry fun get_counter(_: &AuthProxy, ctx: &mut TxContext) {
        let counter = Counter {
            id: object::new(ctx),
            val: 0

        transfer::public_transfer(counter, tx_context::sender(ctx))
    public entry fun increment(c: &mut Counter) {
        c.val = c.val + 1
    public fun val(c: &Counter): u64 {
  • get_counter: This functions requires a AuthProxy account in order to create a new counter for the user.

  • increment: To increment the value in the counter

  • val: To get the value in the counter

Here is the test file for our contract.

module soul_bound_object::soul_bound_object_tests {
    use soul_bound_object::registry;
    use soul_bound_object::counter;
    use sui::test_scenario;

    fun test_soul_bound_object() {
        let user: address = @123;

        let mut scenario = test_scenario::begin(user);
            let ctx = scenario.ctx();


            let proxy = scenario.take_from_sender<registry::AuthProxy>();
            let ctx = scenario.ctx();
            counter::get_counter(&proxy, ctx);
            scenario.return_to_sender( proxy)

            let mut c = test_scenario::take_from_sender<counter::Counter>(&scenario);

Please note that the register function and get counter function are created in 2 different transactions, since the objects are being transferred to user using transfer and public_transfer. The objects are generated and transferred to user only when the PTB is executed. Which means, we can't create and consume those objects in the same PTB.