Capability Pattern
The Move capability pattern enables address-owned objects to act as authorization tokens in Move. In this example, an AdminCap object gates who can create and transfer Hero objects, and existing admins can delegate authority by minting new AdminCap instances for other addresses.
When to use this pattern
Use this pattern when you need to:
-
Restrict who can call sensitive functions (minting, configuration, withdrawals) without hardcoding addresses.
-
Delegate authority to multiple accounts rather than locking admin access to a single deployer.
-
Revoke access by destroying or transferring the capability object, instead of managing an onchain allowlist.
-
Compose authorization by requiring multiple capabilities for a single operation (for example, an
AdminCapand aMintCap). -
Avoid the singleton constraint of the
Publisherpattern when more than 1 account needs admin rights.
What you learn
This example teaches:
-
Capability objects: A struct with the
keyability that acts as a permission token. Holding the object proves authorization. You do not need an onchain list or mapping. -
Reference-based gating: Functions accept
&AdminCap(an immutable reference) as a parameter. The caller must own the object to pass it, but the function does not consume it. -
Delegation: An existing capability holder can create new capability objects and transfer them to other addresses, granting those addresses the same privileges.
-
Init-time issuance: The
initfunction creates the firstAdminCapand transfers it to the deployer. This is the only way the first capability enters circulation.
Architecture
In this example, the deployer receives the initial AdminCap when the module is published. That capability gates 3 operations: creating heroes, transferring heroes, and delegating admin access. The deployer can call new_admin to mint a second AdminCap for another address, giving them the same privileges. Any AdminCap holder can create a Hero and transfer it to a user.
The diagram below traces the delegation and hero creation flow.
The following steps walk through the flow:
-
The deployer publishes the module. The
initfunction createsAdminCap #1and transfers it to the deployer. -
The deployer calls
new_adminwith a reference to theirAdminCapand Admin2's address. The function mintsAdminCap #2and transfers it to Admin2. -
Admin2 calls
create_herowith a reference to theirAdminCap. The function creates a newHeroand returns it. -
Admin2 calls
transfer_herowith theirAdminCap, theHero, and a user address. The function transfers the hero to the user.
Access control fails if the caller does not own an AdminCap. Move's type system enforces this at transaction construction time, because the caller cannot produce a &AdminCap reference without owning one.
Prerequisites
- Prerequisites
-
Download and install an IDE. The following are recommended, as they offer Move extensions:
-
VSCode, corresponding Move extension
-
Emacs, corresponding Move extension
-
Vim, corresponding Move extension
-
Zed, corresponding Move extension
Alternatively, you can use the Move web IDE, which does not require a download. It does not support all functions necessary for this guide, however.
-
-
Node.js 18 or later
Setup
Follow these steps to set up the example locally.
Step 1: Clone the repo
$ git clone -b solution https://github.com/MystenLabs/sui-move-bootcamp.git
$ cd sui-move-bootcamp/C1/capability
Step 2: Build and test
$ rm Move.lock
$ sui move build
$ sui move test
All 4 tests should pass, confirming the capability pattern works for initialization, hero creation, hero transfer, and admin delegation.
Step 3: Publish to Testnet (optional)
$ sui client switch --env testnet
$ sui client publish --gas-budget 200000000
The init function automatically creates and transfers the AdminCap to your address. Take note of your package's ID and the object ID:
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x88b18141ae40ab392479f2046ba4e93b27f22a2d1e031dcf01d4a4d37650f917 <---- Object ID │
│ │ Sender: 0x9ac241b2b3cb87ecd2a58724d4d182b5cd897ad307df62be2ae84beddc9d9803 │
│ │ Owner: Account Address ( 0x9ac241b2b3cb87ecd2a58724d4d182b5cd897ad307df62be2ae84beddc9d9803 ) │
│ │ ObjectType: 0x18f1a5f9f0fd5e9a903477fdfc8160979aa8b0a0791bf3308143240577a24c83::hero::AdminCap │ <-- Note Object Type of hero::AdminCap
│ │ Version: 847518299 │
│ │ Digest: HLrKjEFdQ6T7JkwTNrAPyMXS3cvEjVbwKR6QYr3oNYbc
...
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x18f1a5f9f0fd5e9a903477fdfc8160979aa8b0a0791bf3308143240577a24c83 <---- Package ID │
│ │ Version: 1 │
│ │ Digest: 2yoR5uP5iMf3gjvoUxsehfLttNf42cxP4F2JLbdGtt22 │
│ │ Modules: hero │
│ └── │
╰──────────────────────────
Run the example
After publishing, verify you received the AdminCap:
$ sui client objects
You should see an object of type capability::hero::AdminCap in your owned objects.
To create a hero and transfer it to another address, replace PACKAGE_ID with your published package ID, ADMIN_CAP_ID with the object ID to use AdminCap to create a hero, and replace RECIPIENT_ADDRESS with your destination address.
$ sui client ptb --move-call '@OBJECT_ID::hero::create_hero' @PACKAGE_ID '"My Hero"' --assign hero --move-call 'OBJECT_ID::hero::transfer_hero' @PACKAGE_ID hero @RECIPIENT_ADDRESS --gas-budget 10000000
The transaction creates a Hero object owned by your address, then delegates admin access to another address.
Key code highlights
Destroying or transferring a capability is not sufficient revocation if the holder is compromised or uncooperative. You cannot force someone else's object to be deleted, and a compromised holder can act before you react. For robust revocation, gate privileged functions on a registry (an allowlist or set of valid capability IDs the module checks) or a version/epoch field that you can bump to invalidate outstanding caps. Also plan for capability rotation, hold high-value caps in multisig custody, and emit events for privileged actions so you can audit them. See Access control.
Common modifications
-
Add a
MintCapseparate fromAdminCap: Split creation and administration into 2 capabilities. The deployer holdsAdminCapfor configuration, and minters holdMintCapfor hero creation. This follows the principle of least privilege. -
Gate with multiple capabilities: Require both an
AdminCapand aTreasuryKeyto call a withdrawal function. The caller must hold 2 separate objects, which enables multi-sig-like authorization at the application layer. -
Add
storeability toAdminCap: This lets the cap be wrapped inside other objects, enabling patterns like time-locked capabilities or escrow.
Troubleshooting
The following sections address common issues with this example.
Cannot call create_hero because AdminCap is missing
Symptom: The transaction fails with an error about not being able to provide the AdminCap argument.
Cause: Your address does not own an AdminCap. Either you are not the deployer, or no admin delegated a cap to you.
Fix: Ask an existing admin to call new_admin with your address. Or re-publish the module from your address to receive the initial cap.
Test fails with has_most_recent_for_address returned false
Symptom: The test_publisher_address_gets_admin_cap test fails.
Cause: The init function did not run, or ran with a different sender address than the test expects.
Fix: Verify the test uses the same address constant (ADMIN) as the ts::begin(ADMIN) call. The init function transfers the cap to ctx.sender(), which is the address passed to begin.
Hero has key but not store, cannot wrap it
Symptom: Trying to wrap a Hero inside another object fails with an ability constraint error.
Cause: Hero has key but the code shown does not include store. Without store, the object cannot be placed inside another object's fields.
Fix: Add store to the Hero struct abilities if you need to wrap it: public struct Hero has key, store { ... }. The example already includes store on Hero for this reason.