Site icon Chainstack

Sui object ownership: choosing the right model

Sui Object Ownership logo

A personal notebook, a public registry, and a permanent set of rules can all be Sui objects. They need different access patterns. One user should control the notebook, several users should contribute to the registry, and everyone should be able to use the rules without changing that particular version of them.

The first article introduced objects and the runtime records around their Move values. We will now use that model to choose an ownership form. Address-owned, shared, immutable, wrapped, and party objects each change how state can enter an operation and which checks the application must provide.

We will build small Move modules, compile them, and run seven local tests. Then we will publish the package once to Sui Testnet, use two fresh accounts to exercise the access rules, and inspect the resulting records with Python through native gRPC.

What ownership actually controls

An object read and an object input are different things. A data service can return an address-owned notebook’s public fields to someone who cannot use it in a transaction.

A Move function asks for a value or a reference such as &Notebook or &mut Notebook. The surrounding ownership state helps determine whether the requested stored object can enter that call. The function can then apply additional rules, such as checking a capability or comparing the sender with an administrator field.

Sui’s ownership documentation describes five forms, but they do not all correspond to values in an object’s owner field. Wrapping describes containment. For example, a notebook wrapped inside an address-owned case becomes part of the case’s Move value. The case has the runtime owner record, and access to the notebook goes through the case.

FormCreated withWho can supply it as an input
Address-ownedtransfer::transferThe owner address, as the transaction sender
Sharedtransfer::share_objectAny address. The module decides what each caller may do
Immutabletransfer::freeze_objectAnyone, but only as a read-only &T reference
WrappedStored as a field of another objectOnly through the container that holds it
Partytransfer::public_party_transfer with party::single_ownerThe party owner, with consensus-managed versions

Preparing Alice and Bob on Testnet

Two accounts let us test who can supply an object and which additional permissions its Move functions require.

Run:

mkdir -p sui-ownership
cd sui-ownership

mkdir -p .tools/bin move/sources client evidence

curl -fL https://github.com/MystenLabs/sui/releases/download/testnet-v1.83.0/sui-testnet-v1.83.0-macos-arm64.tgz -o .tools/sui.tgz
tar -xzf .tools/sui.tgz -C .tools/bin ./sui
export PATH="$PWD/.tools/bin:$PATH"

sui --version

Output:

sui 1.83.0-16d2dc1e05ed

Then create and select the accounts:

Run:

sui client --yes new-address ed25519 alice
sui client new-address ed25519 bob

sui client switch --env testnet --address alice
ALICE=$(sui client active-address)

sui client switch --address bob
BOB=$(sui client active-address)

sui client switch --address alice

# Output: 
# ALICE=0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497
# BOB=0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0

Your generated addresses and all later package IDs, object IDs, transaction digests, and versions will differ. Keep the variables returned by your own run.

Fund both accounts through the official Testnet faucet. The pinned CLI solves the faucet’s proof of work. Ensure each account has at least 1 SUI before continuing. These tokens have no real monetary value. The faucet guide describes the public service:

Run:

sui client faucet --address "$ALICE" --url https://faucet.testnet.sui.io

sui client faucet --address "$BOB" --url https://faucet.testnet.sui.io

Output:

Request successful. The faucet sent 1 SUI to the address balance of 0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497 in transaction 7L16J4khwPr1fH6EFj1QQnvbiFfXDmXWqscTyfVp4zga.

Request successful. The faucet sent 1 SUI to the address balance of 0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0 in transaction 4eHui9RBXBkqQdWs45TqWaAbjFkkkGEvgj3NywUJyzBd.

Create the Move package using the Sui CLI:

Run:

sui move new sui_ownership --path move
ls -1 move

Output:

Move.toml
sources
tests

Address-owned objects: custody and application permission

Choose an address-owned object when one account should supply that state to transactions. For example, a personal notebook, a ticket and a capability are natural examples. Other people can inspect their public records through a data service, but that observation does not grant access to the existing objects as transaction inputs. See address-owned objects.

We’ll use a notebook and a separate object that grants permission to update it.

Let’s create a file named move/sources/owned.move:

module sui_ownership::owned;

const EWrongNotebook: u64 = 0;

public struct Notebook has key, store {
    id: UID,
    value: u64,
}

public struct NotebookCap has key, store {
    id: UID,
    notebook: ID,
}

public fun create(value: u64, ctx: &mut TxContext): (Notebook, NotebookCap) {
    let note = Notebook { id: object::new(ctx), value };
    let cap = NotebookCap { id: object::new(ctx), notebook: object::id(&note) };
    (note, cap)
}

#[allow(lint(self_transfer))]
public fun create_owned(value: u64, ctx: &mut TxContext) {
    let (note, cap) = create(value, ctx);
    transfer::transfer(note, ctx.sender());
    transfer::transfer(cap, ctx.sender());
}

public fun value(note: &Notebook): u64 { note.value }

public fun set_value(note: &mut Notebook, cap: &NotebookCap, value: u64) {
    assert!(object::id(note) == cap.notebook, EWrongNotebook);
    note.value = value;
}

#[test_only]
public fun destroy_for_testing(note: Notebook, cap: NotebookCap) {
    let Notebook { id, value: _ } = note;
    object::delete(id);
    let NotebookCap { id, notebook: _ } = cap;
    object::delete(id);
}

#[test]
fun matching_cap_updates_value() {
    let mut ctx = tx_context::dummy();
    let (mut note, cap) = create(7, &mut ctx);
    set_value(&mut note, &cap, 9);
    assert!(value(&note) == 9, 1);
    destroy_for_testing(note, cap);
}

