Site icon Chainstack

Sui architecture: objects, tokenomics, and storage

Sui Architecture logo

In our EVM series, we followed execution into contract storage. With Solana, we separated program code from the accounts that hold application data. In Hyperliquid, we examined how a specialized financial state machine works alongside an EVM. Each system gave us a different answer to the same question: how does a network represent state, decide who can change it, and pay for maintaining it?

Sui is a general purpose Layer 1 blockchain built around Move code and a state model centered on objects. An asset, a position, or an administrative capability can have an identity the network recognizes directly. That changes how we describe application data, how authority reaches that data, and which operations compete for the same mutable state.

Before constructing our first network transaction, we need to understand what those statements mean. An object ID is different from a wallet address. The fields inside a Move value are different from the metadata the runtime tracks around it. Paying for execution is different from funding the future storage of data. We can make those distinctions concrete by defining an object, compiling its module, and testing the rules that govern its use.

In this opening article, we will build that foundation with a small Move package. We will create and update a notebook in local tests, examine a capability check, and see why the compiler rejects an attempt to copy its identity-bearing value. Python will handle the separate task of reading existing public chain data. We will then connect objects to SUI’s economic role and the cost and lifetime of stored state, without publishing a package or submitting our own transaction.

Why Sui organizes state as objects

Imagine an application with a million independently owned game items. Each item has an owner, a level, and a set of attributes. Two players improve two unrelated items. The application needs to preserve both changes, verify each player’s authority, and make the results available to future operations. The first architectural question is how those items appear to the execution layer.

On an EVM chain, a contract account holds code and persistent storage. An application commonly represents its items through mappings and other structures inside that storage. Calling the contract invokes code that interprets those slots and applies its rules. The runtime understands accounts, calls, and storage operations. The meaning of an item and its identifier comes from the contract. Ethereum’s account model describes the boundary between an account’s code and its storage.

Solana makes us choose the accounts that an instruction will use. A program operates on account data, while the account’s outer owner identifies the program permitted to modify that data. A game’s player authority can be another value inside the data. That is why a program-owned account and a player-controlled asset are compatible descriptions of the same application state. The two statements refer to different enforcement layers. See Solana’s account model.

Hyperliquid gives us another useful reference point. HyperCore implements specialized financial behavior, while HyperEVM supports EVM contracts. Both are secured by the chain’s HyperBFT consensus. Before comparing an operation with Sui, we need to distinguish an order processed by HyperCore from a Solidity call executed in HyperEVM. A shared consensus system does not make their execution responsibilities identical. The HyperEVM overview explains that relationship.

Sui gives each independently stored Move object its own identity and type, with ownership interpreted by the network. Our game can represent each item as an object. The item’s fields still need application code, but its identity and access category are part of the runtime’s model. A transaction can name the objects it needs, and execution can follow dependencies between their states. The objects overview introduces this unit of application state.

NetworkUnit of stateWhat gives it meaning
EVM chainsA contract account with code and persistent storageThe contract, through mappings and structures inside its storage
SolanaAccounts passed to each instructionThe program, which the account’s outer owner field names as the one permitted to modify its data
HyperliquidHyperCore for specialized financial behavior, HyperEVM for EVM contractsEach execution layer, both secured by HyperBFT consensus
SuiA Move object with its own identity and typeMove code defines the fields, and the network interprets ownership

This makes the shape of state an engineering decision with execution consequences. If two players use separate mutable items, those items do not create a shared write dependency by themselves. If both operations update one global leaderboard object, that object introduces a shared dependency. Splitting an application into objects does not remove contention from a genuinely shared resource. It gives us a way to identify where that contention comes from.

Keep consensus separate from this dependency analysis. In the current network flow, Mysticeti sequences transactions, and validators execute accepted work according to its dependencies. Independent work can execute in parallel. An ordinary owned-object operation still participates in this consensus flow. The current transaction lifecycle is the reference for that boundary.

The first model we need is therefore small: Move packages define behavior, objects carry identifiable application state, and the network validates and applies permitted changes. SUI pays for the resources that work consumes and participates in securing the network. We will examine those economic responsibilities after making the object itself concrete.

What actually lives inside an object

Consider a coin that contains a small amount of SUI. The asset is SUI, while the coin is one stored container for an amount of that asset. Two coins can contain the same amount and still have different identities. Transferring one whole coin can preserve its ID while changing its owner. Splitting an amount out of a coin produces a different object. Amount, identity, and ownership answer separate questions.

The framework defines Coin<T> as an object containing an id and a Balance<T>. The type argument identifies the asset. Coin<SUI> and a coin instantiated with another asset type are distinct types, even if their raw amounts happen to be equal. The coin module lets us inspect that relationship directly, without relying on a wallet’s symbol or decimal formatting.

A Move object is an instance of a struct with the key ability. Its first field is id: UID. The UID gives the instance its identity in object storage. An ID is a copyable reference to that identity, while UID has stronger rules that prevent ordinary code from duplicating the identity-bearing value. Copying an object’s hexadecimal ID into Python does not create another instance of that object. See the framework’s ID and UID definitions.

The defining code lives in a published Move package. A package contains bytecode modules, and those modules define types and functions. Publishing code and creating an instance of one of its types are different operations. The package object is immutable after publication. An application can have many mutable instances governed by those definitions. Package upgrades introduce further identity and linkage rules, which we will examine when we reach package management. The object model describes packages, modules, and their stored metadata.

