Sui.

Post

Share your knowledge.

article banner.
harry phan.
Apr 24, 2025
Article

Soul-Binding Logic & the ReturnReceipt Pattern

Soul-Binding Logic & the ReturnReceipt Pattern

Now for a special design pattern that uses the parent-child mechanism: soul-bound objects. A soul-bound object is intended to be tied to a specific owner and not be permanently transferable. However, you might want to temporarily give someone access to it within a single transaction – for example, to perform some operation on it – but ensure it returns to you by the end of that transaction. This is achieved with what we call the ReturnReceipt pattern, a variant of the “hot potato” pattern.

The Hot Potato Pattern in Sui

The “hot potato” pattern is named after the children’s game – if you’re handed a hot potato 🌡️🥔, you can’t hold onto it; you must pass it on quickly. In Move terms, a hot potato is usually an object or resource that must be consumed or passed along in the same transaction, otherwise the transaction fails . This is often implemented by giving the object no drop ability (so you can’t just ignore it or drop it) and designing the logic such that the only way to get rid of it is to perform the required action (like returning a loan).

A common use case is a flash loan: you borrow some coins in a transaction (get a Loan object, plus a “debt” token). If you don’t repay by the end of the transaction, you can’t finalize because you still hold that debt token which you have no permission to drop – effectively forcing you to return the loan or abort .

Soul-Bound Object Example

Let’s say we have a SoulBound object that we always want to remain with its original owner’s address. We can enforce that if someone “borrows” it (as a child object under some parent), they must return it in the same transaction. How? By using a ReturnReceipt.

Below is a simplified version inspired by Sui’s documentation example of soul-bound objects

module demo::soul_bound {
    use sui::transfer::{Self, Receiving};
    use sui::object::UID;
    use sui::transfer;

    const EWrongObject: u64 = 0;

    /// A soul-bound object that cannot be permanently transferred.
    /// It has only `key` ability (no `store`), to tighten transfer/receive rules [oai_citation_attribution:40‡docs.sui.io](https://docs.sui.io/concepts/transfers/transfer-to-object#:~:text=%2F%2F%2F%20This%20object%20has%20,id%3A%20UID%2C).
    public struct SoulBound has key {
        id: UID,
        data: u64
    }

    /// A receipt that proves a SoulBound object must be returned.
    /// No abilities: cannot be copied, dropped, or stored (implicitly).
    public struct ReturnReceipt {
        /// The object ID of the soul-bound object that must be returned.
        object_id: UID,
        /// The address (or object ID) it must be returned to (the original owner).
        return_to: address
    }

    /// Allows the owner of `parent` to retrieve their SoulBound child.
    /// Returns the SoulBound object *and* a ReturnReceipt that compels return.
    public fun take_soul_bound(parent: &mut UID, sb_ticket: Receiving<SoulBound>): (SoulBound, ReturnReceipt) {
        let sb = transfer::receive(parent, sb_ticket);  // receive SoulBound (only this module can do it, since SoulBound has no store) [oai_citation_attribution:41‡docs.sui.io](https://docs.sui.io/concepts/transfers/transfer-to-object#:~:text=%2F%2F%2F%20,to_address%28%29%2C%20object_id%3A%20object%3A%3Aid%28%26soul_bound%29%2C) 
        let receipt = ReturnReceipt {
            object_id: sb.id,
            return_to: parent.to_address()
        };
        (sb, receipt)
    }

    /// Return a SoulBound object using the ReturnReceipt.
    /// This must be called in the same transaction after take_soul_bound.
    public fun return_soul_bound(sb: SoulBound, receipt: ReturnReceipt) {
        // Verify the receipt matches this SoulBound object
        assert!(sb.id == receipt.object_id, EWrongObject);
        // Send the SoulBound object back to the original owner address
        transfer::transfer(sb, receipt.return_to);
        // (ReturnReceipt is consumed/destroyed here as function param)
    }
}