#[test]
#[expected_failure(abort_code = 0, location = sui_ownership::owned)]
fun another_notebooks_cap_is_rejected() {
    let mut ctx = tx_context::dummy();
    let (mut note, cap) = create(7, &mut ctx);
    let (other, other_cap) = create(7, &mut ctx);
    set_value(&mut note, &other_cap, 9);
    destroy_for_testing(note, cap);
    destroy_for_testing(other, other_cap);
}

Run:

sui move test --path move --no-lint owned

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_ownership
Running Move unit tests
[ PASS    ] sui_ownership::owned::another_notebooks_cap_is_rejected
[ PASS    ] sui_ownership::owned::matching_cap_updates_value
Test result: OK. Total tests: 2; passed: 2; failed: 0

Notebook holds the integer value. Its separate NotebookCap records the ID of the notebook it authorizes. The create function gives both objects fresh identities and binds the capability to the new notebook. Calling it again produces another pair.

create_owned transfers both objects to the transaction sender. The structs contain no owner address field. Their address ownership lives in the surrounding runtime records, which we will inspect after publication.

set_value takes &mut Notebook, which allows it to change the notebook, and &NotebookCap, which allows it to read the capability. These reference types describe what the function can do after receiving the objects. A read-only reference does not grant access to another account’s address-owned capability.

Before executing the Move call, Sui’s node software validates the stored input objects. In its Rust transaction checker, check_objects passes transaction.sender() to check_one_object for each ordinary input.

For an address-owned object, the stored metadata contains Owner::AddressOwner(actual_owner). The ownership check compares owner == &actual_owner, where owner is the sender address passed into that function. A mismatch returns IncorrectUserSignature and prevents set_value from starting. This check applies independently to the notebook and the capability.

Once both inputs pass that check, our Move function applies a different rule: object::id(note) == cap.notebook. The sender could own both objects and still supply a capability for another notebook. That pair passes ownership validation but fails our assertion with EWrongNotebook. The create function establishes the binding by writing the notebook’s ID into cap.notebook. Under Move’s struct access rules, other modules cannot construct their own NotebookCap { ... } values or directly rewrite that field. They must use the functions our module exposes.

The first unit test changes a notebook from 7 to 9 using its matching capability. The second deliberately supplies another notebook’s capability and expects abort code 0. Its passing result means that the expected rejection occurred. These tests exercise the module’s binding rule with locally constructed values. The later node experiment checks actual account ownership.

This design makes the transfer policy visible. Both types have store, so their owners can transfer them separately. The capability guards this setter, and an application that intends a complete update handover must account for both objects. If several accounts instead need ongoing access to one mutable record, a shared object gives us a different starting point.

Shared objects: public inputs with explicit application rules

Choose a shared object when multiple users must interact with the same mutable state without transferring it between them. A registration service needs one count of accepted entries, just as a marketplace needs common state for its participants. Our small registry stores that count so we can isolate access rules from the rest of an application.

Let’s create a file named move/sources/registry.move:

module sui_ownership::registry;

const ENotAdmin: u64 = 0;

public struct Registry has key {
    id: UID,
    entries: u64,
    admin: address,
}

public fun create(ctx: &mut TxContext) {
    transfer::share_object(Registry {
        id: object::new(ctx),
        entries: 0,
        admin: ctx.sender(),
    });
}

public fun add_entry(registry: &mut Registry) {
    registry.entries = registry.entries + 1;
}

public fun reset(registry: &mut Registry, ctx: &TxContext) {
    assert!(ctx.sender() == registry.admin, ENotAdmin);
    registry.entries = 0;
}

public fun entries(registry: &Registry): u64 { registry.entries }

#[test]
fun public_updates_and_admin_reset() {
    let mut ctx = tx_context::dummy();
    let mut registry = Registry {
        id: object::new(&mut ctx), entries: 0, admin: ctx.sender(),
    };
    add_entry(&mut registry);
    add_entry(&mut registry);
    assert!(entries(&registry) == 2, 1);
    reset(&mut registry, &ctx);
    assert!(entries(&registry) == 0, 2);
    let Registry { id, entries: _, admin: _ } = registry;
    object::delete(id);
}

#[test]
#[expected_failure(abort_code = 0, location = sui_ownership::registry)]
fun another_address_cannot_reset() {
    let mut ctx = tx_context::dummy();
    let mut registry = Registry {
        id: object::new(&mut ctx), entries: 2, admin: @0xA,
    };
    reset(&mut registry, &ctx);
    let Registry { id, entries: _, admin: _ } = registry;
    object::delete(id);
}

Run:

sui move test --path move --no-lint registry

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_ownership
Running Move unit tests
[ PASS    ] sui_ownership::registry::another_address_cannot_reset
[ PASS    ] sui_ownership::registry::public_updates_and_admin_reset
Test result: OK. Total tests: 2; passed: 2; failed: 0

create builds a fresh Registry and calls share_object. Any address can then reference it in a transaction. The module exposes add_entry as an operation open to everyone, so both Alice and Bob should be able to increment the count.

The same transaction checker rejects a shared or party object that a transaction specifies as an owned input, with NotOwnedObjectError.

Shared availability alone does not authorize every operation a module could expose. See shared objects and access control.

The admin field introduces a second rule. It records the creator’s address, and reset compares it with the transaction sender obtained from TxContext. That field is application data, not the object’s runtime owner. The object remains shared when Alice is its administrator.