Now separate the Move value from the runtime record around it. A coin’s application value contains its identity-bearing field and balance. The runtime also needs information such as its ownership form, version, and the transaction that produced its current state. The implementation represents these responsibilities in the object types. An API response can expose both layers in one JSON document, but that does not make every displayed field a field in the Move struct.

This distinction matters when we decode bytes. A serialized Move value answers what the type’s fields contain. A complete runtime object representation carries additional information around that value. Decoding the coin’s balance cannot, by itself, prove who currently owns the coin. The owner comes from the surrounding state record. We will keep those two observations separate in the Python inspection.

An object’s version identifies a state in its history, while its ID lets us follow the continuing instance. A digest commits to a particular representation of that state. For this article, their purpose is enough: an observation of one state is more precise than an object ID alone. Their derivation and ordering rules deserve a later experiment after we understand what is changing and which component records the change.

There is also an important qualification to the coin example. Current Sui supports address balances alongside Coin<T> objects. An address can hold a protocol-maintained balance for an asset without owning a separate coin object for that amount. Its total can include both representations. We choose an existing coin because its ID, fields, and owner are easy to inspect together. That choice should not become a claim that every payment creates a coin object. See Address Balances.

Define and compile our first Move object

We have described what an object contains. Now let’s define an application type ourselves. A notebook will contain an identity and one integer value. This keeps the state small enough to follow while letting us examine the same boundaries that appear in positions, game items, and administrative records.

Compiling the package prepares code. It does not create a live notebook on a public network. The package overview describes this source layout.

We will use the Sui CLI to resolve the framework dependencies, compile the module, and run its tests. Keep the recorded CLI version and the package lockfile with the example. These identify the environment behind the captured output. Every source file, setup step, and command needed for our experiment appears below.

Before compiling our first Move module, we need the Sui CLI. It provides the commands for building packages, running tests, and inspecting compiled code.

We’ll install it through suiup, the version manager recommended by the official Sui documentation. These commands work on macOS and Linux.

Installation:

curl -sSfL https://raw.githubusercontent.com/MystenLabs/suiup/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
suiup install sui@mainnet-1.82.3

Run:

sui --version

Output:

# something like

sui 1.82.3-bf4073918ed3

We’ll use Sui CLI to compile our Move package and run its tests locally. Later, our Python programs will inspect existing objects and transaction records on Sui Testnet.

Let’s create the environment:

mkdir -p sui-foundations
cd sui-foundations
mkdir -p move/sources move/tests move/negative/copy_object/sources client evidence
cd move

Let’s create a file named Move.toml:

[package]
name = "sui_foundations"
edition = "2024"

Run:

sui move build

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_foundations

This first build resolved the package and its framework dependencies. No notebook module has been added yet.

They are implicit dependencies for this toolchain, so the manifest does not need explicit dependency URLs. The compiler generated Move.lock from this manifest.

Keep this generated lockfile with the package so subsequent builds retain the dependency resolution.

The manifest describes the package rather than its application state. The package name identifies our source package, the edition selects the language rules, and the dependency information tells the toolchain where the imported framework definitions come from. The generated lockfile records the resolution used by the build. Neither file is an object instance or a substitute for the module that defines one.

The module below defines two objects:

We use the second object to expose an application permission check directly in the code, rather than assume that any value called a capability automatically grants the right permission.

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

module sui_foundations::notebook;

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 notebook = Notebook { id: object::new(ctx), value };
    let cap = NotebookCap {
        id: object::new(ctx),
        notebook: object::id(&notebook),
    };
    (notebook, cap)
}

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

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

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

Run:

sui move build

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_foundations

The build output identifies the package and framework code the toolchain compiled. The resulting module artifact contains bytecode and type information. A successful build establishes that this source passed the checks performed by this toolchain. It does not establish publication, consensus acceptance, or a change to public chain state. The Move CLI reference separates building from later client operations.

Start with Notebook. The key ability makes it an object type on Sui, with id: UID as its first field. value: u64 is application data. The UID comes from object::new(ctx), which uses the transaction context to produce a fresh identity-bearing value. We do not manufacture an object by placing an arbitrary hexadecimal string in its ID field. See Move on Sui.

The store ability describes permitted uses of the value, including storage inside other values and transfer through the public transfer API where applicable. It is separate from copy and drop. A notebook contains a UID, so we cannot simply duplicate or forget the value as if it were an ordinary integer. The type’s declared abilities and those of its fields have to agree.

The constructor returns a notebook and a capability bound to its ID. These are separate objects with separate identities. The notebook’s current owner, version, and producing transaction are not fields we have added to the struct. Those remain responsibilities of the surrounding runtime state. Its integer field and its owner therefore can change through different mechanisms.

set_value receives &mut Notebook, a mutable borrow of the existing value. Updating its integer does not create a second notebook. The function also borrows a capability and checks that its stored notebook ID matches the notebook being updated. That check is our module’s policy. It is not a universal rule automatically attached to every type whose name ends in Cap.

Move also enforces module boundaries. Declaring the struct as public lets other modules name its type. It does not let arbitrary code construct, unpack, or rewrite its fields directly. Our functions define the operations the module exposes. The distinction matters when we decide which state changes an application should permit.

The prelude makes names such as UID, ID, TxContext, and object available in this Sui Move edition. The minimal module therefore does not need extra imports for those names. Its constructor creates values and returns them to its caller. There is no module initializer here, and building this package does not create or store a notebook on Testnet.

