Coming from Solidity, Move looks familiar at first: modules, structs, u256, assert! instead of require. The real difference is underneath. In Solidity, a token is a number in a contract's mapping. In Move, a token is a typed value that the language itself won't let you copy or lose. That changes how you store state, how you control access, and which bugs you need to worry about.
Resources and abilities
Every Move struct declares which abilities it has:
| Ability | Allows |
|---|---|
copy | The value can be duplicated. |
drop | The value can be discarded at the end of a scope. |
store | The value can be stored inside other structs in global storage. |
key | The value can be a top-level item in storage (on Sui: an object with an id: UID). |
A struct with neither copy nor drop is a resource: it must end up somewhere, either stored, transferred or explicitly destroyed by its defining module. "Mint twice by accident" and "tokens vanish because a variable went out of scope" become compile-time errors, and the bytecode verifier re-checks this on-chain when a module is published, so hand-written bytecode can't bypass it.
Only the module that defines a struct can create it, destroy it or access its fields.
Storage: two different models
Solidity contracts own their storage: mapping(address => uint256) balances lives inside the token contract. Aptos and Sui both move state out to the owner, in different ways.
Aptos has global storage indexed by address and type. A module publishes a resource under an account with move_to and reads it with borrow_global:
module my_addr::counter {
use std::signer;
const ENOT_INITIALIZED: u64 = 1;
struct Counter has key {
value: u64,
}
public entry fun init(account: &signer) {
move_to(account, Counter { value: 0 });
}
public entry fun increment(account: &signer) acquires Counter {
let addr = signer::address_of(account);
assert!(exists<Counter>(addr), ENOT_INITIALIZED);
let counter = borrow_global_mut<Counter>(addr);
counter.value = counter.value + 1;
}
}