The positive unit test adds two entries and lets the matching administrator reset the count to zero. The negative test gives the registry a different administrator and expects abort code 0. Together they exercise the two function policies. They use local values, so we will separately inspect the published shared object and call it from two actual Testnet accounts.

Shared state also creates a common place for conflicting writes. Two transactions modifying this counter must have an ordered result. Sharing does not make those writes independent or mean they execute simultaneously. Use a common shared record when the application needs that relationship. Separate personal records can remain address-owned when there is no reason for every user to update the same object.

Immutable objects: permanent reference data

Choose an immutable object for reference data that must remain fixed, such as a permanent rule set or a particular configuration snapshot. We’ll use a limit that everyone can read and no account can revise.

Let’s create a file named move/sources/rules.move:

module sui_ownership::rules;

public struct Rules has key, store {
    id: UID,
    limit: u64,
}

public fun create(limit: u64, ctx: &mut TxContext) {
    transfer::freeze_object(Rules { id: object::new(ctx), limit });
}

public fun read_limit(rules: &Rules): u64 { rules.limit }

public fun set_limit(rules: &mut Rules, limit: u64) {
    rules.limit = limit;
}

#[test]
fun read_then_freeze() {
    let mut ctx = tx_context::dummy();
    let rules = Rules { id: object::new(&mut ctx), limit: 10 };
    assert!(read_limit(&rules) == 10, 0);
    transfer::freeze_object(rules);
}

Run:

sui move test --path move --no-lint rules

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_ownership
Running Move unit tests
[ PASS    ] sui_ownership::rules::read_then_freeze
Test result: OK. Total tests: 1; passed: 1; failed: 0

freeze_object takes the object by value and makes it immutable. This is a permanent state change: the frozen object cannot subsequently be mutated, transferred, or deleted. Our constructor freezes the freshly created rules immediately. The resulting runtime record has the immutable ownership form, rather than an administrator address. See immutable objects.

An immutable object can enter a Move call as &Rules. It cannot satisfy an argument requiring &mut Rules or a by-value Rules. This is stronger than a function merely choosing to borrow an ordinary mutable object immutably for one call. Freezing constrains every later use of this stored instance.

We include set_limit as a deliberate probe. The function’s source is valid, and it would update a mutable Rules value. After our constructor freezes an instance, the runtime must prevent that instance from being supplied to the mutable parameter. The function’s visibility cannot undo the object’s immutable state.

Because its assertion runs before freeze_object, this unit test does not verify access to an already-frozen object.

A configuration that must change later needs another design. Creating a new rules object gives it a different identity, and existing transactions or objects referring to the old rules do not automatically switch to the new one. Immutable objects suit applications that deliberately preserve a specific rule set. For mutable common configuration, shared state with an explicit administrator check addresses a different requirement.

Wrapped objects: access through a case

A notebook can belong to an account while remaining an independently addressable object. Wrapping changes that relationship.

This fits an application where an item should move or be accessed together with its container. Examples include equipment inside a character, a document inside a case, or an asset held by a request object whose module exposes specific release operations. The wrapper’s fields and functions define how the contained value can be reached. Choosing wrapping therefore changes the application’s operations as well as its data layout.

Let’s create a file named move/sources/case.move:

module sui_ownership::case;

use sui_ownership::owned::{Self, Notebook, NotebookCap};

public struct Case has key, store {
    id: UID,
    notebook: Notebook,
}

#[allow(lint(self_transfer))]
public fun pack(notebook: Notebook, ctx: &mut TxContext) {
    transfer::transfer(Case { id: object::new(ctx), notebook }, ctx.sender());
}

public fun note_id(case: &Case): ID {
    object::id(&case.notebook)
}

public fun set_inside(case: &mut Case, cap: &NotebookCap, value: u64) {
    owned::set_value(&mut case.notebook, cap, value);
}

#[allow(lint(self_transfer))]
public fun unpack(case: Case, ctx: &mut TxContext) {
    let Case { id, notebook } = case;
    object::delete(id);
    transfer::public_transfer(notebook, ctx.sender());
}

#[test]
fun wrapped_notebook_keeps_identity() {
    let mut ctx = tx_context::dummy();
    let (notebook, cap) = owned::create(7, &mut ctx);
    let original_id = object::id(&notebook);
    let mut case = Case { id: object::new(&mut ctx), notebook };
    assert!(note_id(&case) == original_id, 1);
    set_inside(&mut case, &cap, 9);
    let Case { id, notebook } = case;
    object::delete(id);
    assert!(object::id(&notebook) == original_id, 2);
    assert!(owned::value(&notebook) == 9, 3);
    owned::destroy_for_testing(notebook, cap);
}

Run:

sui move test --path move --no-lint case

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_ownership
Running Move unit tests
[ PASS    ] sui_ownership::case::wrapped_notebook_keeps_identity
Test result: OK. Total tests: 1; passed: 1; failed: 0

Our Case stores the notebook itself in a field. The case has its own UID, and the notebook keeps its UID inside the case. While wrapped, that notebook is no longer a separate live object that a transaction can supply by ID. Access goes through the case. See wrapped objects.

We will reuse the notebook from the address-owned experiment. Bob keeps its matching capability separately and puts the notebook into a new case. This lets us test whether the capability’s ID binding survives containment. It also gives us a useful distinction to inspect later: the case has an ordinary address owner, but a direct current-object read using the notebook’s ID will fail while the notebook is inside it.

