Workshop I Practical — IOTA Move Foundations
You’ve seen the slides. Now let’s make Move objects come alive.
In this workshop, you’ll ship a tiny “ScholarFlow” package that mints Grant objects for students. Along the way you’ll:
- Publish a Move package to IOTA
- Receive and inspect an admin capability object
- Design an owned object and an event
- Implement a mint flow that turns a function call into a new on-chain object owned by a student
You will implement scholarflow::grant::mint yourself. The rest of the package scaffolding is provided here.
What we’re building (mental model first)
- An
AdminCapstruct withkeycreates a real L1 object when instantiated. We’ll hand this to the package publisher duringinit, so only they can mint in this workshop. - A
Grantis an owned object. When you instantiate it, it’s created with a freshUIDand becomes transferable. Our goal is to transfer it to a student. - A
GrantMintedevent captures what happened so indexers and UIs can react to mints.
keymeans the struct can live on-chain as an object (it carries aUID). Instantiating it is like minting a serialized item with an owner.- Think of a boxed laptop with a serial number and a shipping label (owner address).
- A capability (like
AdminCap) is a permission token; callers must present it (e.g.,&AdminCap) to do privileged actions. - Like an office badge that opens a specific door; no badge, no entry.
- Learn more: Capabilities pattern
If these concepts are new, skim: UID and ID and Using Events.
Prerequisites
- IOTA CLI installed and connected: Install, Connect
- A funded account: Get Test Tokens
- Optional: a second address to act as the student
Create a working directory:
mkdir -p ~/scholarflow && cd ~/scholarflow
Scaffold the package
iota move new scholarflow_core
cd scholarflow_core
And a minimal Move.toml:
[package]
name = "scholarflow_core"
version = "0.0.1"
edition = "2024"
[addresses]
scholarflow = "0x0"
iota = "0x2"
std = "0x1"
Useful references while you build:
- CLI usage: IOTA Move CLI
- Concepts: Module Initializers
Model the world in Move (small steps)
Create sources/grant.move. Start with the module, imports, and the three data types. Don’t worry about functions yet.
module scholarflow::grant {
use iota::object::{Self, UID, ID};
use iota::tx_context::{Self, TxContext};
use iota::transfer;
use iota::event;
/// Capability granting authority to mint grants.
public struct AdminCap has key { id: UID }
/// An owned grant object assigned to a student.
public struct Grant has key {
id: UID,
student: address,
amount: u64,
}
/// Emitted when a grant is minted.
public struct GrantMinted has copy, drop, store {
student: address,
amount: u64,
grant_id: ID,
}
}
Notice how both AdminCap and Grant have the key ability. Instantiating either will create an object with a UID you can later transfer.
UIDis the unique token stored inside the object that gives it identity; it isn’t copyable and lives only within the object.IDis the canonical identifier value derived from a reference to the object (object::id(&obj)), great for events and lookups.- Think:
UIDis the sealed certificate inside the box;IDis the serial number on the receipt. - Learn more: UID and ID
Give someone the keys (module initializer)
Now, let’s make sure the package publisher receives the admin capability when the package is published.
Append this function inside the same module:
/// Runs once at package publish. Transfers AdminCap to the publisher.
fun init(ctx: &mut TxContext) {
let cap = AdminCap { id: object::new(ctx) };
let publisher = tx_context::sender(ctx);
transfer::transfer(cap, publisher);
}
fun initruns once at publish time to bootstrap state (e.g., mint anAdminCap). It cannot be called again.TxContextis the per‑transaction context: it gives you the sender and lets you create fresh object IDs (object::new(ctx)).- Like a deployment migration that seeds initial rows and grants the first admin.
- Learn more: Module Initializers
Quick checkpoint:
iota move build
If you hit snags, see Build, Test, Debug and the Move CLI.
The mint you’ll write
Here’s the story your mint function should tell when it runs:
- Create a fresh
Grantobject with the intendedstudentand anamount. - Emit a
GrantMintedevent that includes the new object’sID. - Transfer the newly created
Grantto thestudentaddress.
We’ll leave the full body for you to implement. Start with the signature and a placeholder, then iterate.
Append this inside the same module:
public entry fun mint(
student: address,
amount: u64,
_cap: &AdminCap,
ctx: &mut TxContext
) {
// TODO: Create Grant, emit event, transfer to `student`.
// Hints: object::new(ctx), object::id(&grant), transfer::transfer(grant, student)
abort 0;
}
- Both
publicandentryfunctions can be called from programmable transaction blocks (CLI/SDK/PTB). The difference is in constraints and visibility:entryadds stricter rules;publiccan be called from other modules. - Why
public entryhere: we want to callgrant::mintfrom the CLI as a top‑level transaction while keeping entry‑function constraints (inputs must be direct tx arguments; returns must havedrop). Marking itpublicalso leaves room for other modules to reusemintif needed later. - If you don’t need inter‑module reuse, an
entryfunction (withoutpublic) is sufficient for CLI calls. - When minting/transferring objects you pass a
&mut TxContextso the runtime can create IDs and record effects. transfer::transfer(obj, recipient)moves ownership of the on‑chain object to an address. One owner at a time, enforced by Move.- Learn more: Entry Functions • Object Ownership
Helpful references while implementing:
- Object IDs and
UID: UID and ID - Events: Using Events
- Entry functions: Entry Functions
Publish the package (and receive your cap)
iota client publish --gas-budget 100000000
Expected output (truncated example):
╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├─────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x<upgrade_cap_id> │
│ │ Sender: 0x<sender_address> │
│ │ Owner: Account Address ( 0x<sender_address> ) │
│ │ ObjectType: 0x2::package::UpgradeCap │
│ │ Version: <n> │
│ │ Digest: <digest> │
│ └── │
│ ┌── │
│ │ ObjectID: 0x<admin_cap_object_id> │
│ │ Sender: 0x<sender_address> │
│ │ Owner: Account Address ( 0x<sender_address> ) │
│ │ ObjectType: 0x<package_id>::grant::AdminCap │
│ │ Version: <n> │
│ │ Digest: <digest> │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0x<gas_coin_object_id> │
│ │ Sender: 0x<sender_address> │
│ │ Owner: Account Address ( 0x<sender_address> ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::iota::IOTA> │
│ │ Version: <n> │
│ │ Digest: <digest> │
│ └── │
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x<package_id> │
│ │ Version: 1 │
│ │ Digest: <digest> │
│ │ Modules: grant │
│ └── │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯
- The UpgradeCap is created automatically for your package (keep it if you plan to upgrade in later workshops).
- The AdminCap is created by your
initand transferred to the publisher — this confirmsinitran as expected. - Your gas coin is mutated to pay for the transaction; the package is published with its on-chain
PackageID.
Export the ADMIN_CAP_ID for later commands (copy from Created Objects above):
export ADMIN_CAP_ID=0x<admin_cap_object_id>
Capture what publish returns and set environment vars for convenience:
export PKG_ID=<paste Package ID>
export ADMIN_CAP_ID=<paste AdminCap object ID>
export PUBLISHER_ADDR=<your publisher 0x...>
iota client objects --owner "$PUBLISHER_ADDR"
iota client object --id "$ADMIN_CAP_ID"
More context: Publish a Package
Try your mint (after you implement it)
export STUDENT_ADDR=<0x...student address...>
export AMOUNT=1000
iota client call \
--package $PKG_ID \
--module grant \
--function mint \
--args $STUDENT_ADDR $AMOUNT $ADMIN_CAP_ID \
--gas-budget 100000000
Expected output (events excerpt):
╭──────────────────────────────────────────────── ──────────────────────────────────────────────────────╮
│ Transaction Block Events │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌── │
│ │ EventID: <event_id> │
│ │ PackageID: 0x<package_id> │
│ │ Transaction Module: grant │
│ │ Sender: 0x<sender_address> │
│ │ EventType: 0x<package_id>::grant::GrantMinted │
│ │ ParsedJSON: │
│ │ ┌──────────┬────────────────────────────────────────────────────────────────────┐ │
│ │ │ amount │ <amount> │ │
│ │ ├──────────┼────────────────────────────────────────────────────────────────────┤ │
│ │ │ grant_id │ 0x<grant_object_id> │ │
│ │ ├──────────┼────────────────────────────────────────────────────────────────────┤ │
│ │ │ student │ 0x<student_address> │ │
│ │ └──────────┴────────────────────────────────────────────────────────────────────┘ │
│ └── │
Verify what happened
iota client objects --owner "$STUDENT_ADDR"
iota client object --id <GRANT_OBJECT_ID>
This confirms that a Grant object now exists and is owned by the student. For events, use your indexer of choice and the Using Events patterns. Ownership concepts recap: Object Ownership.
Handy script (optional)
#!/usr/bin/env bash
set -euo pipefail
: "${PKG_ID:?set PKG_ID}"
: "${ADMIN_CAP_ID:?set ADMIN_CAP_ID}"
: "${STUDENT_ADDR:?set STUDENT_ADDR}"
AMOUNT="${AMOUNT:-1000}"
iota client call \
--package "$PKG_ID" \
--module grant \
--function mint \
--args "$STUDENT_ADDR" "$AMOUNT" "$ADMIN_CAP_ID" \
--gas-budget 100000000
What we learned
- Summary of what we built.
- To further practise, we suggest trying X,Y (Extension tasks)
- Add hints or extension tasks with the objective of deepening understanding.
Common pitfalls
- Network: ensure your CLI is connected to Testnet (
iota client envs; switch withiota client switch --env testnet). - Funding: request test tokens via faucet before publishing/calling (
iota client faucet --address $PUBLISHER_ADDR). - First publish may rewrite addresses — re-check
Move.tomland rebuild if you changed named addresses. - Make sure you pass the correct
ADMIN_CAP_IDtomint. - Increase
--gas-budgetif you see out-of-gas failures. - Double-check signer and argument order for
iota client call.
Bring this to Workshop II
Keep these handy:
PKG_IDADMIN_CAP_ID- At least one
GRANT_OBJECT_ID - Publisher and student addresses