The production build from before wrote build/sui_foundations/bytecode_modules/notebook.mv, a 422-byte module in this captured run. The test-only destructor is absent from that release bytecode. A compiled module is code that can later be published.

We can also inspect how the getter compiled. The first command generates the complete disassembly locally at build/sui_foundations/disassembly/notebook.mvb. The second displays the value function from that generated file.

Run:

sui move build --disassemble
sed -n '/public value(/,/^}/p' build/sui_foundations/disassembly/notebook.mvb

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_foundations
public value(notebook#0#0: &Notebook): u64 {
B0:
	0: MoveLoc[0](notebook#0#0: &Notebook)
	1: ImmBorrowField[0](Notebook.value: u64)
	2: ReadRef
	3: Ret
}

MoveLoc[0] moves the local &Notebook reference into the instruction’s operand flow. It does not duplicate or move the notebook object itself. ImmBorrowField[0] selects the integer field, ReadRef reads its copyable u64 value, and Ret returns it. The source-level field access has become explicit bytecode operations.

Test object identity and the rules around it

We have defined the state and compiled its operations. Now we need to observe how those rules behave when the functions run. The local test runner supplies a test transaction context, so the constructor can generate UIDs without a wallet, faucet, or public-chain request. These are local Move executions, not published objects. The testing documentation explains that boundary.

The tests exercise three distinct claims. Creating and updating one notebook should preserve its identity while changing its application value. Creating a second notebook should produce a different identity. Passing a capability associated with another notebook should abort at the module’s explicit permission check.

Let’s create a file named tests/notebook_tests.move:

#[test_only]
module sui_foundations::notebook_tests;

use sui_foundations::notebook;

#[test]
fun test_create_and_update() {
    let mut ctx = tx_context::dummy();
    let (mut note, cap) = notebook::create(7, &mut ctx);
    let original_id = object::id(&note);
    assert!(notebook::value(&note) == 7, 1);
    notebook::set_value(&mut note, &cap, 9);
    assert!(notebook::value(&note) == 9, 2);
    assert!(object::id(&note) == original_id, 3);
    notebook::destroy_for_testing(note, cap);
}

#[test]
fun test_distinct_ids() {
    let mut ctx = tx_context::dummy();
    let (first, first_cap) = notebook::create(7, &mut ctx);
    let (second, second_cap) = notebook::create(7, &mut ctx);
    assert!(object::id(&first) != object::id(&second), 4);
    notebook::destroy_for_testing(first, first_cap);
    notebook::destroy_for_testing(second, second_cap);
}

#[test]
#[expected_failure(abort_code = 0, location = sui_foundations::notebook)]
fun test_wrong_capability() {
    let mut ctx = tx_context::dummy();
    let (mut first, first_cap) = notebook::create(7, &mut ctx);
    let (second, second_cap) = notebook::create(7, &mut ctx);
    notebook::set_value(&mut first, &second_cap, 9);
    notebook::destroy_for_testing(first, first_cap);
    notebook::destroy_for_testing(second, second_cap);
}

Run:

sui move test

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING sui_foundations
Running Move unit tests
[ PASS    ] sui_foundations::notebook_tests::test_create_and_update
[ PASS    ] sui_foundations::notebook_tests::test_distinct_ids
[ PASS    ] sui_foundations::notebook_tests::test_wrong_capability
Test result: OK. Total tests: 3; passed: 3; failed: 0

The successful creation and update test checks the result of the constructor and the mutation function. The distinct-ID test checks two constructor results in the same local context. Together they connect the struct definition to actual values, rather than just repeat that a UID is unique.

The wrong-capability case is marked as an expected failure with the relevant abort code. Its passing test result means the operation reached the intended guard and aborted as specified. If the guard stopped aborting, or produced a different matched failure, the test would fail. An expected abort is therefore evidence of an enforced condition, rather than an unexplained red error in the output.

The cleanup helper consumes the test objects and deletes their UIDs. Objects with no drop ability must be handled explicitly. Marking that helper as test-only keeps its special cleanup route out of the production build. The helper is part of the test environment, not an administrative backdoor that a published caller could invoke.

Keep the enforcement stages distinct. Source type checking and bytecode checks reject invalid programs. A runtime assertion rejects a particular execution of an otherwise valid program. Public-chain ownership checks determine whether a transaction can supply an existing object to that execution. Our local capability test covers the application assertion. It does not by itself prove network ownership validation, finality, physical persistence, or actual gas accounting.

The mutation test begins with 7 and changes the value to 9 while preserving the notebook’s ID. The second test creates two notebooks in one test context and checks that their IDs differ. These are the exact local observations. They do not, on their own, establish uniqueness across an entire public network.

The wrong-capability test pins both abort code 0 and its location, sui_foundations::notebook. Its cleanup remains after the guarded call in the source. If the guard were removed, the function could finish normally and the expected-failure test would fail. This makes the authorization check an explicit, falsifiable part of our example.

Ask the compiler to duplicate the object

The valid package gives us a baseline. In a separate fixture, we now request the copy ability for the notebook type. That would permit duplicating its complete value, including the UID it contains. The UID does not supply that ability, so the requested struct ability is incompatible with its field. Keeping this fixture separate lets the main package remain buildable while preserving the exact rejected source.

The isolated fixture has its own manifest. We are still inside move/, and setup already created its negative/copy_object/sources/ directory. Build the manifest before adding the deliberately invalid source, just as we established a valid package baseline above.

Let’s create a file named negative/copy_object/Move.toml:

[package]
name = "copy_object_failure"
edition = "2024"

Run:

sui move build --path negative/copy_object

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING copy_object_failure

This successful build also generates a Move.lock file for the isolated package.

Let’s create a file named negative/copy_object/sources/bad_copy.move:

module copy_object_failure::bad_copy;

public struct BadNotebook has key, store, copy {
    id: UID,
}

Run:

sui move build --path negative/copy_object

Output:

INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING copy_object_failure
error[EC05001]: ability constraint not satisfied
   ┌─ ./sources/bad_copy.move:4:9
   │
 4 │     id: UID,
   │         ^^^
   │         │
   │         Invalid field type. The struct was declared with the ability 'copy' so all fields require the ability 'copy'
   │         The type 'sui::object::UID' does not have the ability 'copy'
   │
   ┌─ /Users/0xbytebeetle/clones/temp_blog_area/sui-selfcontained-lab/.local/move-cache/....
   │
84 │ public struct UID has store {
   │               --- To satisfy the constraint, the 'copy' ability would need to be added here

Failed to build Move modules: Compilation error.

Declaring BadNotebook with the copy ability asks Move to allow copies of the entire value. Every field must therefore also support copy. The compiler points to id: UID because UID does not have that ability. Wrapping it inside another struct cannot make it copyable. The build fails before any function runs, preventing us from duplicating an object’s identity by copying its value.

The two examples reject invalid operations at different stages. The copy_object_failure package never compiles because its UID field cannot be copied. Our notebook package does compile. In the earlier test_wrong_capability test, set_value runs and aborts because the supplied capability belongs to another notebook. The test passes because that abort is expected. The copying restriction comes from Move’s ability rules, while the capability restriction comes from the ID check we wrote inside set_value.

Connect the module’s rule to network ownership

Our notebook function begins with references to values. On the network, the transaction must first establish that it can supply those objects under their ownership rules. An address-owned capability can restrict who has access to the proof, while the stored notebook ID restricts what that proof authorizes. These checks cooperate. A caller knowing the IDs of both objects has not obtained either value or permission to use it.

The ownership forms below describe how an operation reaches stored state. The module’s function then enforces the application rule on the values it receives. That is the same separation we just observed in the local test, with a public-chain input-access layer added around it.

For an ordinary address-owned coin, a transaction signed by its account owner can use the coin as an owned input. The owner can transfer it through an allowed operation, after which the new owner controls later uses. An object’s owner address can also identify another object, with access established through the corresponding receiving mechanisms. A wallet address and an object ID may use the same hexadecimal representation while serving different roles. The address-owned object documentation explains these access cases.

A shared object supports interactions from multiple participants. A market or game board can be shared because many users need access to the same evolving state. That does not authorize every participant to perform every operation. A public shop can let customers buy an item while requiring a separate administrative capability to collect its revenue. The shared object makes the shop accessible. The function’s checks and required arguments establish what the caller may do. The framework’s shared shop example makes this distinction concrete.

An immutable object solves a different problem. Anyone can use it as read-only input, and it cannot later be modified, transferred, or deleted. Several operations can consult the same permanent configuration without competing to rewrite it. Freezing an object therefore changes what the runtime permits, rather than just expressing an intention in a frontend. See Immutable Objects.

Wrapped objects: wrapping changes the boundary through which a value is reached. An object stored directly inside another object’s value is no longer independently available as an input or ordinary object lookup. Code reaches it through its wrapper. This is stronger than keeping a child’s ID in a list. The first design embeds the value, while the second keeps a reference to separately stored state. We will return to these composition choices when comparing wrapping with dynamic fields. See Wrapped Objects.

Party objects add restricted access with consensus-managed versioning. The public transfer functions currently support single-owner parties. This can let an application submit several dependent operations without selecting the next exact owned-object version itself, while mutations still execute in order. It does not make one mutable object support simultaneous conflicting writes. The party object documentation separates those concerns.

FormWho can use itTypical use
Address-ownedThe owner, through a transaction they sign. A new owner takes over after a transfer.An ordinary coin
SharedMultiple participants. Function checks and required arguments decide what each may do.A market or game board
ImmutableAnyone, as read-only input. It cannot be modified, transferred or deleted.Permanent configuration
WrappedOnly through the object that contains it. It is no longer an independent input.A value embedded in another object
PartyOne owning address, with consensus-managed versioning. Only single-owner parties are supported today.Dependent operations without choosing the next object version

Read an existing object with Python

We will inspect a public Testnet coin that already exists, without creating a wallet or submitting a transaction. Use Python 3.10 or newer. Save the following files under client/ and run the Python commands from the project root in the order shown. The two network readers use the gRPC dependencies installed below. The local decoder uses only the Python standard library.

Our first Python program reads an existing object from a Sui Testnet full node using the GetObject RPC. We request its identity, version, ownership information, Move type, and serialized contents. The Move value is encoded in Binary Canonical Serialization, or BCS, which we will inspect in the next example. We save the response to evidence/object.json so our decoder will use the same object observation. This call reads existing state without constructing, signing, or submitting a blockchain transaction (Querying Data with gRPC)

First, let’s install the Python dependencies:

python3 -m venv .venv
source .venv/bin/activate
mkdir -p client evidence
python3 -m pip install grpcio==1.84.1 grpcio-reflection==1.84.1 protobuf==7.36.2
python3 -m pip freeze

Version check output:

grpcio==1.84.1
grpcio-reflection==1.84.1
protobuf==7.36.2
typing_extensions==4.16.0

We will put the connection code in one small file. It obtains the RPC message definitions through the node’s reflection service, then uses those definitions to construct requests and decode responses. This keeps the two network readers short without requiring generated client files.

Let’s create a file client/rpc.py:

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

endpoint = "fullnode.testnet.sui.io:443"
service_name = "sui.rpc.v2.LedgerService"

def rpc(method_name, **fields):
    credentials = grpc.ssl_channel_credentials()
    with grpc.secure_channel(endpoint, credentials) as channel:
        database = ProtoReflectionDescriptorDatabase(channel)
        pool = descriptor_pool.DescriptorPool(database)
        method = pool.FindServiceByName(service_name).methods_by_name[method_name]
        request_type = message_factory.GetMessageClass(method.input_type)
        response_type = message_factory.GetMessageClass(method.output_type)
        call = channel.unary_unary(
            f"/{service_name}/{method_name}",
            request_serializer=request_type.SerializeToString,
            response_deserializer=response_type.FromString,
        )
        response = call(request_type(**fields), timeout=30)
        return json_format.MessageToDict(response, preserving_proto_field_name=True)

if __name__ == "__main__":
    info = rpc("GetServiceInfo")
    if info["chain"] != "testnet":
        raise RuntimeError("The endpoint did not report Sui Testnet")
    print("RPC endpoint:", endpoint)
    print("Network:", info["chain"])
    print("Chain identifier:", info["chain_id"])
    print("Native gRPC connection verified")

Run:

python3 client/rpc.py

Output:

RPC endpoint: fullnode.testnet.sui.io:443
Network: testnet
Chain identifier: 69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD
Native gRPC connection verified

The helper opens a TLS connection to the specified endpoint. Its standalone check calls GetServiceInfo, which reports the node’s network and chain identifier. The other programs import the same rpc function.

Run these examples on Chainstack. Sui nodes on Chainstack serve the same sui.rpc.v2 gRPC API with server reflection enabled. To use one, set endpoint to sui-testnet.core.chainstack.com:443 and send your node’s token as x-token metadata on every call, including the reflection request. The helper above sends no metadata, so add it before you switch endpoints. Sui removes JSON-RPC from the full node in v1.82, so gRPC is the interface to build on. See the Sui tooling and Sui gRPC endpoint docs, or deploy a Sui node on Chainstack.

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

import base64
import json
from decimal import Decimal
from pathlib import Path

from rpc import rpc

object_id = "0x846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f"
info = rpc("GetServiceInfo")

if info["chain"] != "testnet":
    raise RuntimeError("The endpoint did not report Sui Testnet")

result = rpc("GetObject", object_id=object_id, read_mask={"paths": ["*"]})
obj = result["object"]
fields = obj["json"]
balance = int(fields["balance"])

assert obj["object_id"] == fields["id"] == object_id
assert balance == int(obj["balance"])

Path("evidence").mkdir(exist_ok=True)
Path("evidence/object.json").write_text(json.dumps(result, indent=2) + "\n")
Path("evidence/service-info.json").write_text(json.dumps(info, indent=2) + "\n")

print("Network: Sui Testnet")
print("Chain identifier:", info["chain_id"])
print("Service checkpoint:", info["checkpoint_height"])
print("Object ID:", obj["object_id"])
print("Version:", obj["version"])
print("Owner kind:", obj["owner"]["kind"])
print("Owner address:", obj["owner"]["address"])
print("Move type:", obj["object_type"])
print("Move fields:", json.dumps(fields, sort_keys=True))
print("Balance:", balance, "MIST =", Decimal(balance) / Decimal(10**9), "SUI")
print("Move value bytes:", len(base64.b64decode(obj["contents"]["value"], validate=True)))
print("Full object bytes:", len(base64.b64decode(obj["bcs"]["value"], validate=True)))
print("Object storage rebate:", obj["storage_rebate"], "MIST")
print("Producing transaction:", obj["previous_transaction"])
print("Saved the public response to evidence/object.json")

Run:

python3 client/01_read_object.py

Output:

Network: Sui Testnet
Chain identifier: 69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD
Service checkpoint: 398857475
Object ID: 0x846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f
Version: 3
Owner kind: ADDRESS
Owner address: 0x504366012fb19e3a09b1a22541d54c1e44c9334a65e43316cd0d2e58afba4b73
Move type: 0x0000000000000000000000000000000000000000000000000000000000000002::coin::Coin<0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI>
Move fields: {"balance": "10000000", "id": "0x846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f"}
Balance: 10000000 MIST = 0.01 SUI
Move value bytes: 40
Full object bytes: 126
Object storage rebate: 988000 MIST
Producing transaction: FT6BDcF3pzwzWUBoNJYiUmbKGu7H1bitHrz5XSDG4jNF
Saved the public response to evidence/object.json

The GetObject request identifies the coin by its object_id. The returned owner address identifies who owns it. The parsed Move value contains id and balance, while owner, version, storage_rebate, and previous_transaction describe the surrounding runtime object. We can now see the distinction between the fields defined by the Move type and the metadata maintained around that value.

The amount is 10000000 MIST. One SUI is one billion MIST, so the displayed amount is exactly 0.01 SUI. Python’s integers preserve the raw amount, and Decimal formats it without floating-point rounding. The object’s 988000 MIST storage rebate is a separate quantity associated with storage accounting. It is not an additional coin balance, and reading the object does not spend it.

The checkpoint height comes from GetServiceInfo and describes the node’s progress through the chain. The object version comes from GetObject and identifies this particular object’s state. These numbers measure different things and do not advance together. Because we obtained them through separate requests, we should not assume the object was read at exactly the reported checkpoint. We save the object response as the input for our subsequent decoding.

Inspect the Move value with Python

We can inspect the saved response and independently decode the fields returned by the service. This example works entirely from evidence/object.json, so it makes no additional chain request. We keep the Move example for defining and testing object rules, while Python makes their stored representation observable.

For this Coin<SUI>, the Move value is 40 bytes: the UID address occupies the first 32 bytes, followed by an eight-byte little-endian u64 balance. We will decode those fields, compare them with the parsed JSON, and deliberately remove one byte. This checks the specific Move value, while the complete 126-byte runtime object and its digest remain separate mechanisms.

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

import base64
import json
from pathlib import Path

obj = json.loads(Path("evidence/object.json").read_text())["object"]
raw = base64.b64decode(obj["contents"]["value"], validate=True)

def decode_coin(value):
    if len(value) != 40:
        raise ValueError(f"Expected 40 Move value bytes, received {len(value)}")
    object_id = "0x" + value[:32].hex()
    balance = int.from_bytes(value[32:40], "little")
    return object_id, balance

object_id, balance = decode_coin(raw)
assert object_id == obj["object_id"] == obj["json"]["id"]
assert balance == int(obj["json"]["balance"]) == int(obj["balance"])

print("UID bytes [0:32]:", raw[:32].hex())
print("Balance bytes [32:40]:", raw[32:40].hex())
print("Decoded object ID:", object_id)
print("Decoded balance:", balance, "MIST")
print("PASS: decoded values match both the object metadata and Move JSON")

try:
    decode_coin(raw[:-1])
except ValueError as error:
    print("Truncated input rejected locally:", error)
else:
    raise AssertionError("The truncated input was incorrectly accepted")

Run:

python3 client/02_decode_coin.py

Output:

UID bytes [0:32]: 846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f
Balance bytes [32:40]: 8096980000000000
Decoded object ID: 0x846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f
Decoded balance: 10000000 MIST
PASS: decoded values match both the object metadata and Move JSON
Truncated input rejected locally: Expected 40 Move value bytes, received 39

The bytes 8096980000000000 decode to 10000000 MIST in little-endian order. That agrees with the node’s parsed balance, and the first 32 bytes match both the parsed Move UID and the object’s object_id metadata. We have checked the fields through a second representation of the same response.

The 39-byte input fails our local length check. It is a deliberate decoder rejection, not a validator rejection. The decoded amount also does not reveal the owner or the storage rebate. Those belong to the runtime metadata around the value, which is why we retain both the Move and Python viewpoints.

You can compare the result with the same object in Suiscan. The screenshot below shows version 3, the 0.01 SUI amount, its owner, and the separate storage rebate. These are existing public records, read for this article.

The coin’s Move type defines its id and balance fields, just as our notebook type defines its own fields. The full node also returns the owner, version, and previous transaction as metadata around that Move value. We can read these public records without possessing the authority needed to use the coin in a transaction.

We have now decoded the coin’s 40-byte Move value and compared it with the node’s parsed fields. The complete 126-byte object serialization contains additional information beyond those fields. Reconstructing that full representation and verifying its object digest requires further serialization rules, which we will examine in a later post. For now, we can identify the coin’s application fields, inspect their encoded bytes, and distinguish them from its ownership and other runtime metadata.

What SUI is used for and how stake secures the network

We have inspected a container holding SUI. Now consider the asset itself. The native unit connects application activity with the resources needed to operate the network. Users and applications consume execution and storage. Validators provide those resources and participate in consensus. Holders can delegate stake, giving the protocol an economic basis for its validator committee.

SUI has fee-payment, staking, application-asset, and governance roles.

These roles do not all use the token in the same way:

Use of SUIWhat happens
Paying for workSettles a resource charge.
Delegating stakeParticipates in validator economics.
Holding an application balanceDoes not itself authorize a privileged Move function. That permission still comes from the object and code rules we have already discussed.

The tokenomics overview connects these participants and flows.

Epochs connect stake to an operating validator committee

Sui operates in periods called epochs. During each epoch, a committee of validators processes transactions under an established configuration. At the epoch boundary, the protocol can update the committee and its voting power for the next period.

SUI connects token holders to this process through delegated proof of stake. Holders can delegate their SUI to validator staking pools, and stake contributes to the committee’s voting power. Validators and delegators receive rewards under the protocol’s rules. We will examine staking and reward distribution in detail later. See Epochs and Reconfiguration.

An object can remain live across many epochs, even as the validator committee changes. Validators joining later still need to retain state created by earlier users. This creates an ongoing storage obligation beyond the computation performed when the object was created. The storage fund helps account for that continuing cost, which brings us to how Sui charges for computation and storage (There will be a dedicated blogpost about that).

How Sui stores state and records changes

Executing a transaction requires computation. Keeping its resulting objects available requires storage. The execution finishes, but the state it creates can remain live across many epochs. Sui therefore accounts for processing work and persistent storage separately. See the storage overview.

When we discuss stored data, we should distinguish three things:

ItemWhat it contains
Current objectThe object’s latest available state, including its fields, owner, and version
Transaction effectsWhat an execution changed, including the object versions it consumed and wrote
Retained object versionsEarlier object states that a node or archival service still makes available

Our previous program read the coin’s current state. Its previous_transaction field gives us a way to inspect the change that produced that state.

We will read that existing transaction through native gRPC. Its effects identify the coin’s input and output versions. The queried node also returns the corresponding object records, allowing us to compare their contents and ownership.

We will measure two representations returned by the node. contents.value contains the serialized Move value, while bcs.value contains the complete serialized Sui object. In the saved JSON, both byte fields are represented as Base64 strings. We decode them before counting their bytes.

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

import base64
import json
from pathlib import Path

from rpc import rpc

obj = json.loads(Path("evidence/object.json").read_text())["object"]
object_id = obj["object_id"]
digest = obj["previous_transaction"]
result = rpc("GetTransaction", digest=digest, read_mask={"paths": ["*"]})
tx = result["transaction"]
Path("evidence/object-history.json").write_text(json.dumps(result, indent=2) + "\n")

change = next(item for item in tx["effects"]["changed_objects"]
              if item["object_id"] == object_id)
versions = {item["version"]: item
            for item in tx.get("objects", {}).get("objects", [])
            if item["object_id"] == object_id}
if not {change["input_version"], change["output_version"]} <= versions.keys():
    raise RuntimeError("This endpoint did not return both historical object versions")

before = versions[change["input_version"]]
after = versions[change["output_version"]]
assert after["version"] == obj["version"]
assert after["digest"] == obj["digest"]
assert after["owner"] == obj["owner"]

def move_bytes(record):
    return base64.b64decode(record["contents"]["value"], validate=True)

def object_bytes(record):
    return base64.b64decode(record["bcs"]["value"], validate=True)

print("Existing transaction:", tx["digest"])
print("Transaction checkpoint:", tx["checkpoint"])
print("Object ID:", object_id)
print("Stored version change:", before["version"], "->", after["version"])
print("Owner before:", before["owner"]["address"])
print("Owner after:", after["owner"]["address"])
for record in (before, after):
    print(f"Version {record['version']}: {len(move_bytes(record))} Move bytes, "
          f"{len(object_bytes(record))} full object bytes")
print("Move value unchanged:", move_bytes(before) == move_bytes(after))
print("Full object bytes unchanged:", object_bytes(before) == object_bytes(after))

Run:

python3 client/03_inspect_storage.py

Output:

Existing transaction: FT6BDcF3pzwzWUBoNJYiUmbKGu7H1bitHrz5XSDG4jNF
Transaction checkpoint: 398384729
Object ID: 0x846dbd2afd7d7531e7feea369cfecd2586359a7315324ccfffaeea649071362f
Stored version change: 2 -> 3
Owner before: 0x0be16ad0f831bfb346361b5f12658d85847f3481bd915fe785a77a1d34efaf91
Owner after: 0x504366012fb19e3a09b1a22541d54c1e44c9334a65e43316cd0d2e58afba4b73
Version 2: 40 Move bytes, 126 full object bytes
Version 3: 40 Move bytes, 126 full object bytes
Move value unchanged: True
Full object bytes unchanged: False

The object keeps the same ID, but its owner changes. This transaction consumed version 2 and wrote version 3. Those are the versions observed in this example, rather than a rule that every change must increment a version by exactly one.

The two final lines show an important distinction. The coin’s Move value stayed identical: its UID and balance did not change. Its complete object serialization changed because the surrounding runtime state changed.

Both complete representations are 126 bytes long. Equal length does not mean equal contents. We therefore compare the actual bytes as well as measuring their size.

This gives us a concrete view of stored state. A coin consists of more than its balance. Sui also maintains the information needed to identify its state and determine how later transactions can use it. Transaction records and retained versions let us inspect how that state changed.

The 40-byte and 126-byte measurements describe serialized representations returned by the API. They do not measure the node’s physical disk consumption. A node also maintains database structures, indexes, and other records needed to operate and answer queries.

Historical availability is another consideration. This endpoint returned both versions for our inspection. A full node can prune historical records according to its retention policy, while archival services provide access to longer history. Reading the latest object and retrieving every earlier version are therefore different capabilities. See handling pruned data.

The storage fund carries the obligation into future epochs

An object can remain live across many epochs, even as the validator committee changes. Storage charges contribute to Sui’s storage fund, which helps compensate validators for retaining state, including data created before they joined. This connects the payment made when state is stored with the continuing obligation to maintain it.

Eligible changes or removal can return part of the associated storage payment as a rebate. We will examine those calculations in a dedicated post. For now, remember that the 40-byte Move value and 126-byte object we measured describe serialized data. They do not directly tell us the storage charge or the node’s physical disk usage. See Sui’s storage overview.

Where object data lives and what history means

We have inspected an object’s serialized contents and compared two retained versions. These responses show how the node exposes state to a client. The node also needs a physical database representation of that state.

Sui validators and full nodes use local databases, including RocksDB. Serving queries can require additional indexes and retained records. A serialized object’s byte length therefore does not tell us how much disk space the database uses.

We can now distinguish four things:

LayerWhat it is
Move valueThe fields defined by the Move type. Our coin’s UID and balance occupy 40 serialized bytes.
Runtime objectThe Move value together with its type, version, ownership, and other runtime information. Our complete object serialization occupies 126 bytes.
Retained historyEarlier object versions and records describing changes. We compared versions 2 and 3 using the existing transaction’s response.
Physical database storageThe records, keys, indexes, and storage-engine structures maintained by a node. Our API measurements did not measure their disk usage.

Live state and historical records serve different readers

A live object represents state that later transactions can use. Earlier versions show how that state used to look. In our example, version 3 was the coin’s current state, while version 2 showed its state before the transfer. Transaction effects describe the change, and checkpoints organize committed transaction records. Explorers and indexers use these records to build queryable views.

A full node can prune older object versions and historical records according to its retention configuration while keeping the live state needed for operation. Pruning changes what that node retains locally. Deleting a live object through an authorized onchain operation changes the chain’s state. Removing an old local record does not itself trigger a storage rebate (Managing Data).

Our successful comparison means the queried node returned both versions at the time of the request. It does not guarantee that every full node will retain them indefinitely. An application that needs historical versions must account for the serving endpoint’s retention policy. If an endpoint cannot return an old version, that can reflect a retention or service limitation rather than evidence that the version never existed.

Checkpoints, archives, and snapshots have different jobs

PieceJob
CheckpointsOrganize committed transaction records that nodes and data pipelines can consume. Indexers can process these records to build searchable views of objects, transactions, and events.
ArchivesRetain historical records beyond an individual full node’s retention window. If a full node has pruned a record, an application may need to query an archival service explicitly. Full-node gRPC does not automatically fall back to an archive. (handling pruned data).
SnapshotsHelp a new or recovering node obtain a usable starting state. They support bootstrapping and recovery, while archives serve historical lookups. Having a snapshot does not imply having a complete, queryable history of every transaction and object version (Snapshots).

Our Python examples show why these distinctions matter. 01_read_object.py saves the object response to evidence/object.json. Then 03_inspect_storage.py follows its previous_transaction and saves the returned transaction and object records to evidence/object-history.json. These files preserve the data behind our comparison. Rerunning the third program still requires the endpoint to return those historical records, and saving its response does not independently verify consensus.

How much history Chainstack Sui nodes keep. Sui Global Nodes on Chainstack are Extended nodes. They hold a window of checkpoints from lowestAvailableCheckpoint to the tip, and LedgerService/GetServiceInfo reports both ends of the window. gRPC requests for checkpoints, transactions and object versions older than the window return NotFound, so read the floor at runtime and query an archival service for older data. See Checkpoint retention in the Chainstack docs.

Summary

Sui organizes persistent application state around typed objects. An object has an identity, a version, and ownership information, while its Move type defines its application fields. Understanding an object means understanding both its stored value and the rules governing access to it.

Our Move notebook made those rules concrete. We compiled its module, tested an update that preserved its identity, and checked that a capability belonged to the notebook being changed. The rejected copy declaration showed a restriction enforced during compilation, while the capability test demonstrated a check enforced during execution.

SUI connects application activity to the network’s operation through computation payments, storage accounting, and staking. Epochs provide operating periods for the validator committee. The storage fund addresses a continuing obligation: live state can outlast both the transaction that created it and the validators serving at that time.

Our Python programs inspected an existing coin through native gRPC. We decoded its 40-byte Move value and compared two versions of its complete 126-byte object representation. Its application fields stayed the same while its ownership and runtime state changed. We also distinguished current state from retained history, and serialized bytes from physical database usage. Checkpoints, archives, and snapshots support different parts of how that data is recorded, retrieved, and used to operate nodes.

Frequently asked questions

What is an object in Sui?

A Sui object is an instance of a Move struct that has the key ability and id: UID as its first field. The UID gives the instance an identity the network recognizes. The runtime also tracks metadata around the Move value, such as the object’s version, ownership form and the transaction that produced its current state.

How is a Sui object different from an Ethereum or Solana account?

On an EVM chain, a contract account holds code and storage, and the contract gives meaning to its items. On Solana, a program operates on accounts that you pass to each instruction. Sui gives each independently stored Move object its own identity and type, with ownership interpreted by the network, so a transaction can name the objects it needs.

What is the difference between ID and UID in Move?

An ID is a copyable reference to an object’s identity. A UID is the identity-bearing value stored inside the object, and it cannot be copied or dropped by ordinary code. Copying an object’s hexadecimal ID into another program does not create another instance of that object.

What ownership forms can a Sui object have?

An object can be address-owned, shared, immutable or wrapped inside another object. Party objects add restricted access with consensus-managed versioning, and currently support single-owner parties. The form decides how an operation reaches the stored state, while the module’s functions enforce the application rules.

What is SUI used for?

SUI pays for the resources that execution and storage consume, and holders can delegate it to validator staking pools to secure the network. It also acts as an application asset and plays a governance role. Holding a balance does not by itself authorize a privileged Move function.

What is the Sui storage fund?

Storage charges contribute to Sui’s storage fund, which helps compensate validators for retaining state, including data created before they joined. An object can stay live across many epochs, so the fund covers an ongoing cost beyond the computation that created it. Eligible changes or removal can return part of the storage payment as a rebate.

How can I read a Sui object with gRPC?

Call LedgerService/GetObject on a Sui full node with the object ID and a read mask. The response includes the object’s version, owner, Move type, parsed fields, serialized contents and storage rebate. This reads existing state without building, signing or submitting a transaction.

Exit mobile version