pack takes the notebook by value and places it inside a newly created Case. set_inside obtains a mutable reference to that field and passes it, together with the separately supplied capability, to the original setter. Wrapping does not remove the notebook module’s binding check. Bob must be able to supply the case and the capability, and the capability must still identify the contained notebook.

unpack consumes the case, extracts the notebook, deletes the case UID, and transfers the notebook to the sender. The unit test checks the notebook’s identity and setter behavior in local Move values.

Keep this distinct from transferring an object to another object’s ID. That mechanism uses an object-owned record and receiving operations. Our case uses a nested Move field, and its functions extract or borrow that field directly.

Party objects: restricted access with consensus-managed versions

Consider a counter maintained by one service account. Other accounts should not update it, but the service would like to launch another update before an earlier request has completed. A party object provides restricted ownership with consensus-managed version assignment. Its access rule and its input versioning mechanism are separate decisions.

Let’s create a file named move/sources/party_counter.move:

module sui_ownership::party_counter;

use sui::party;

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

public fun create(ctx: &mut TxContext) {
    let counter = Counter { id: object::new(ctx), value: 0 };
    transfer::public_party_transfer(counter, party::single_owner(ctx.sender()));
}

public fun increment(counter: &mut Counter) {
    counter.value = counter.value + 1;
}

public fun value(counter: &Counter): u64 {
    counter.value
}

#[test]
fun repeated_updates_change_value() {
    let mut ctx = tx_context::dummy();
    let mut counter = Counter { id: object::new(&mut ctx), value: 0 };
    increment(&mut counter);
    increment(&mut counter);
    assert!(value(&counter) == 2, 1);
    let Counter { id, value: _ } = counter;
    object::delete(id);
}

Run:

sui move test --path move --no-lint party_counter

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_ownership
Running Move unit tests
[ PASS    ] sui_ownership::party_counter::repeated_updates_change_value
Test result: OK. Total tests: 1; passed: 1; failed: 0

The public API we are using supports a single-owner party. party::single_owner gives that address the permissions, and public_party_transfer places the object in this ownership form. We will create Alice’s counter that way. The existence of a permission structure does not make this example a multi-member group. See party objects and current limitations.

The counter deliberately has no separately supplied capability. Its ordinary updates rely on the party owner’s authority. Adding an address-owned capability to every call would introduce another input whose state must be coordinated. Alice will submit two updates before waiting for their results.

increment is a public Move function and contains no sender comparison. That does not give Bob access to Alice’s counter. Sui still checks whether the sender may supply the party object.

The local unit test establishes that two function calls increment the value correctly. It does not test concurrent submission or ownership admission. Those observations come from the separate node experiment. Launching both commands before waiting demonstrates multiple inflight requests. The conflicting counter writes still execute in consensus order, so the successful results do not establish parallel execution of those writes.

Verify the choices on public Testnet

Publish the package once from Alice’s account:

Run:

sui client publish move --sender "$ALICE" --gas-budget 100000000 --json > evidence/publish.json

To print transaction status and extract the IDs used below, let’s create a file named client/receipt.py:

import argparse
import json
from pathlib import Path

parser = argparse.ArgumentParser()
parser.add_argument("receipt")
parser.add_argument("selector", nargs="?", default="status")
parser.add_argument("index", nargs="?", type=int, default=0)
args = parser.parse_args()
data = json.loads(Path(args.receipt).read_text())

if args.selector == "status":
    print("Status:", data["effects"]["status"]["status"])
    print("Digest:", data["digest"])
elif args.selector == "package":
    print(next(x["packageId"] for x in data["objectChanges"] if x["type"] == "published"))
else:
    matches = [x["objectId"] for x in data["objectChanges"]
               if x["type"] == "created"
               and x.get("objectType", "").endswith(args.selector)]
    print(matches[args.index])

Run:

python3 client/receipt.py evidence/publish.json

Output:

Status: success
Digest: 9hLA4w6XbkqkAwJt6XX1uafsDqu59BXDtYMinEfRE4T3

The publication transaction and package page expose the deployed modules on public Testnet.

Run:

PACKAGE=$(python3 client/receipt.py evidence/publish.json package)

# Output:
# PACKAGE=0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf

Address-owned: transfer the notebook, then its grant

Run:

sui client call --package "$PACKAGE" --module owned --function create_owned --args 7 --sender "$ALICE" --gas-budget 50000000 --json > evidence/owned-create.json
NOTEBOOK=$(python3 client/receipt.py evidence/owned-create.json '::owned::Notebook')
CAPABILITY=$(python3 client/receipt.py evidence/owned-create.json '::owned::NotebookCap')

# NOTEBOOK=0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050
# CAPABILITY=0xc9dbe7189dc0ea44e2b520a5d44c4b4bcd341419b42461aebb9018ca96ea4293

sui client call --package "$PACKAGE" --module owned --function set_value --args "$NOTEBOOK" "$CAPABILITY" 9 --sender "$ALICE" --gas-budget 50000000 --json > evidence/owned-alice-update.json
python3 client/receipt.py evidence/owned-alice-update.json

Output:

Status: success
Digest: HKjgjS8DsgXMfRhJHPrngLUKi5tarS3Nuw31gxL6f56z

We’ll read the notebook’s current owner and fields from public Testnet.

Create a Python virtual environment and install the dependencies for the native gRPC reads:

Run:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install grpcio==1.84.1 grpcio-reflection==1.84.1 protobuf==7.36.2

Output:

Successfully installed grpcio-1.84.1 grpcio-reflection-1.84.1 protobuf-7.36.2 typing-extensions-4.16.0

Let’s create a file named client/read_object.py:

