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;
}
}
move_to needs a &signer, so only the account holder can put resources under their address. Aptos also has an object model and a Fungible Asset standard built on it, which newer code generally uses for tokens and NFTs.
Sui is object-centric and has no global move_to/borrow_global. Every stored thing is an object with a unique UID, and every object is address-owned, shared, immutable, or wrapped inside another object. Transactions name the objects they touch up front, and the runtime checks that the sender owns any owned object passed in.
module counter::counter;
const ENotOwner: u64 = 0;
public struct Counter has key {
id: UID,
owner: address,
value: u64,
}
public fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: 0,
});
}
public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
public fun reset(counter: &mut Counter, ctx: &TxContext) {
assert!(counter.owner == ctx.sender(), ENotOwner);
counter.value = 0;
}
(That's Move 2024 edition syntax, which current Sui tooling uses by default.)
Access control: signers and capabilities
There's no msg.sender global. On Aptos, a function that needs the caller's authority takes &signer, which only the transaction can supply. On Sui, you read ctx.sender(), but the idiomatic tool is a capability object:
module fees::config;
public struct AdminCap has key { id: UID }
public struct Config has key {
id: UID,
fee: u64,
}
fun init(ctx: &mut TxContext) {
transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender());
transfer::share_object(Config { id: object::new(ctx), fee: 0 });
}
public fun set_fee(_: &AdminCap, config: &mut Config, fee: u64) {
config.fee = fee;
}
Only whoever owns the AdminCap can pass it, so only they can call set_fee. Ownership replaces onlyOwner modifiers.
No classic reentrancy
Solidity reentrancy works because an external call can hand control to arbitrary code that calls back into you mid-update. Move calls are statically resolved: you call a known function in a known module, and resources can't be borrowed mutably twice. The DAO-style callback attack doesn't exist in the core language. Aptos has since added some dynamic dispatch features, with runtime reentrancy checks; if you use them, read how they restrict re-entry.
Flash loans use this in reverse: the "hot potato" pattern returns a struct with no abilities alongside the borrowed funds. It can't be stored or dropped, so the transaction can only succeed if it's passed back to the repay function in the same transaction.
Upgrades
Solidity upgrades usually mean proxies. Move doesn't need them, but each chain differs:
- Aptos upgrades packages in place at the same address, under an upgrade policy (
compatibleorimmutable). Compatible upgrades can't change existing struct layouts, or remove or change the signatures of public functions. - Sui packages are immutable. An upgrade publishes a new version with a new ID, authorized by an
UpgradeCap, and the old version stays callable. If an upgrade fixes a bug, add a version field to your shared objects and check it, or attackers can keep calling the old code.
On both, a public function is permanent API. Use public(package) (Sui) or friend/package visibility (Aptos) for anything internal.
What still goes wrong
Move removes some bug classes, not all of them:
- Missing access control on shared objects. On Sui anyone can pass a shared object to any public function that takes it. The ownership check only protects owned objects.
- Not linking objects. A function takes a
Pooland aPoolCapbut never checks that the cap belongs to that pool. - Generic type confusion. A function generic over
Taccepts aCoin<T>of the wrong type unless its signature tiesTto the pool's type. - Careless abilities. Giving a receipt
dropbreaks the hot potato. Giving a capabilitystorelets anyone who holds it transfer or wrap it. - Arithmetic. Overflow aborts, as in Solidity 0.8, but division truncates and shifts silently drop bits. A flawed overflow check in a shift helper was at the root of the 2025 Cetus exploit on Sui.
Summary
| Solidity | Move |
|---|---|
| Balances in a contract's mapping | Typed resources held by owners |
msg.sender, onlyOwner | &signer, TxContext, capability objects |
| Reentrancy guards | Static calls, no dynamic re-entry by default |
| Proxy upgrades | Package upgrades (in place on Aptos, versioned on Sui) |
require with strings | assert! with abort codes |
Read Move code by asking: what are the abilities of every asset and capability, who can obtain each object, and which public functions accept shared state?
Get the weekly commit
New blockchain deep dives every week.