Skip to main content

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
The one thing you’ll implement

You will implement scholarflow::grant::mint yourself. The rest of the package scaffolding is provided here.

What we’re building (mental model first)​

  • An AdminCap struct with key creates a real L1 object when instantiated. We’ll hand this to the package publisher during init, so only they can mint in this workshop.
  • A Grant is an owned object. When you instantiate it, it’s created with a fresh UID and becomes transferable. Our goal is to transfer it to a student.
  • A GrantMinted event captures what happened so indexers and UIs can react to mints.
What is the key ability?
  • key means the struct can live on-chain as an object (it carries a UID). 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).
What is a capability?
  • 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​

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:

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.

What are UID and ID?
  • UID is the unique token stored inside the object that gives it identity; it isn’t copyable and lives only within the object.
  • ID is the canonical identifier value derived from a reference to the object (object::id(&obj)), great for events and lookups.
  • Think: UID is the sealed certificate inside the box; ID is 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);
}
What are init and TxContext?
  • fun init runs once at publish time to bootstrap state (e.g., mint an AdminCap). It cannot be called again.
  • TxContext is 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 Grant object with the intended student and an amount.
  • Emit a GrantMinted event that includes the new object’s ID.
  • Transfer the newly created Grant to the student address.

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;
}
Entry vs public, and transfers
  • Both public and entry functions can be called from programmable transaction blocks (CLI/SDK/PTB). The difference is in constraints and visibility: entry adds stricter rules; public can be called from other modules.
  • Why public entry here: we want to call grant::mint from the CLI as a top‑level transaction while keeping entry‑function constraints (inputs must be direct tx arguments; returns must have drop). Marking it public also leaves room for other modules to reuse mint if needed later.
  • If you don’t need inter‑module reuse, an entry function (without public) is sufficient for CLI calls.
  • When minting/transferring objects you pass a &mut TxContext so 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:

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 │
│ └── │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯
What this means
  • The UpgradeCap is created automatically for your package (keep it if you plan to upgrade in later workshops).
  • The AdminCap is created by your init and transferred to the publisher — this confirms init ran 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​

caution
  • Network: ensure your CLI is connected to Testnet (iota client envs; switch with iota 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.toml and rebuild if you changed named addresses.
  • Make sure you pass the correct ADMIN_CAP_ID to mint.
  • Increase --gas-budget if 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_ID
  • ADMIN_CAP_ID
  • At least one GRANT_OBJECT_ID
  • Publisher and student addresses

Further reading​