import argparse
import json
import os
import sys
from pathlib import Path

import grpc
from google.protobuf import descriptor_pool, json_format, message_factory
from grpc_reflection.v1alpha.proto_reflection_descriptor_database import (
    ProtoReflectionDescriptorDatabase,
)

parser = argparse.ArgumentParser()
parser.add_argument("object_id")
parser.add_argument("label")
args = parser.parse_args()
if not args.label.isidentifier():
    parser.error("Use a simple label such as owned, shared, or wrapped_child")
endpoint = os.environ.get("SUI_GRPC_ENDPOINT", "fullnode.testnet.sui.io:443")
host = endpoint.rsplit(":", 1)[0]
channel = (
    grpc.insecure_channel(endpoint)
    if host in {"localhost", "127.0.0.1", "[::1]"}
    else grpc.secure_channel(endpoint, grpc.ssl_channel_credentials())
)
service = "sui.rpc.v2.LedgerService"

try:
    with channel:
        pool = descriptor_pool.DescriptorPool(ProtoReflectionDescriptorDatabase(channel))
        method = pool.FindServiceByName(service).methods_by_name["GetObject"]
        request_type = message_factory.GetMessageClass(method.input_type)
        response_type = message_factory.GetMessageClass(method.output_type)
        call = channel.unary_unary(
            f"/{service}/GetObject",
            request_serializer=request_type.SerializeToString,
            response_deserializer=response_type.FromString,
        )
        response = call(
            request_type(object_id=args.object_id, read_mask={"paths": ["*"]}), timeout=30
        )
        result = json_format.MessageToDict(response, preserving_proto_field_name=True)
except grpc.RpcError as error:
    result = {"error": {"code": error.code().name, "details": error.details()}}

Path("evidence").mkdir(exist_ok=True)
path = Path("evidence") / f"{args.label}.json"
path.write_text(json.dumps(result, indent=2) + "\n")

if "error" in result:
    print("GetObject failed:", result["error"]["code"], result["error"]["details"])
    print("Saved:", path)
    sys.exit(1)

obj = result["object"]

print("Object ID:", obj["object_id"])
print("Version:", obj["version"])
print("Type:", obj["object_type"])
print("Owner:", json.dumps(obj["owner"], sort_keys=True))
print("Move value:", json.dumps(obj["json"], indent=2, sort_keys=True))
print("Saved:", path)

Run:

python3 client/read_object.py "$NOTEBOOK" owned_before

Output:

Object ID: 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050
Version: 2
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::owned::Notebook
Owner: {"address": "0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497", "kind": "ADDRESS"}
Move value: {
  "id": "0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050",
  "value": "9"
}
Saved: evidence/owned_before.json

For the state reads, use Sui’s native LedgerService.GetObject. Server reflection supplies the protobuf descriptors from the public Testnet endpoint. See gRPC access.

Run these reads on Chainstack. client/read_object.py takes its endpoint from SUI_GRPC_ENDPOINT. Sui Testnet Global Nodes on Chainstack serve the same sui.rpc.v2 gRPC API at sui-testnet.core.chainstack.com:443. Send your node’s token as x-token metadata on every call, including the reflection request. The script above sends no metadata, so add it before you switch endpoints. See the Sui tooling docs, or deploy a Sui node on Chainstack.

This public read names Alice as owner and shows value 9. Keep the virtual environment active for each later read.

Run:

sui client transfer --to "$BOB" --object-id "$NOTEBOOK" --sender "$ALICE" --gas-budget 50000000 --json > evidence/owned-transfer.json
python3 client/receipt.py evidence/owned-transfer.json

Output:

Status: success
Digest: CDm9AcLC2KsC6WYRGahNATWHB3Pa2gZHvMzaxvSFukov

Alice still has the capability but no longer has the notebook:

Run:

sui client call --package "$PACKAGE" --module owned --function set_value --args "$NOTEBOOK" "$CAPABILITY" 10 --sender "$ALICE" --gas-budget 50000000 --json

Output: Expected exit 1.

Gas selection failed

Caused by:
    code: 'Client specified an invalid argument', message: "Error checking transaction input objects: Transaction was not signed by the correct sender: Object 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050 is owned by account address 0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0, but given owner/signer address is 0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497"

Bob has the notebook but still lacks the capability:

Run:

sui client call --package "$PACKAGE" --module owned --function set_value --args "$NOTEBOOK" "$CAPABILITY" 10 --sender "$BOB" --gas-budget 50000000 --json

Output: Expected exit 1.

Gas selection failed

Caused by:
    code: 'Client specified an invalid argument', message: "Error checking transaction input objects: Transaction was not signed by the correct sender: Object 0xc9dbe7189dc0ea44e2b520a5d44c4b4bcd341419b42461aebb9018ca96ea4293 is owned by account address 0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497, but given owner/signer address is 0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0"

The CLI reports Gas selection failed because input validation rejects an object the sender does not own. The nested error identifies the object and both addresses. This happens before set_value runs.

The address-owner check uses IncorrectUserSignature for this mismatch, even when signature cryptography is valid. These attempts produce no committed transaction.

Run:

sui client transfer --to "$BOB" --object-id "$CAPABILITY" --sender "$ALICE" --gas-budget 50000000 --json > evidence/cap-transfer.json
python3 client/receipt.py evidence/cap-transfer.json

sui client call --package "$PACKAGE" --module owned --function set_value --args "$NOTEBOOK" "$CAPABILITY" 11 --sender "$BOB" --gas-budget 50000000 --json > evidence/owned-bob-update.json
python3 client/receipt.py evidence/owned-bob-update.json

