Capabilities and Witnesses
SUI Move does not have built-in role-based access control like Solidity's onlyOwner modifier. Instead, access control is built from the type system itself using two patterns: capability objects and typed witnesses.
The Four Abilities
Every struct in Move declares which abilities it has. These abilities are enforced at compile time -- you cannot bypass them at runtime.
key -- Object Identity
A struct with key can exist as a standalone object in SUI's global storage. It must contain an id: UID field:
public struct AdminCap has key, store {
id: UID,
}Objects with key have a unique on-chain address and can be owned by an account, shared, or frozen.
store -- Nestable
A struct with store can be stored inside other objects (as a field or dynamic field). Combined with key, it means the object can also be transferred between owners:
// key + store = transferable top-level object
public struct AdminCap has key, store { id: UID }
// store only = can live inside another object, but not on its own
public struct TribeConfig has drop, store {
tribe: u32,
}copy -- Duplicable
A struct with copy can be implicitly duplicated. This is useful for small value types like keys used to index dynamic fields:
public struct TribeConfigKey has copy, drop, store {}Without copy, every use of a value is a move -- the original variable becomes invalid.
drop -- Discardable
A struct with drop can be silently destroyed when it goes out of scope. Without drop, the compiler forces you to explicitly handle every value -- you cannot just let it disappear:
// This has drop -- it can be created and immediately discarded
public struct XAuth has drop {}
// This does NOT have drop -- you must explicitly destroy it
public struct OnlineReceipt has key {
id: UID,
turret_id: ID,
}The absence of drop is what makes the "hot potato" pattern work (covered in the turret tutorials).
Capability-Based Access Control
The capability pattern uses object ownership to prove authorization. If a function requires a reference to AdminCap, only the account holding that object can call it:
public fun set_rule<K: copy + drop + store, V: store + drop>(
config: &mut ExtensionConfig,
_: &AdminCap, // <-- proves the caller owns AdminCap
key: K,
value: V,
) {
if (df::exists_(&config.id, copy key)) {
let _old: V = df::remove(&mut config.id, copy key);
};
df::add(&mut config.id, key, value);
}Notice the _: &AdminCap parameter. The function does not even use the value -- it only needs proof that the caller has it. The underscore is idiomatic Move for "I need this type but not the value."
The AdminCap is created once during init and transferred to the deployer:
fun init(ctx: &mut TxContext) {
let admin_cap = AdminCap { id: object::new(ctx) };
transfer::transfer(admin_cap, ctx.sender());
// ...
}From that point on, whoever holds the AdminCap object can perform admin operations. Transfer it to a multisig address, and you have multisig-gated admin access. Destroy it, and admin operations become impossible forever.
The Typed Witness Pattern
While capabilities work for "who can do this?" authorization, the typed witness pattern answers a different question: "which extension is authorized to do this?"
In EVE Frontier, Smart Storage Units (SSUs) allow third-party extensions to manipulate inventories. But the SSU needs to know which extensions are authorized. This is solved with a zero-sized witness type:
/// Zero-sized typed witness for SSU extension authorization.
public struct XAuth has drop {}
/// Create an XAuth witness value. Only callable within this package.
public(package) fun x_auth(): XAuth {
XAuth {}
}Why This Works
XAuth is a type -- it exists as tribe_storage_access::config::XAuth. No other package can create this exact type.x_auth() is public(package) -- only modules within tribe_storage_access can call it. External code cannot mint XAuth {} values.XAuth has no fields. It carries no data. It exists purely as a type-level proof.When the SSU owner authorizes an extension, they register the XAuth type:
// The SSU owner calls this (via PTB):
storage_unit::authorize_extension<XAuth>(ssu, owner_cap);Now the SSU's gated functions require an XAuth witness value as proof:
storage_unit::deposit_to_open_inventory<XAuth>(
storage_unit,
character,
item,
config::x_auth(), // <-- witness value proving authorization
ctx,
);The SUI runtime verifies that the type parameter XAuth matches what was authorized. Since only our package can produce XAuth {} values, only our package can call these gated functions.
Capabilities vs. Witnesses
| Pattern | Question Answered | How It Works |
Capability (AdminCap) | "Who is calling?" | Caller must own the cap object |
Witness (XAuth) | "Which code is calling?" | Function must produce the witness type |
Both patterns are used together in the tribe storage extension:
AdminCapgates admin configuration (setting tribe ID, adding rules).XAuthgates inventory operations (depositing to open inventory, withdrawing items).
Real-World Example: Both Patterns Combined
Here is how share() uses both patterns in a single flow:
public fun share<T: key>(
extension_config: &ExtensionConfig, // shared config (no auth needed to read)
storage_unit: &mut StorageUnit,
character: &Character,
owner_cap: &OwnerCap<T>, // capability: proves player identity
type_id: u64,
quantity: u32,
ctx: &mut TxContext,
) {
verify_tribe(extension_config, character); // business logic check
let item = storage_unit::withdraw_by_owner(
storage_unit, character, owner_cap, // uses capability
type_id, quantity, ctx,
);
storage_unit::deposit_to_open_inventory<XAuth>(
storage_unit, character, item,
config::x_auth(), // uses witness
ctx,
);
}The owner_cap parameter is a capability proving the caller's identity. The config::x_auth() call produces a witness proving the extension's authorization. Together, they ensure that only authorized tribe members using an authorized extension can move items.
Key Takeaways
- SUI Move uses abilities (
key,store,copy,drop) as compile-time constraints on what you can do with a type. - The capability pattern uses object ownership for access control -- hold the
AdminCapto perform admin actions. - The typed witness pattern uses zero-sized
has dropstructs for extension authorization -- only the package that defines the type can instantiate it. public(package)visibility is essential for witnesses -- it prevents external packages from minting your authorization tokens.- Both patterns compose naturally: capabilities prove identity, witnesses prove code authorization.
Sign in to track your progress.