In this design:

  • SoulBound is a key-only object. By not giving it store, we prevent use of public_transfer or public_receive on it , meaning the only way to transfer or receive it is through its defining module’s functions (ensuring our custom logic is used).
  • take_soul_bound (analogous to “get_object” in the docs ) is a function the owner would call to take their soul-bound object out of some parent. It calls transfer::receive to get the SoulBound object. Because SoulBound has no store, this call is only allowed here in its module (which is fine). It then creates a ReturnReceipt struct containing the ID of the soul-bound object and the address to return to (the parent’s address). We return both the object and the receipt.
  • ReturnReceipt has no drop, store, or copy abilities (we didn’t declare any, so it’s treated as a resource that cannot be dropped or duplicated). This means the transaction must do something with it; otherwise, it will be a leftover resource and the VM will not let the transaction end successfully. Essentially, the ReturnReceipt is our hot potato token 🔥🥔.
  • The only valid thing to do with a ReturnReceipt is to call return_soul_bound (or a similar designated function) in the same transaction. In return_soul_bound, we verify that the SoulBound object’s ID matches the receipt’s record (to prevent someone trying to return a different object with a wrong receipt) . Then we call transfer::transfer(sb, receipt.return_to), which sends the SoulBound object back to the address in the receipt (the original owner) . This effectively restores the soul-bound object to its rightful place. The ReturnReceipt is consumed as an argument (thus destroyed).
  • If the user tries to finish the transaction without calling return_soul_bound, they would still be holding the ReturnReceipt (from take_soul_bound output). Since ReturnReceipt has no drop, the Move VM will refuse to complete the transaction (in Move, you cannot simply discard a resource; it must be used or stored somewhere). The transaction would abort or be considered invalid, meaning the whole operation rolls back. Outcome: you can’t just run off with the soul-bound object; if you don’t return it, the tx fails and it stays with the parent.

From the moment you call take_soul_bound to the moment you call return_soul_bound in the transaction, you do have the SoulBound object value available – presumably to perform some allowed operations on it (maybe read it, or use it as needed). But you must return it before the transaction is over, thanks to the receipt enforcement .

This pattern is often described as “soul-bound = can’t leave its owner” but more accurately, it can leave within a single transaction as a child object, with the guarantee it comes back. It’s like checking out a library book – you have to return it before the library closes for the day 😅.

Why not just never transfer it at all? There are scenarios where temporarily transferring an object to another object (or context) is useful. One example is a shared object context: perhaps the SoulBound item is held under a shared object during some operation and needs to be taken out by the original owner. Another is to allow controlled composition – you might want to let some module operate on your object by giving it to that module’s object and then get it back.

By making SoulBound key-only, we ensured no external public_receive can pluck it without our module’s involvement . By using the receipt, we force adherence to the return policy. The comment in Sui’s example code even notes that the ReturnReceipt prevents swapping – if two soul-bound objects were taken out in one transaction, each receipt carries a specific object ID so you can’t mix them up and return the wrong one to cheat .

Generalizing the idea: The ReturnReceipt pattern can be used whenever you want to enforce that an object is given back. Flash loans use a similar concept (loan token and debt token that must be repaid). Anytime you have an invariant “object X must end up back at address Y by end of tx,” you can create a receipt resource to uphold it.

Quick Recap:

  • Soul-bound objects: key-only, retrieved only by their module’s logic.
  • ReturnReceipt: a dummy resource returned alongside the object to ensure it gets returned. It’s non-drop, so the user must call the return function or fail.
  • Hot potato: If you hold a ReturnReceipt, you better handle it (return the object) before the “music stops” (transaction ends) .
  • If the object isn’t returned, the transaction will not successfully execute – your changes rollback, effectively enforcing the soul-bound rule.

And with that, we’ve covered the major concepts of parent-child relationships in Sui Move!

5. Project Structure & Testing Strategy (GitHub-Ready)

To solidify these concepts, you might want to set up a Sui Move project and play with the examples. Here’s a suggested project layout that includes modules for the above examples. You can compile and run unit tests or use the Sui CLI to interact with them.

parent_child_demo/
├── Move.toml
├── sources/
│   ├── toy_box.move        (Parent & child in same module example)
│   ├── parcel.move         (Child module for Parcel)
│   ├── warehouse.move      (Parent module for Warehouse, uses public_receive)
│   └── soul_bound.move     (SoulBound and ReturnReceipt module)
└── tests/
    └── parent_child_test.move   (Integration tests for the modules)

Move.toml: Make sure to include the Sui framework and any addresses for your package. For example:

[package]
name = "parent_child_demo"
version = "0.0.1"
dependencies = [ 
    { name = "Sui", git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework", rev = "devnet" }
]

[addresses]
demo = "0x0"

(Use a proper address instead of 0x0 if required by Sui, or a named address that you’ll assign when publishing.)

In the sources directory:

  • toy_box.move could contain the example::toy_box module from Part 2, with Toy and Box definitions and the take_toy function.
  • parcel.move and warehouse.move will contain the modules from Part 3.
  • soul_bound.move will contain the module from Part 4.

Each module should use sui::... as needed for transfer, object, tx_context, etc., as shown in the examples. The tests/parent_child_test.move can then import these modules and simulate scenarios:

module demo::parent_child_test {
    use 0xYourAddr::toy_box;
    use 0xYourAddr::warehouse;
    use 0xYourAddr::parcel;
    use 0xYourAddr::soul_bound;
    use sui::tx_context::TxContext;

    #[test]
    fun test_toy_withdraw() {
        let ctx = TxContext::new(); // pseudo, context is usually provided
        let my_box = toy_box::Box { id: object::new(&mut ctx) };
        let my_toy = toy_box::Toy { id: object::new(&mut ctx), name: b"Ball".to_vec() };
        // Transfer toy into the box
        transfer::transfer(my_toy, my_box.id); 
        // Now simulate receiving it back
        let ticket = transfer::Receiving<toy_box::Toy>{ /*...*/ }; // In real test, use transfer::make_receiver or a transaction call
        let returned_toy = toy_box::take_toy(&mut my_box, ticket);
        assert!(returned_toy.name == b"Ball".to_vec());
    }

    // Additional tests for warehouse/parcel and soul_bound can be written similarly.
}

The above is a conceptual illustration. In practice, Sui’s test context might differ – you may need to use transfer::public_transfer in tests when transferring to object (if outside module), and use the Receiving from the transaction context. Sui provides a transfer::make_receiver (test-only function) that can fabricate a Receiving given an ID and version , which is useful in unit tests to simulate a child object input.

Running tests: You can run sui move test which will execute the #[test] functions. Or deploy the package to a local Sui network and call the entry functions via CLI or an SDK to observe the behavior:

  • Create a Parcel, create a Warehouse, call transfer::public_transfer via CLI to put the parcel into the warehouse, then call withdraw_parcel.
  • Create a SoulBound object (perhaps by making an entry fun to mint one), transfer it to some parent (or shared object), then in a single transaction call take_soul_bound and omit return_soul_bound to see the transaction fail (expected), versus including the return call to see success.

Each part of this project addresses one of the topics:

  • toy_box.move: basic parent-child and receive.
  • parcel.move & warehouse.move: cross-module children and public_receive.
  • soul_bound.move: soul-bound with ReturnReceipt.

By experimenting with these, you’ll deepen your understanding of Sui’s object model. The code is structured to be educational and should not be used as-is for production without security reviews, but it provides a solid starting point.


Conclusion: Sui Move’s parent-child object functionality is powerful. It lets you create complex data structures on-chain (like inventories, wallets, collections) with fine-grained access control. The combination of transfer::receive/public_receive and Move’s type system ensures that only authorized code can retrieve child objects, and patterns like ReturnReceipt enable enforcing temporal ownership rules (soul-binding, flash loans, etc.). We sprinkled some fun analogies and emojis, but at the end of the day, these are robust tools for builders. Now go forth and build some nested-object magic! 🚀🔥

When an object is transferred to another object instead of an account address, they form a parent-child relationship. You can think of it like this:

  • Parent = container object
  • Child = thing placed inside

Move doesn’t care if you’re transferring to an account or object ID. It just moves.

public struct Parent has key {
    id: UID,
    name: String,
}

public struct Child has key {
    id: UID,
    description: String,
}

Creating Parent & Child Objects

The parent must be mutable and must exist. You can’t stuff things into an immutable box!

  • Sui
  • Architecture
3
Share
Comments
.

Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.

366Posts503Answers
Sui.X.Peera.

Earn Your Share of 1000 Sui

Gain Reputation Points & Get Rewards for Helping the Sui Community Grow.

Reward CampaignJuly