Output:

Status: success
Digest: 7CVpmyQuHZCfboyZ7giKUJjn6YkrQgxHUfmhmhZuiNZ8
Status: success
Digest: 9vN7rMVaBUcNyzsdu22p5YZ9Xe4bQdX7JmTY6avoKeej

Run:

python3 client/read_object.py "$NOTEBOOK" owned

Output:

Object ID: 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050
Version: 4
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::owned::Notebook
Owner: {"address": "0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0", "kind": "ADDRESS"}
Move value: {
  "id": "0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050",
  "value": "11"
}
Saved: evidence/owned.json

The notebook is now Bob’s value-11 object. Notebook transfer, capability transfer, and Bob’s successful update provide the public handover records. Moving the matching capability supplied the missing input. The capability’s notebook binding and the notebook’s ID stayed unchanged.

Shared: public additions and a rejected reset

Run:

sui client call --package "$PACKAGE" --module registry --function create --sender "$ALICE" --gas-budget 50000000 --json > evidence/registry-create.json
REGISTRY=$(python3 client/receipt.py evidence/registry-create.json '::registry::Registry')

# REGISTRY=0x0fcd4a588b475da4a2ce27731e1610d6bfe66370524bdc2eac0a09fdb7401725

sui client call --package "$PACKAGE" --module registry --function add_entry --args "$REGISTRY" --sender "$ALICE" --gas-budget 50000000 --json > evidence/registry-alice-add.json
python3 client/receipt.py evidence/registry-alice-add.json

sui client call --package "$PACKAGE" --module registry --function add_entry --args "$REGISTRY" --sender "$BOB" --gas-budget 50000000 --json > evidence/registry-bob-add.json
python3 client/receipt.py evidence/registry-bob-add.json

Output:

Status: success
Digest: 3iMSEAacuQRDKzY97tDS6KdAgDCXhkMzxroG6ADYbL7U
Status: success
Digest: 6o1czPoWBDJbxLX5TUi7TR3STHD4ai77CzR8qndH6N1o

Run:

sui client call --package "$PACKAGE" --module registry --function reset --args "$REGISTRY" --sender "$BOB" --gas-budget 50000000 --json

Output: Expected exit 1.

Error executing transaction 'GSz1wpk89aW1Hqgt4g1y2UwNGXPWoVD2muDLGdnj8gUU': 1st command aborted within function '0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::registry::reset' at instruction 11 with code 0

Bob can supply the shared registry, so this call reaches reset and aborts at the administrator check. The recorded failed transaction confirms MoveAbort in that function with code 0. It leaves the entry count at 2.

Inspect Alice’s addition, Bob’s addition, and the executed reset failure:

Run:

python3 client/read_object.py "$REGISTRY" shared

Output:

Object ID: 0x0fcd4a588b475da4a2ce27731e1610d6bfe66370524bdc2eac0a09fdb7401725
Version: 4
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::registry::Registry
Owner: {"kind": "SHARED", "version": "1"}
Move value: {
  "admin": "0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497",
  "entries": "2",
  "id": "0x0fcd4a588b475da4a2ce27731e1610d6bfe66370524bdc2eac0a09fdb7401725"
}
Saved: evidence/shared.json

Inspect the shared registry on the Testnet explorer.

The top-level version is 4. The owner record’s version 1 identifies when shared access began.

Immutable: a usable read reference, an invalid mutable reference

Run:

sui client call --package "$PACKAGE" --module rules --function create --args 10 --sender "$ALICE" --gas-budget 50000000 --json > evidence/rules-create.json
RULES=$(python3 client/receipt.py evidence/rules-create.json '::rules::Rules')

# RULES=0xe0a3f020908e4287bc0799ed1c0daf639e97adb2d1819451765745198725225e

sui client call --package "$PACKAGE" --module rules --function read_limit --args "$RULES" --sender "$BOB" --gas-budget 50000000 --json > evidence/rules-bob-read.json
python3 client/receipt.py evidence/rules-bob-read.json

Output:

Status: success
Digest: 5sV1tfAofeomH9CtHbUt7yEQ52gyPJopH5i23oDb7qzp

Run:

sui client call --package "$PACKAGE" --module rules --function set_limit --args "$RULES" 20 --sender "$BOB" --gas-budget 50000000 --json

Output: Expected exit 1.

Error executing transaction 'HQoijmMqg6CqtxE76sy63dng7RAawte77UDs5kj8MN3u': CommandArgumentError { arg_idx: 0, kind: InvalidObjectByMutRef } in command 0

Bob’s signed read call succeeded. The mutation attempt produced an execution argument error, InvalidObjectByMutRef, because argument 0 cannot provide a mutable reference. This is different from an application assertion rejecting Bob after receiving a mutable object.

Run:

python3 client/read_object.py "$RULES" immutable

Output:

Object ID: 0xe0a3f020908e4287bc0799ed1c0daf639e97adb2d1819451765745198725225e
Version: 1
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::rules::Rules
Owner: {"kind": "IMMUTABLE"}
Move value: {
  "id": "0xe0a3f020908e4287bc0799ed1c0daf639e97adb2d1819451765745198725225e",
  "limit": "10"
}
Saved: evidence/immutable.json

The immutable rules object is also publicly inspectable.

The rules remain at limit 10. The owner kind is IMMUTABLE and has no account owner address. Publishing a new function that accepts &mut Rules would not make this frozen instance mutable again.

Compare the successful read call with the executed mutable-reference failure.

Wrapped: inspect the container and restore the same notebook

Run:

sui client call --package "$PACKAGE" --module case --function pack --args "$NOTEBOOK" --sender "$BOB" --gas-budget 50000000 --json > evidence/case-pack.json
CASE=$(python3 client/receipt.py evidence/case-pack.json '::case::Case')

# CASE=0xeff1f3dda73922c61ff16a0a90877032c3efaf386ef7b57a6f09e19cc5cc362c

python3 client/read_object.py "$CASE" wrapped

Output:

Object ID: 0xeff1f3dda73922c61ff16a0a90877032c3efaf386ef7b57a6f09e19cc5cc362c
Version: 5
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::case::Case
Owner: {"address": "0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0", "kind": "ADDRESS"}
Move value: {
  "id": "0xeff1f3dda73922c61ff16a0a90877032c3efaf386ef7b57a6f09e19cc5cc362c",
  "notebook": {
    "id": "0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050",
    "value": "11"
  }
}
Saved: evidence/wrapped.json

The case’s object page describes this container. The capture below records its packed state before we release the notebook.

Run:

python3 client/read_object.py "$NOTEBOOK" wrapped_child

Output: This read exits with status 1.

GetObject failed: NOT_FOUND Object 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050 not found
Saved: evidence/wrapped_child.json

There is no separate Wrapped runtime owner value to look for in that notebook response. Containment is the reason the notebook lacks its own live record at this stage. Our known pack operation and the case’s nested data establish that explanation. A missing response by itself would not establish why an arbitrary object was unavailable.

NOT_FOUND has more than one cause. Here the notebook is missing because it is wrapped, but a node can also answer NOT_FOUND for an object version or transaction older than its retention window. Sui Global Nodes on Chainstack hold checkpoints from lowestAvailableCheckpoint to the tip, and LedgerService/GetServiceInfo reports that floor. Check it before you blame wrapping. See Checkpoint retention in the Chainstack docs.

Run:

sui client call --package "$PACKAGE" --module owned --function set_value --args "$NOTEBOOK" "$CAPABILITY" 12 --sender "$BOB" --gas-budget 50000000 --json

Output: Expected exit 1.

code: 'Some requested entity was not found', message: "Object 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050 not found"

The direct call fails during object resolution. This is not the notebook module rejecting a mismatched capability. The notebook is currently contained inside the case, so it is unavailable as the independent object input requested by this call. Our pack operation and the parent’s nested data establish why this known ID is unavailable. This lookup failure also has no committed transaction receipt. The captured read was taken before unpacking, and the case’s explorer page can later show its deleted state.

Run:

sui client call --package "$PACKAGE" --module case --function set_inside --args "$CASE" "$CAPABILITY" 12 --sender "$BOB" --gas-budget 50000000 --json > evidence/case-update-inside.json
python3 client/receipt.py evidence/case-update-inside.json
sui client call --package "$PACKAGE" --module case --function unpack --args "$CASE" --sender "$BOB" --gas-budget 50000000 --json > evidence/case-unpack.json
python3 client/receipt.py evidence/case-unpack.json

Output:

Status: success
Digest: 5Pm3C7o8hRVmhcFtTVA6nXA42tE6w3fEfYRKnyRA2uo9
Status: success
Digest: 4zZpHJuMkrAmJ7NckTpo8BPsAMwjYax3RyhTXGDnjycB

Run:

python3 client/read_object.py "$NOTEBOOK" unwrapped

Output:

Object ID: 0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050
Version: 7
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::owned::Notebook
Owner: {"address": "0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0", "kind": "ADDRESS"}
Move value: {
  "id": "0x4533721f6d1b6a56c3e82ea26af9380306a4ea7c4fa11217533c8b16a6fa0050",
  "value": "12"
}
Saved: evidence/unwrapped.json

The notebook’s public object page now resolves independently again.

Borrowing through Case.notebook allowed the module to update the contained value. Unpacking then deleted the case UID and returned the notebook to Bob. The notebook reappeared under its original ID with value 12. Follow packing, the update inside the case, and unpacking on the explorer.

Party: one permitted owner and two inflight updates

Run:

sui client call --package "$PACKAGE" --module party_counter --function create --sender "$ALICE" --gas-budget 50000000 --json > evidence/party-create.json
PARTY=$(python3 client/receipt.py evidence/party-create.json '::party_counter::Counter')

# PARTY=0x1051ce0f96a8b4c833cff80c056708f148f97d94a96de2ef87be49041be5f9d6

sui client call --package "$PACKAGE" --module party_counter --function increment --args "$PARTY" --sender "$BOB" --gas-budget 50000000 --json

Output: Expected exit 1.

Gas selection failed

Caused by:
    code: 'Client specified an invalid argument', message: "Error checking transaction input objects: Transaction was not signed by the correct sender: Object 0x1051ce0f96a8b4c833cff80c056708f148f97d94a96de2ef87be49041be5f9d6 is owned by account address 0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497, but given owner/signer address is 0x4d99c21a4439b90bb63a31ca3ed2e59d85a1eeaa3ff7ce44309414925abd0df0"

Bob’s input-owner check fails. Now submit two updates from Alice before waiting for either result:

Run:

sui client call --package "$PACKAGE" --module party_counter --function increment --args "$PARTY" --sender "$ALICE" --gas-budget 50000000 --json > evidence/party-update-a.json &
PID_A=$!

sui client call --package "$PACKAGE" --module party_counter --function increment --args "$PARTY" --sender "$ALICE" --gas-budget 50000000 --json > evidence/party-update-b.json &
PID_B=$!

wait "$PID_A"
wait "$PID_B"

python3 client/receipt.py evidence/party-update-a.json
python3 client/receipt.py evidence/party-update-b.json

Output:

Status: success
Digest: HcdjXwXxTPW9zDA7sfto55pEWJCY9ko5RbBYzLfQyE5j
Status: success
Digest: 5MUYYRfWP6KkG4XD47HZGrKkSPEh4XqZqyebm25Mk9Le

Run:

python3 client/read_object.py "$PARTY" party

Output:

Object ID: 0x1051ce0f96a8b4c833cff80c056708f148f97d94a96de2ef87be49041be5f9d6
Version: 3
Type: 0x20a8c601ffbb45123efe3d65e961f0ba5ab0528278c8fedf341278b0b3e1a4bf::party_counter::Counter
Owner: {"address": "0x9f80559b996a375c341a9906a2c10fc82555c3d9c3646105de3dc00ce8145497", "kind": "CONSENSUS_ADDRESS", "version": "1"}
Move value: {
  "id": "0x1051ce0f96a8b4c833cff80c056708f148f97d94a96de2ef87be49041be5f9d6",
  "value": "2"
}
Saved: evidence/party.json

Inspect the party counter’s public object page beside its native owner record.

The two CLI processes were launched before either wait. Both updates succeeded and the counter reached 2. The public receipts for update A and update B record the two executions. The conflicting writes still have an ordered execution. This experiment demonstrates accepted inflight requests, not parallel execution or a latency benchmark.

Both transaction records encode the counter as sharedObject with initialSharedVersion: 1. The owner record remains CONSENSUS_ADDRESS with Alice’s address. That input representation does not grant Bob public shared access. For this owner kind, owner.version is the consensus start version, while the top-level version identifies the returned state. See the native owner definition.

What the Testnet runs showed

FormAttemptResultStopped by
Address-ownedAlice updates the notebook after transferring it to BobIncorrectUserSignature, no transaction committedInput ownership check
Address-ownedBob updates the notebook without the capabilityThe same error, because Alice owns the capabilityInput ownership check
Address-ownedBob updates with the notebook and the capabilitySuccess, value 11Not stopped
SharedAlice and Bob each call add_entryBoth succeed, entries reach 2Not stopped
SharedBob calls resetMoveAbort, code 0The module’s admin check
ImmutableBob calls read_limitSuccessNot stopped
ImmutableBob calls set_limitInvalidObjectByMutRefArgument check
WrappedA direct call by the notebook’s IDObject not foundInput resolution
Wrappedset_inside through the caseSuccess, value 12Not stopped
PartyBob increments Alice’s counterOwner check errorInput ownership check
PartyAlice sends two updates before waitingBoth succeed, value 2Not stopped

Summary

An application can use several ownership forms at once.

ChooseWhenRemember
Address-ownedOne account should supply the objectAdd capabilities or other module checks for the operations that need them
SharedSeveral users need the same mutable stateDefine privileged operations explicitly and account for conflicting writes
ImmutableUsers must rely on one fixed instance of reference dataIt can never change, and a replacement has a new identity
WrappedThe contained value should be reached through a container’s operationsThe child is not an independent input while wrapped
PartyRestricted ownership and consensus-managed version assignment suit the submission patternThe public API used here supports a single-owner party

These choices are not freely interchangeable toggles. share_object currently requires an object created in the same transaction, and sharing is permanent. Freezing is also irreversible. The transfer APIs define those restrictions.

The failures also identify different enforcement points. An owner mismatch can prevent an object from entering a call. A shared object can enter and still fail a Move authorization check. An immutable object cannot satisfy a mutable reference. A wrapped child is unavailable as an independent live input. Reading those outcomes beside the source and owner records makes the choice concrete: decide who needs to supply the state, how it may change, and which operations the module must authorize.

Frequently asked questions

What are the object ownership models in Sui?

Sui documents five forms: address-owned, shared, immutable, wrapped and party objects. Wrapping describes containment rather than a value in the owner field. Each form changes how stored state can enter an operation and which checks the application must add.

When should I use a shared object instead of an address-owned one?

Use a shared object when several users must interact with the same mutable state without transferring it between them, such as a common registry or marketplace. Shared access does not authorize every operation, so privileged functions need explicit checks. Conflicting writes to one shared object are ordered, not independent.

Can I turn an owned object into a shared object later?

No. In the v1.83.0 framework, share_object aborts with ESharedNonNewObject unless the object was created in the same transaction, and sharing is irreversible. Freezing an object is also irreversible.

What can a transaction do with an immutable object?

It can use the object as a read-only &T input. It cannot satisfy a &mut T or by-value parameter, so a call that asks for one fails with InvalidObjectByMutRef. The object cannot be modified, transferred or deleted.

What happens to an object that is wrapped inside another object?

The wrapped object becomes part of its container’s Move value and stops being an independent live object. A direct lookup by its ID returns not found. Access goes through the container, and unpacking returns the object under its original ID.

Do party objects allow multiple owners?

Not through the public API used in this guide. party::single_owner gives one address the permissions, and the object gets consensus-managed versions. The party type can describe more members, but the supported ownership today is a single owner.

Why does Sui reject my call before the Move function runs?

Sui validates the stored input objects before execution. If the sender does not own an address-owned input, the check returns IncorrectUserSignature, even when the signature itself is valid. Your function never starts, and no transaction is committed.

Exit mobile version