Fungible token
ERC-20 keeps every balance in one contract mapping; nobody holds anything. On Sui, Coin<T> objects are the balances and holders own them directly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @title 01 — Fungible token (ERC-20)
/// @notice The single most deployed pattern on EVM chains (OpenZeppelin Tokens:
/// 150k+ verified deployments). All balances live in ONE contract as a
/// `mapping(address => uint256)`; holders never custody anything.
/// @dev Sui counterpart: `patterns/move/patterns/sources/fungible_token.move` —
/// `coin::create_currency` mints `Coin<T>` objects that holders own
/// directly; there is no balances mapping to read or corrupt.
contract PatternToken is ERC20, Ownable {
constructor(address admin) ERC20("Pattern Token", "PTRN") Ownable(admin) {}
/// The `onlyOwner` mint gate is the piece Sui replaces with possession of
/// the `TreasuryCap` object — an unforgeable capability, not an if-check.
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
}/// ERC-20 equivalent.
///
/// Solidity habit: an ERC-20 is one contract that owns a `mapping(address =>
/// uint256) balances` and a `totalSupply`. Every transfer mutates that shared
/// map, so every transfer contends on the same storage slot.
///
/// Sui idiom: there is no balances mapping. A fungible currency is a *type*
/// `FUNGIBLE_TOKEN`, and each holder owns `Coin<FUNGIBLE_TOKEN>` objects that
/// literally ARE the balance. Transfers move objects between owners on the fast
/// path, so unrelated transfers never contend. Minting authority is a
/// `TreasuryCap<T>` object (a capability) rather than an `onlyOwner` modifier.
module patterns::fungible_token;
use sui::coin::{Self, TreasuryCap};
/// The One-Time Witness: a struct named exactly like the module, uppercased.
/// The Sui VM instantiates exactly one value of this type at publish and hands
/// it to `init`, guaranteeing the currency is registered once and only once.
public struct FUNGIBLE_TOKEN has drop {}
/// Runs once at publish. `create_currency` consumes the OTW and returns the
/// mint/burn authority plus the immutable metadata object.
///
/// Note: the newer Currency Standard (`sui::coin_registry::new_currency_with_otw`)
/// supersedes `coin::create_currency`; we use the classic call here because it is
/// the one Solidity devs meet first. `#[allow(deprecated_usage)]` keeps the lesson
/// noise-free.
#[allow(deprecated_usage)]
fun init(witness: FUNGIBLE_TOKEN, ctx: &mut TxContext) {
let (treasury, metadata) = coin::create_currency(
witness,
6, // decimals
b"GOLD",
b"Gold Token",
b"An ERC-20-style fungible token on Sui",
option::none(),
ctx,
);
// Metadata is frozen (read-only forever); the TreasuryCap goes to the
// publisher, who now holds the sole right to mint and burn.
transfer::public_freeze_object(metadata);
transfer::public_transfer(treasury, ctx.sender());
}
/// `mint` is gated by *ownership of the TreasuryCap*, not a role check. Whoever
/// holds `&mut TreasuryCap` is the minter; there is nothing else to verify.
public fun mint(
treasury: &mut TreasuryCap<FUNGIBLE_TOKEN>,
amount: u64,
recipient: address,
ctx: &mut TxContext,
) {
let coin = coin::mint(treasury, amount, ctx);
transfer::public_transfer(coin, recipient);
}
/// Burning consumes a Coin object; supply drops by its value. No allowance
/// dance — you can only burn coins you own and pass in.
public fun burn(treasury: &mut TreasuryCap<FUNGIBLE_TOKEN>, coin: coin::Coin<FUNGIBLE_TOKEN>) {
coin::burn(treasury, coin);
}NFT
One contract per ERC-721 collection, metadata behind a URI. On Sui each NFT is a first-class object and Display renders metadata natively.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC721URIStorage, ERC721} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @title 02 — NFT (ERC-721)
/// @notice One contract per collection; token ownership is a row in the
/// contract's `_owners` mapping and metadata is a URI the contract
/// points at. Marketplaces must be trusted to honor royalties.
/// @dev Sui counterpart: `nft.move` — every NFT is a first-class object with
/// its own on-chain ID and fields; `Display` renders metadata natively
/// and royalty policies are enforced by the chain (kiosk), not goodwill.
contract PatternNFT is ERC721URIStorage, Ownable {
uint256 private _nextId;
constructor(address admin) ERC721("Pattern NFT", "PNFT") Ownable(admin) {}
function mint(address to, string calldata uri) external onlyOwner returns (uint256 id) {
id = ++_nextId;
_safeMint(to, id);
_setTokenURI(id, uri);
}
}/// ERC-721 equivalent.
///
/// Solidity habit: an ERC-721 is one contract holding `mapping(uint256 =>
/// address) owners` and `mapping(uint256 => string) tokenURIs`. A "token" is
/// just a row keyed by `tokenId`; it has no independent existence.
///
/// Sui idiom: there is no collection contract and no owner mapping. Each NFT is
/// a first-class object with its own `UID`; ownership is a property of the
/// object recorded by the runtime, not a row we maintain. `display::Display`
/// tells wallets/explorers how to render every object of this type at once,
/// replacing per-token `tokenURI` strings.
///
/// Mint authority follows the SAME capability rule as the fungible-token snippet:
/// the Solidity pair gates `mint` with `onlyOwner`, and here that gate is
/// possession of a `MinterCap` object — the NFT analogue of `TreasuryCap`. Sui
/// removes the owner *mapping*, not the mint *authorization*; leaving `mint`
/// open would let anyone inflate the collection.
module patterns::nft;
use std::string::{Self, String};
use sui::display;
use sui::package;
/// The NFT itself. `key + store` means it is a standalone object that can also
/// be wrapped, traded in a kiosk, or held in another object.
public struct Nft has key, store {
id: UID,
name: String,
image_url: String,
}
/// Mint authority. Holding this object is the entire authorization proof for
/// `mint` — the capability equivalent of ERC-721's `onlyOwner` gate, mirroring
/// `TreasuryCap` in the fungible-token snippet. Transfer it to hand over minting
/// rights; there is no owner address to compare.
public struct MinterCap has key, store { id: UID }
/// OTW so we can claim the `Publisher` needed to configure Display.
public struct NFT has drop {}
fun init(otw: NFT, ctx: &mut TxContext) {
let publisher = package::claim(otw, ctx);
// One Display object describes rendering for *every* Nft. The `{name}` and
// `{image_url}` templates read fields off each object at query time.
let mut disp = display::new<Nft>(&publisher, ctx);
disp.add(string::utf8(b"name"), string::utf8(b"{name}"));
disp.add(string::utf8(b"image_url"), string::utf8(b"{image_url}"));
disp.update_version();
// The publisher receives sole minting authority.
transfer::public_transfer(MinterCap { id: object::new(ctx) }, ctx.sender());
transfer::public_transfer(publisher, ctx.sender());
transfer::public_transfer(disp, ctx.sender());
}
/// Mint = allocate a fresh object and transfer it. No global counter, no
/// `_mint` bookkeeping — the object's `UID` is its unique id. The `&MinterCap`
/// parameter is the guard: you cannot call this without holding the cap, and the
/// compiler enforces it — no `require`, no sender check. Declared `entry` (not
/// `public`) so it is PTB-callable but not composable from other packages — the
/// idiomatic form for a leaf mint.
entry fun mint(
_cap: &MinterCap,
name: vector<u8>,
image_url: vector<u8>,
recipient: address,
ctx: &mut TxContext,
) {
let nft = Nft {
id: object::new(ctx),
name: string::utf8(name),
image_url: string::utf8(image_url),
};
transfer::transfer(nft, recipient);
}Access control
Ownable/AccessControl gate on "is msg.sender on the list?". Sui checks possession of a capability object — OpenZeppelin Contracts for Sui layers familiar roles on top.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
/// @title 03 — Access control (Ownable / AccessControl)
/// @notice #3 most-deployed OZ category (115k+). Every privileged path is an
/// identity check: "is msg.sender on the list?" — the list being a
/// storage slot an attacker only needs one bug to rewrite.
/// @dev Sui counterpart: `access_control.move` — authority is an OBJECT you
/// hold (`AdminCap`), checked by the type system at call time. The
/// OZ-for-Sui `openzeppelin_access` package layers familiar role-based
/// semantics (and two-step transfer) on top of that capability model.
/// Single-admin flavor. `Ownable2Step` = transfer must be accepted by the
/// recipient, preventing ownership burns via typo'd addresses.
contract OwnedVault is Ownable2Step {
uint256 public parameter;
constructor(address admin) Ownable(admin) {}
function setParameter(uint256 value) external onlyOwner {
parameter = value;
}
}
/// Multi-role flavor: separate roles per privilege, admin can grant/revoke.
contract RoleGuardedVault is AccessControl {
bytes32 public constant SETTER_ROLE = keccak256("SETTER_ROLE");
bytes32 public constant DRAINER_ROLE = keccak256("DRAINER_ROLE");
uint256 public parameter;
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function setParameter(uint256 value) external onlyRole(SETTER_ROLE) {
parameter = value;
}
function drain(address payable to) external onlyRole(DRAINER_ROLE) {
to.transfer(address(this).balance);
}
receive() external payable {}
}/// Ownable / AccessControl equivalent — shown two ways.
///
/// Solidity habit: `Ownable` stores an `owner` address and guards functions
/// with `onlyOwner`; `AccessControl` keeps `mapping(bytes32 => RoleData)` and
/// guards with `onlyRole(ROLE)`. Both compare `msg.sender` against stored state.
///
/// Sui idiom: authority is an *object you hold*, not an address you match.
/// (a) The native one-liner: an `AdminCap` capability object. Owning it IS the
/// permission — no address comparison, no storage read.
/// (b) When you need many named roles with on-chain grant/revoke, reach for
/// OpenZeppelin's `access_control`, which Solidity devs already know.
module patterns::access_control;
use openzeppelin_access::access_control::{Self, AccessControl, Auth};
// =========================================================================
// (a) Native capability pattern — the idiomatic "Ownable"
// =========================================================================
/// Holding this object is the entire authorization proof. Transfer it to hand
/// over ownership; there is no `transferOwnership` bookkeeping to write.
public struct AdminCap has key, store { id: UID }
/// A privileged action. The `&AdminCap` parameter is the guard: you cannot call
/// this without owning the cap, and the compiler enforces it. No `require`.
public fun admin_only_action(_cap: &AdminCap): u64 {
42
}
// =========================================================================
// (b) Role-based access with OpenZeppelin AccessControl
// =========================================================================
/// The OTW doubles as OpenZeppelin's *root role* (its default admin role).
public struct ACCESS_CONTROL has drop {}
/// An extra role, defined in this same module (OZ requires home-module roles).
public struct MinterRole {}
fun init(otw: ACCESS_CONTROL, ctx: &mut TxContext) {
// Native side: mint the AdminCap and give it to the publisher.
transfer::public_transfer(AdminCap { id: object::new(ctx) }, ctx.sender());
// OZ side: stand up the role registry. `new` makes the sender the default
// admin (root role), with a 1-day timelock on future admin transfers.
let mut registry = access_control::new(otw, 86_400_000, ctx);
// The root admin grants itself MinterRole so it can mint auth witnesses.
registry.grant_role<_, MinterRole>(ctx.sender(), ctx);
transfer::public_share_object(registry);
}
/// Grant MinterRole to `account`. Caller must already hold the role's admin
/// (the root role) — OZ checks that inside `grant_role`.
public fun grant_minter(registry: &mut AccessControl<ACCESS_CONTROL>, account: address, ctx: &mut TxContext) {
registry.grant_role<_, MinterRole>(account, ctx);
}
/// A role-gated action. `Auth<MinterRole>` is an unforgeable proof minted only
/// by `new_auth` to an address that currently holds MinterRole.
public fun minter_only_action(_auth: &Auth<MinterRole>): u64 {
7
}Upgradeability
UUPS proxies delegatecall an implementation so code changes while storage stays — append-only forever. Sui upgrades packages natively; the chain enforces layout compatibility.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
/// @title 04 — Upgradeable contract (UUPS proxy)
/// @notice Upgradeability on EVM is a workaround: an ERC-1967 proxy
/// `delegatecall`s an implementation, so code can change while state
/// stays put. The cost: constructors don't run (initializers instead),
/// storage layout is append-only forever, and a storage collision
/// bricks the contract. 98.24% of upgradeable proxies never upgrade.
/// @dev Sui counterpart: `versioned.move` — package upgrades are NATIVE and
/// `UpgradeCap`-mediated; the chain enforces layout compatibility, no
/// delegatecall exists. Only shared-object versioning remains your job.
contract CounterV1 is Initializable, UUPSUpgradeable {
// Storage layout is the contract's real ABI now: V2+ may only APPEND.
address public owner;
uint256 public count;
/// Implementations must never be initialized directly — only via proxy.
constructor() {
_disableInitializers();
}
function initialize(address owner_) external initializer {
owner = owner_;
}
function increment() external {
count += 1;
}
function _authorizeUpgrade(address) internal view override {
require(msg.sender == owner, "not owner");
}
}
/// V2 repeats V1's layout verbatim (owner, count), then appends. Reordering
/// or retyping either field would silently corrupt live state.
contract CounterV2 is Initializable, UUPSUpgradeable {
address public owner; // slot 0 — unchanged
uint256 public count; // slot 1 — unchanged
uint256 public step; // slot 2 — appended by V2
constructor() {
_disableInitializers();
}
function initializeV2(uint256 step_) external reinitializer(2) {
step = step_;
}
function increment() external {
count += step;
}
function _authorizeUpgrade(address) internal view override {
require(msg.sender == owner, "not owner");
}
}/// Upgradeable-proxy equivalent.
///
/// Solidity habit: to upgrade you deploy a proxy that `delegatecall`s into a
/// logic contract, and you must hand-manage a storage layout that both versions
/// agree on. Get the layout wrong and you get a *storage collision* that
/// silently corrupts state. There is no first-class notion of "the code changed
/// but the data is the same".
///
/// Sui idiom: packages are upgraded natively — `sui client upgrade` publishes a
/// new version at a new address while old versions stay callable forever. There
/// is no delegatecall and no shared storage layout, so storage collisions are
/// impossible. What you DO manage is a version gate: bump a `VERSION` constant
/// each upgrade and stamp your shared objects, so old code paths refuse to run
/// against migrated state.
module patterns::versioned;
/// Bumped by hand on every breaking upgrade. Entry points assert against it.
const VERSION: u64 = 1;
#[error(code = 0)]
const EWrongVersion: vector<u8> = b"Object version does not match package version";
#[error(code = 1)]
const EAlreadyMigrated: vector<u8> = b"Object is already at the current version";
/// The long-lived shared state. `version` records which package version last
/// migrated it.
public struct Config has key {
id: UID,
version: u64,
value: u64,
}
/// Authority to run migrations — a capability, so upgrade rights are an object
/// you hold, not an address you compare.
public struct AdminCap has key, store { id: UID }
fun init(ctx: &mut TxContext) {
transfer::public_transfer(AdminCap { id: object::new(ctx) }, ctx.sender());
transfer::share_object(Config { id: object::new(ctx), version: VERSION, value: 0 });
}
/// Every entry point calls this first. After an upgrade, a stale `Config` (still
/// at the old version) makes the new code abort until `migrate` runs — the Sui
/// analogue of guarding against a not-yet-migrated proxy.
fun assert_version(config: &Config) {
assert!(config.version == VERSION, EWrongVersion);
}
/// A normal, version-guarded operation.
public fun set_value(config: &mut Config, value: u64) {
assert_version(config);
config.value = value;
}
/// Run once after publishing an upgrade. Gated by the AdminCap; bumps the stored
/// version so guarded entry points start accepting the object again. Add any
/// data-shape migration here.
public fun migrate(_cap: &AdminCap, config: &mut Config) {
assert!(config.version < VERSION, EAlreadyMigrated);
config.version = VERSION;
}Factory / clones
Factories mint >90% of all EVM contracts (Ethereum + Polygon since 2020); the cheapest ones stamp out 45-byte ERC-1167 clones. On Sui the pattern vanishes: one package serves unlimited object instances.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
/// @title 05 — Factory + minimal-proxy clones (ERC-1167)
/// @notice The #1 operational pattern by volume: factories have minted >90% of
/// all contracts on Ethereum and Polygon since 2020. Because deploying bytecode is
/// expensive, factories stamp out 45-byte ERC-1167 clones that
/// delegatecall one shared implementation (Uniswap pairs, Safe
/// wallets, NFT drops all work this way).
/// @dev Sui counterpart: `no_factory.move` — the entire pattern evaporates.
/// One published package serves unlimited instances; "deploying an
/// account" is just creating an object. No clones, no delegatecall.
contract UserAccount is Initializable {
address public owner;
constructor() {
_disableInitializers();
}
function initialize(address owner_) external initializer {
owner = owner_;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "not owner");
(bool ok,) = owner.call{value: amount}("");
require(ok, "transfer failed");
}
receive() external payable {}
}
contract AccountFactory {
address public immutable implementation;
mapping(address user => address account) public accountOf;
event AccountCreated(address indexed user, address account);
constructor() {
implementation = address(new UserAccount());
}
function createAccount() external returns (address account) {
require(accountOf[msg.sender] == address(0), "already created");
account = Clones.clone(implementation); // 45-byte proxy, ~41k gas
UserAccount(payable(account)).initialize(msg.sender);
accountOf[msg.sender] = account;
emit AccountCreated(msg.sender, account);
}
}/// Factory / ERC-1167 minimal-proxy equivalent.
///
/// Solidity habit: to give every user their own contract instance you deploy a
/// factory that `CREATE`s (or `CREATE2` clones via ERC-1167) a fresh contract
/// per instance. Each clone costs a deployment and lives at its own address.
///
/// Sui idiom: there are no per-instance deployments. One published package is
/// the code for *unlimited* instances; an "instance" is just an object you
/// allocate with `object::new`. The `create` function below is the entire
/// factory — no clone bytecode, no CREATE2 address prediction, no registry.
module patterns::no_factory;
/// The per-user instance. Publishing the package once lets anyone mint as many
/// of these as they like.
public struct Vault has key, store {
id: UID,
owner: address,
balance: u64,
}
/// "Deploy a new instance" = allocate an object and transfer it. This single
/// package call replaces an ERC-1167 clone factory.
public fun create(ctx: &mut TxContext): Vault {
Vault { id: object::new(ctx), owner: ctx.sender(), balance: 0 }
}Escrow
The EVM escrow takes custody and must hand assets back correctly on every path. On Sui the item is a shared object that can't be silently dropped.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title 06 — Escrow (trustless NFT-for-ETH sale)
/// @notice The contract takes CUSTODY: the seller's NFT sits inside the
/// escrow's own address until the buyer pays. Both parties must trust
/// this code with their assets, and every path (accept/cancel) must
/// hand them back correctly.
/// @dev Sui counterpart: `escrow.move` — the item is wrapped in a shared
/// object; ownership mechanics are language-level (no `transferFrom`
/// approval dance) and the escrow cannot "forget" to return an object —
/// Move objects can't be silently dropped.
contract NftEscrow is ReentrancyGuard {
struct Deal {
address seller;
IERC721 nft;
uint256 tokenId;
uint256 price;
}
uint256 private _nextDealId;
mapping(uint256 dealId => Deal) public deals;
event Listed(uint256 indexed dealId, address indexed seller, uint256 price);
event Sold(uint256 indexed dealId, address indexed buyer);
event Cancelled(uint256 indexed dealId);
/// Seller must have called `nft.approve(escrow, tokenId)` first — the
/// two-transaction approval dance Sui eliminates.
function list(IERC721 nft, uint256 tokenId, uint256 price) external returns (uint256 dealId) {
dealId = ++_nextDealId;
deals[dealId] = Deal(msg.sender, nft, tokenId, price);
nft.transferFrom(msg.sender, address(this), tokenId);
emit Listed(dealId, msg.sender, price);
}
function buy(uint256 dealId) external payable nonReentrant {
Deal memory deal = deals[dealId];
require(deal.seller != address(0), "no deal");
require(msg.value == deal.price, "wrong price");
delete deals[dealId]; // effects before interactions
deal.nft.transferFrom(address(this), msg.sender, deal.tokenId);
(bool ok,) = deal.seller.call{value: msg.value}("");
require(ok, "pay failed");
emit Sold(dealId, msg.sender);
}
function cancel(uint256 dealId) external nonReentrant {
Deal memory deal = deals[dealId];
require(msg.sender == deal.seller, "not seller");
delete deals[dealId];
deal.nft.transferFrom(address(this), deal.seller, deal.tokenId);
emit Cancelled(dealId);
}
}/// Trustless swap escrow.
///
/// Solidity habit: an escrow contract holds both sides' assets in its own
/// storage and tracks who deposited what in mappings, trusting its own logic to
/// release correctly. Reentrancy and approval bugs live here.
///
/// Sui idiom: the escrow is a shared object that *owns* the offered item by
/// wrapping it. The counterparty atomically pays the asked amount and receives
/// the item in one transaction; if they never show up, the creator cancels and
/// the wrapped item is returned. The item cannot leak — it is a resource the
/// compiler forces us to route to exactly one owner.
module patterns::escrow;
use sui::coin::Coin;
use sui::sui::SUI;
#[error(code = 0)]
const EWrongAmount: vector<u8> = b"Payment does not match the asked price";
#[error(code = 1)]
const ENotCreator: vector<u8> = b"Only the creator can cancel";
/// Shared escrow. `T: key + store` is the offered item, wrapped inside. Generic
/// over T, so one module escrows NFTs, coins, or anything ownable.
public struct Escrow<T: key + store> has key {
id: UID,
creator: address,
asked: u64, // required SUI payment, in MIST
item: T,
}
/// Offer `item` for `asked` MIST. Shares the escrow so any buyer can take it.
public fun create<T: key + store>(item: T, asked: u64, ctx: &mut TxContext) {
let escrow = Escrow<T> { id: object::new(ctx), creator: ctx.sender(), asked, item };
transfer::share_object(escrow);
}
/// Buyer pays exactly `asked` and receives the item — atomic. Payment goes to
/// the creator; nothing is left half-done because the whole tx reverts on abort.
public fun swap<T: key + store>(escrow: Escrow<T>, payment: Coin<SUI>): T {
let Escrow { id, creator, asked, item } = escrow;
assert!(payment.value() == asked, EWrongAmount);
transfer::public_transfer(payment, creator);
id.delete();
// Return the unwrapped item; the buyer (caller) decides where it lands.
item
}
/// Creator reclaims the item if no swap happened.
public fun cancel<T: key + store>(escrow: Escrow<T>, ctx: &mut TxContext): T {
let Escrow { id, creator, asked: _, item } = escrow;
assert!(ctx.sender() == creator, ENotCreator);
id.delete();
item
}Vesting
OZ VestingWallet releases linearly over time. On Sui the wallet is an object the beneficiary owns, releasing against the on-chain Clock — the same mental model OpenZeppelin Contracts for Sui's finance package packages for production.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {VestingWallet} from "@openzeppelin/contracts/finance/VestingWallet.sol";
/// @title 07 — Vesting (linear release over time)
/// @notice OZ's `VestingWallet` holds ETH/ERC-20 and releases it linearly
/// between `start` and `start + duration`. The beneficiary calls
/// `release()` to pull whatever has vested so far.
/// @dev Sui counterpart: `vesting.move` — the same mental model ported to
/// objects: the wallet IS an object the beneficiary owns, and time comes
/// from the on-chain `Clock`. The snippet releases linearly by hand;
/// OpenZeppelin Contracts for Sui's `openzeppelin_finance` package is the
/// production-grade version to reach for.
contract TeamVesting is VestingWallet {
constructor(address beneficiary, uint64 startTimestamp, uint64 durationSeconds)
VestingWallet(beneficiary, startTimestamp, durationSeconds)
{}
}/// VestingWallet equivalent — linear vesting with a cliff-free straight line.
///
/// Solidity habit: OpenZeppelin's `VestingWallet` streams a balance to a
/// beneficiary as `block.timestamp` advances, tracking `released` in storage.
///
/// Sui idiom: same idea, but time comes from the shared `Clock` object (passed
/// by reference) and the locked funds live as a `Balance<C>` wrapped inside the
/// wallet object rather than in a contract's storage slot.
///
/// OZ ships this for Sui too — `openzeppelin_finance::vesting_wallet_linear`
/// (`create_and_share` + `release`). It is the production choice, but its payout
/// path routes through Sui's newer funds-accumulator (`balance::send_funds`),
/// which is heavier than a teaching snippet needs. We implement the same linear
/// curve natively here so the mechanics stay legible; reach for the OZ package
/// in real code.
module patterns::vesting;
use sui::balance::Balance;
use sui::clock::Clock;
use sui::coin::{Self, Coin};
#[error(code = 0)]
const EZeroDuration: vector<u8> = b"Vesting duration must be positive";
/// Shared vesting wallet. `phantom C` brands which currency it streams.
public struct VestingWallet<phantom C> has key {
id: UID,
beneficiary: address,
start_ms: u64,
duration_ms: u64,
total: u64, // original grant size, for the linear formula
released: u64, // cumulative amount already claimed
locked: Balance<C>,
}
/// Fund and share a wallet that vests `funds` linearly from `start_ms` over
/// `duration_ms`.
public fun create<C>(
funds: Coin<C>,
beneficiary: address,
start_ms: u64,
duration_ms: u64,
ctx: &mut TxContext,
) {
assert!(duration_ms > 0, EZeroDuration);
let total = funds.value();
let wallet = VestingWallet<C> {
id: object::new(ctx),
beneficiary,
start_ms,
duration_ms,
total,
released: 0,
locked: funds.into_balance(),
};
transfer::share_object(wallet);
}
/// Cumulative amount vested by `now_ms` — a straight line clamped to `[0, total]`.
/// Uses u128 intermediates so `total * elapsed` cannot overflow.
public fun vested_amount<C>(wallet: &VestingWallet<C>, now_ms: u64): u64 {
if (now_ms <= wallet.start_ms) return 0;
let elapsed = now_ms - wallet.start_ms;
if (elapsed >= wallet.duration_ms) return wallet.total;
(((wallet.total as u128) * (elapsed as u128)) / (wallet.duration_ms as u128)) as u64
}
/// Claim everything vested-but-not-yet-released and send it to the beneficiary.
public fun claim<C>(wallet: &mut VestingWallet<C>, clock: &Clock, ctx: &mut TxContext) {
let releasable = wallet.vested_amount(clock.timestamp_ms()) - wallet.released;
if (releasable == 0) return;
wallet.released = wallet.released + releasable;
let payout = coin::from_balance(wallet.locked.split(releasable), ctx);
transfer::public_transfer(payout, wallet.beneficiary);
}Multisig
Shared custody on EVM is a Safe contract with a confirmation transaction per co-signer. On Sui multisig is a native key scheme — signatures combine off-chain, no contract at all.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
/// @title 08 — Multisig wallet (M-of-N approval)
/// @notice On EVM, shared custody requires a CONTRACT (Safe being the
/// canonical one): owners submit a transaction, co-owners confirm
/// on-chain (each confirmation is its own transaction + gas), and
/// once the threshold is met anyone can execute.
/// @dev Sui counterpart: `patterns/native/multisig.sh` — NO contract at all.
/// Multisig is a native key scheme: an address can BE a k-of-n composite
/// of ed25519/secp256k1 keys, signatures are combined off-chain, and the
/// combined transaction is a single normal transaction.
contract MultisigWallet {
address[] public owners;
mapping(address => bool) public isOwner;
uint256 public immutable threshold;
struct Transaction {
address to;
uint256 value;
bytes data;
uint256 confirmations;
bool executed;
}
Transaction[] public transactions;
mapping(uint256 txId => mapping(address owner => bool)) public confirmed;
event Submitted(uint256 indexed txId, address indexed by);
event Confirmed(uint256 indexed txId, address indexed by);
event Executed(uint256 indexed txId);
modifier onlyOwner() {
require(isOwner[msg.sender], "not owner");
_;
}
constructor(address[] memory owners_, uint256 threshold_) {
require(owners_.length >= threshold_ && threshold_ > 0, "bad threshold");
for (uint256 i = 0; i < owners_.length; i++) {
require(owners_[i] != address(0) && !isOwner[owners_[i]], "bad owner");
isOwner[owners_[i]] = true;
}
owners = owners_;
threshold = threshold_;
}
function submit(address to, uint256 value, bytes calldata data) external onlyOwner returns (uint256 txId) {
txId = transactions.length;
transactions.push(Transaction(to, value, data, 0, false));
emit Submitted(txId, msg.sender);
}
/// Each confirmation is an on-chain transaction — the coordination cost
/// Sui moves off-chain into signature aggregation.
function confirm(uint256 txId) external onlyOwner {
require(!confirmed[txId][msg.sender], "already confirmed");
confirmed[txId][msg.sender] = true;
transactions[txId].confirmations += 1;
emit Confirmed(txId, msg.sender);
}
function execute(uint256 txId) external onlyOwner {
Transaction storage txn = transactions[txId];
require(!txn.executed, "executed");
require(txn.confirmations >= threshold, "below threshold");
txn.executed = true;
(bool ok,) = txn.to.call{value: txn.value}(txn.data);
require(ok, "call failed");
emit Executed(txId);
}
receive() external payable {}
}#!/usr/bin/env bash
# 08 — Multisig, the Sui way: NO CONTRACT.
#
# On EVM a multisig is a deployed contract (Safe): owners submit, co-owners
# confirm on-chain (a transaction each), then anyone executes. See
# ../solidity/src/08_Multisig.sol.
#
# On Sui, multisig is a NATIVE address scheme. A multisig address is literally
# a k-of-n combination of public keys (ed25519 / secp256k1 / secp256r1).
# Signatures are gathered OFF-CHAIN and combined into one signature; the
# resulting transaction is an ordinary transaction from an ordinary address.
# No contract to deploy, audit, or pay coordination gas to.
set -euo pipefail
# 1. Define the multisig: three signers, threshold 2, equal weights.
# (In practice each signer runs this with their own key in the keystore.)
sui keytool multi-sig-address \
--pks "$PK1" "$PK2" "$PK3" \
--weights 1 1 1 \
--threshold 2
# → prints the multisig Sui address. Fund it like any other address.
# 2. Build a transaction FROM the multisig address (e.g. a transfer) and
# serialize it to base64 (unsigned tx bytes):
TX_BYTES=$(sui client transfer-sui \
--to "$RECIPIENT" --sui-coin-object-id "$COIN" --gas-budget 3000000 \
--serialize-unsigned-transaction)
# 3. Each of two signers signs the SAME bytes independently (off-chain):
SIG1=$(sui keytool sign --address "$SIGNER1" --data "$TX_BYTES" --json | jq -r .suiSignature)
SIG2=$(sui keytool sign --address "$SIGNER2" --data "$TX_BYTES" --json | jq -r .suiSignature)
# 4. Combine the partial signatures into ONE multisig signature:
MULTISIG_SIG=$(sui keytool multi-sig-combine-partial-sig \
--pks "$PK1" "$PK2" "$PK3" --weights 1 1 1 --threshold 2 \
--sigs "$SIG1" "$SIG2" --json | jq -r .multisigSerialized)
# 5. Submit as a single normal transaction:
sui client execute-signed-tx --tx-bytes "$TX_BYTES" --signatures "$MULTISIG_SIG"
# That's the whole feature. The "M-of-N confirmations" loop that costs a
# transaction per co-signer on EVM happens here as free off-chain signing.Merkle airdrop
Pushing to thousands of recipients is too costly, so EVM airdrops publish a merkle root and make claimers prove membership. Sui's parallelism makes direct distribution viable — no proofs.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title 09 — Merkle airdrop (claim with proof)
/// @notice Pushing tokens to 10,000 recipients costs the SENDER prohibitive
/// gas, so EVM airdrops invert the flow: publish one merkle root,
/// make each recipient prove membership and pay their own claim gas.
/// @dev Sui counterpart: `airdrop.move` — parallel execution + cheap object
/// creation make DIRECT distribution viable again: the sender batch-
/// creates Claim objects (or transfers Coins outright) in parallel
/// transactions. No proofs, no claim site, no unclaimed remainder.
contract MerkleAirdrop {
using SafeERC20 for IERC20;
IERC20 public immutable token;
bytes32 public immutable merkleRoot;
mapping(address => bool) public claimed;
event Claimed(address indexed account, uint256 amount);
constructor(IERC20 token_, bytes32 merkleRoot_) {
token = token_;
merkleRoot = merkleRoot_;
}
function claim(address account, uint256 amount, bytes32[] calldata proof) external {
require(!claimed[account], "already claimed");
// OZ standard double-hashed leaf: keccak256(keccak256(abi.encode(...)))
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
require(MerkleProof.verify(proof, merkleRoot, leaf), "bad proof");
claimed[account] = true;
token.safeTransfer(account, amount);
emit Claimed(account, amount);
}
}/// Merkle-distributor equivalent.
///
/// Solidity habit: airdropping to thousands of addresses on-chain is too
/// expensive, so you publish a Merkle root and make each recipient submit a
/// proof to `claim`. The proof machinery exists only to compress a big list into
/// one storage slot.
///
/// Sui idiom: you can just create one small `Claim` object per recipient and
/// transfer it directly to them. Transfers touch independent objects, so the
/// batch parallelizes instead of contending on a shared contract — no Merkle
/// root, no proofs, no per-claim verification. Recipients later "open" their
/// claim to get a `Coin`.
module patterns::airdrop;
use sui::balance::Balance;
use sui::coin::{Self, Coin};
#[error(code = 0)]
const ELengthMismatch: vector<u8> = b"recipients and amounts must be the same length";
/// A pre-funded claim ticket, owned by its recipient.
public struct Claim<phantom C> has key, store {
id: UID,
funds: Balance<C>,
}
/// Split `funds` into one `Claim` per recipient and transfer each directly.
/// Any remainder is returned to the sender as a `Coin`.
#[allow(lint(self_transfer))] // returning the funder's own leftover coin is intentional
public fun airdrop<C>(
mut funds: Coin<C>,
recipients: vector<address>,
amounts: vector<u64>,
ctx: &mut TxContext,
) {
assert!(recipients.length() == amounts.length(), ELengthMismatch);
let mut i = 0;
let n = recipients.length();
while (i < n) {
let amount = amounts[i];
let claim = Claim<C> { id: object::new(ctx), funds: funds.balance_mut().split(amount) };
transfer::public_transfer(claim, recipients[i]);
i = i + 1;
};
// Return the leftover (possibly zero) coin to the funder.
transfer::public_transfer(funds, ctx.sender());
}
/// Recipient opens their claim, receiving a spendable `Coin`.
public fun claim<C>(ticket: Claim<C>, ctx: &mut TxContext): Coin<C> {
let Claim { id, funds } = ticket;
id.delete();
coin::from_balance(funds, ctx)
}Gasless UX
EIP-2612 permit replaces an approve tx with a signed message, per token. Sui makes gas sponsorship native to every transaction — no per-asset opt-in.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20Permit, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title 10 — Gasless approval (EIP-2612 permit / meta-transactions)
/// @notice EVM UX problem: before a dapp can move your tokens you must send
/// an `approve()` transaction — which costs ETH the new user doesn't
/// have. `permit` replaces it with an off-chain EIP-712 signature a
/// relayer submits, paying the gas for you.
/// @dev Sui counterpart: `patterns/native/sponsored-tx.ts` — the platform
/// solves the general problem: a sponsor sets the gas payment on ANY
/// transaction (no approvals exist to begin with — you own your Coins).
/// No per-token opt-in, no signature-replay surface in app code.
contract PermitToken is ERC20Permit {
constructor(uint256 supply) ERC20("Permit Token", "PMT") ERC20Permit("Permit Token") {
_mint(msg.sender, supply);
}
}
/// A dapp contract pulling deposits with a single user signature.
contract PermitDeposits {
using SafeERC20 for IERC20;
mapping(address => uint256) public deposited;
function depositWithPermit(
IERC20 token,
address owner,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
// try/catch: front-running the permit call must not brick the deposit
try IERC20Permit(address(token)).permit(owner, address(this), amount, deadline, v, r, s) {} catch {}
token.safeTransferFrom(owner, address(this), amount);
deposited[owner] += amount;
}
}// 10 — Gasless UX, the Sui way: sponsored transactions.
//
// On EVM, letting a user act without holding ETH means EIP-2612 `permit`: the
// user signs an off-chain approval, a relayer submits it and pays gas, and the
// dapp pulls tokens with that signature. It is PER-TOKEN (each token must
// implement permit) and only covers approvals. See ../solidity/src/10_Permit.sol.
//
// On Sui, gas sponsorship is a NATIVE property of every transaction: a
// transaction has a gas owner distinct from its sender. The sponsor supplies
// the gas coin and co-signs; the user signs the transaction contents. This
// works for ANY action, not just token approvals, and needs no per-asset
// opt-in (users own their Coins outright — there are no approvals to grant).
//
// Requires @mysten/sui. Illustrative — wire real keypairs / a sponsor service.
import { SuiClient, getFullnodeUrl } from "@mysten/sui/client";
import { Transaction } from "@mysten/sui/transactions";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
const client = new SuiClient({ url: getFullnodeUrl("testnet") });
export async function sponsoredTransfer(
user: Ed25519Keypair, // the gasless end-user — holds no SUI
sponsor: Ed25519Keypair, // pays gas on the user's behalf
recipient: string,
) {
const userAddr = user.getPublicKey().toSuiAddress();
const sponsorAddr = sponsor.getPublicKey().toSuiAddress();
// 1. Build the transaction the USER wants (move a Coin the user owns).
// Split from one of the user's OWN coins — NOT tx.gas, which the sponsor
// pays for. The user spends their asset; the sponsor only covers gas.
const tx = new Transaction();
tx.setSender(userAddr);
const userCoins = await client.getCoins({ owner: userAddr });
const [coin] = tx.splitCoins(userCoins.data[0].coinObjectId, [1_000_000]);
tx.transferObjects([coin], recipient);
// 2. The SPONSOR provides the gas payment and is set as gas owner.
tx.setGasOwner(sponsorAddr);
const sponsorCoins = await client.getCoins({ owner: sponsorAddr });
tx.setGasPayment(
sponsorCoins.data.slice(0, 1).map((c) => ({
objectId: c.coinObjectId,
version: c.version,
digest: c.digest,
})),
);
// 3. Build once, then BOTH parties sign the same bytes.
const bytes = await tx.build({ client });
const userSig = (await user.signTransaction(bytes)).signature;
const sponsorSig = (await sponsor.signTransaction(bytes)).signature;
// 4. Submit with both signatures. The user never touched SUI.
return client.executeTransactionBlock({
transactionBlock: bytes,
signature: [userSig, sponsorSig],
});
}Flash loan
ERC-3156 requires a callback and checks balances after — the re-entry surface behind many exploits. Sui's hot-potato Receipt has no abilities, so the tx literally cannot end unpaid.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20FlashMint, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20FlashMint.sol";
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title 11 — Flash loan (ERC-3156)
/// @notice Borrow with zero collateral as long as you repay within the same
/// transaction. On EVM this REQUIRES a callback: the lender calls
/// `onFlashLoan` on your contract and CHECKS BALANCES AFTER — the
/// exact dynamic-dispatch re-entry surface behind a long list of
/// DeFi exploits.
/// @dev Sui counterpart: `flash_loan.move` — the "hot potato" pattern. The
/// loan returns a `Receipt` struct with NO abilities: it cannot be
/// stored, copied, or dropped, so the transaction literally cannot end
/// until `repay` consumes it. Repayment is a type-system guarantee, not
/// a balance check, and there is no callback to re-enter.
contract FlashToken is ERC20FlashMint {
constructor(uint256 supply) ERC20("Flash Token", "FLT") {
_mint(msg.sender, supply);
}
/// 0.1% flash fee (default is 0). Fee is burned by _flashFeeReceiver=0.
function _flashFee(address, uint256 amount) internal pure override returns (uint256) {
return amount / 1000;
}
}
/// Minimal borrower: receives tokens, does its arbitrage (elided), approves
/// principal + fee back to the lender.
contract FlashBorrower is IERC3156FlashBorrower {
bytes32 private constant CALLBACK_OK = keccak256("ERC3156FlashBorrower.onFlashLoan");
IERC3156FlashLender public immutable lender;
constructor(IERC3156FlashLender lender_) {
lender = lender_;
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external override returns (bytes32) {
// Two mandatory ERC-3156 checks: only our known lender may invoke the
// callback, and the loan must have been initiated by us.
require(msg.sender == address(lender), "untrusted lender");
require(initiator == address(this), "untrusted initiator");
// ... use `amount` for arbitrage/liquidation/refinancing here ...
IERC20(token).approve(msg.sender, amount + fee);
return CALLBACK_OK;
}
function borrow(address token, uint256 amount) external {
lender.flashLoan(this, token, amount, "");
}
}/// Flash loan via the hot-potato pattern.
///
/// Solidity habit: a flash loan sends funds to the borrower, calls back into
/// their contract, then checks `balanceAfter >= balanceBefore + fee` — trusting
/// a reentrant callback and a balance assertion to enforce repayment.
///
/// Sui idiom: `borrow` hands out the coin AND a `Receipt` that has *no
/// abilities* — it can't be copied, dropped, or stored. The only way to make
/// the transaction compile is to pass the Receipt back to `repay` in the same
/// PTB. The type system enforces repayment; there is no callback and no
/// reentrancy surface.
module patterns::flash_loan;
use sui::balance::Balance;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
#[error(code = 0)]
const ERepayWrongAmount: vector<u8> = b"Repayment must equal principal plus fee";
#[error(code = 1)]
const EWrongPool: vector<u8> = b"Receipt must be repaid to its issuing pool";
const FEE_BPS: u64 = 30; // 0.30%
/// Shared lending pool.
public struct Pool has key {
id: UID,
reserve: Balance<SUI>,
}
/// The hot potato. No `key`, `store`, `copy`, or `drop`: it MUST be consumed by
/// `repay` before the transaction can end.
public struct Receipt {
pool_id: ID, // pins the debt to the pool that issued it
amount: u64,
fee: u64,
}
/// Seed a pool with initial liquidity.
public fun create(seed: Coin<SUI>, ctx: &mut TxContext) {
transfer::share_object(Pool { id: object::new(ctx), reserve: seed.into_balance() });
}
/// Borrow `amount`. Returns the funds plus a Receipt that pins the debt.
public fun borrow(pool: &mut Pool, amount: u64, ctx: &mut TxContext): (Coin<SUI>, Receipt) {
let fee = (((amount as u128) * (FEE_BPS as u128)) / 10_000) as u64;
let loan = coin::from_balance(pool.reserve.split(amount), ctx);
(loan, Receipt { pool_id: object::id(pool), amount, fee })
}
/// Repay principal + fee, consuming the Receipt. Only this call can retire it,
/// so a transaction that borrows must reach here or fail to compile.
public fun repay(pool: &mut Pool, payment: Coin<SUI>, receipt: Receipt) {
let Receipt { pool_id, amount, fee } = receipt;
assert!(pool_id == object::id(pool), EWrongPool);
assert!(payment.value() == amount + fee, ERepayWrongAmount);
pool.reserve.join(payment.into_balance());
}Security canon
CEI, reentrancy guards and SafeMath defend against dynamic dispatch and silent overflow. Move has neither — most of the canon is moot; what remains is rounding and access.
VulnerableVault in the Solidity file is deliberately exploitable — a teaching artifact. Never deploy it.// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/// @title 12 — The security canon (CEI, reentrancy guard, pull payments, pausable)
/// @notice The defensive patterns every Solidity dev drills, and WHY: any
/// external call can hand control to attacker code that re-enters
/// you mid-state-change (the DAO hack). Defenses: order code as
/// checks-effects-interactions, add a reentrancy mutex, prefer pull
/// over push payments, keep a circuit breaker.
/// @dev Sui counterpart: `security.move` — most of this canon is MOOT. Move
/// has no dynamic dispatch, no fallback code on transfers, and native
/// overflow aborts: reentrancy guards, CEI discipline and SafeMath have
/// nothing to defend against. What still matters: rounding direction,
/// access control, and pausability — see the Move module.
/// ⚠️ INTENTIONALLY VULNERABLE — teaching artifact, never deploy.
/// Interaction (the call) happens BEFORE the effect (zeroing the balance):
/// the recipient's fallback can re-enter withdraw() and drain the vault.
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok,) = msg.sender.call{value: amount}(""); // interaction first ❌
require(ok, "send failed");
balances[msg.sender] = 0; // effect last ❌
}
}
/// The hardened version: CEI order + mutex + circuit breaker.
contract HardenedVault is ReentrancyGuard, Pausable, Ownable {
mapping(address => uint256) public balances;
constructor(address admin) Ownable(admin) {}
function deposit() external payable whenNotPaused {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant whenNotPaused {
uint256 amount = balances[msg.sender]; // check
balances[msg.sender] = 0; // effect
(bool ok,) = msg.sender.call{value: amount}(""); // interaction last ✓
require(ok, "send failed");
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
}/// Why three Solidity security staples are largely moot in Move — and the one
/// that still matters (rounding).
///
/// 1. Reentrancy guards (`nonReentrant`). Reentrancy needs dynamic dispatch: an
/// external call that re-enters your function mid-execution. Move has no
/// dynamic dispatch and no fallback functions — a module calls only functions
/// known at compile time, and a called module cannot call back into yours
/// unless you already depend on it (no cycles allowed). There is no reentrant
/// edge to guard.
///
/// 2. Checks-Effects-Interactions. CEI exists to avoid reentrancy and to avoid
/// losing funds to a failing external call. In Move, assets are resources: a
/// `Coin` you hold cannot vanish, and a transfer is not a callback. The
/// ordering discipline is unnecessary for the reentrancy reason above.
///
/// 3. SafeMath / OpenZeppelin `Math`. Move's integer arithmetic *aborts* on
/// overflow and underflow at the VM level — `a + b` past `u64::MAX` reverts
/// the transaction. There is no silent wraparound to defend against, so no
/// SafeMath wrapper is needed for basic ops.
///
/// What DOES still bite you: rounding direction in `mul_div`. Integer division
/// truncates, and rounding the wrong way lets value leak from a pool to a user
/// (or vice versa). OpenZeppelin's math library for Sui makes the direction
/// explicit — round *against* the party that could exploit the residue.
module patterns::security;
// `u64` is also a primitive type name, so alias the OZ module to avoid shadowing.
use openzeppelin_math::u64 as oz_u64;
use openzeppelin_math::rounding;
#[error(code = 0)]
const EOverflow: vector<u8> = b"mul_div result does not fit in u64";
/// ERC-4626-style share issuance: how many pool shares does `assets` buy?
///
/// `shares = assets * total_shares / total_assets`, rounded DOWN so the
/// depositor never receives a fractional share the pool didn't back — the
/// residue stays with the pool, never in the user's favor. Overflow of the
/// intermediate product is handled by OZ (widened internally); a result too big
/// for `u64` returns `none`, which we turn into an explicit abort.
///
/// Teaching scope: this isolates rounding DIRECTION, which is necessary but not
/// sufficient for a real ERC-4626 vault. A production vault must also handle the
/// first deposit (`total_assets == 0` divides by zero and aborts here) and the
/// inflation/donation attack — where the first depositor mints one share, then
/// donates assets to skew the ratio so later depositors round to zero shares.
/// Defenses (seeding initial shares, virtual shares/assets offsets) live outside
/// this snippet.
public fun shares_for_deposit(assets: u64, total_shares: u64, total_assets: u64): u64 {
let result = oz_u64::mul_div(assets, total_shares, total_assets, rounding::down());
assert!(result.is_some(), EOverflow);
result.destroy_some()
}Governance
Token-weighted propose/vote/execute. On Sui a shared Proposal object is mutated concurrently by voters, with the Clock as deadline.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title 13 — Governance (token voting)
/// @notice Minimal token-weighted governor: propose, vote until a deadline,
/// execute if yes > no. Production systems use OZ Governor +
/// ERC20Votes checkpoints so votes snapshot past balances (this
/// minimal version reads LIVE balances — flash-loan-manipulable,
/// kept simple deliberately; see comment in castVote).
/// @dev Sui counterpart: `governance.move` — a shared Proposal object that
/// voters mutate concurrently; the on-chain `Clock` provides the
/// deadline and votes are recorded per-address in the object itself.
contract MiniGovernor {
IERC20 public immutable token;
uint256 public constant VOTING_PERIOD = 3 days;
struct Proposal {
string description;
uint256 deadline;
uint256 yesVotes;
uint256 noVotes;
bool executed;
}
Proposal[] public proposals;
mapping(uint256 proposalId => mapping(address voter => bool)) public hasVoted;
event Proposed(uint256 indexed id, string description);
event Voted(uint256 indexed id, address indexed voter, bool support, uint256 weight);
event ExecutionApproved(uint256 indexed id);
constructor(IERC20 token_) {
token = token_;
}
function propose(string calldata description) external returns (uint256 id) {
id = proposals.length;
proposals.push(Proposal(description, block.timestamp + VOTING_PERIOD, 0, 0, false));
emit Proposed(id, description);
}
function castVote(uint256 id, bool support) external {
Proposal storage p = proposals[id];
require(block.timestamp < p.deadline, "voting over");
require(!hasVoted[id][msg.sender], "already voted");
hasVoted[id][msg.sender] = true;
// Live balance as weight — real governors use ERC20Votes checkpoints
// to stop borrow-vote-return manipulation.
uint256 weight = token.balanceOf(msg.sender);
if (support) p.yesVotes += weight;
else p.noVotes += weight;
emit Voted(id, msg.sender, support, weight);
}
function execute(uint256 id) external {
Proposal storage p = proposals[id];
require(block.timestamp >= p.deadline, "voting open");
require(!p.executed, "executed");
require(p.yesVotes > p.noVotes, "rejected");
p.executed = true;
// Real governors queue the proposal's calldata in a timelock here.
emit ExecutionApproved(id);
}
}/// Minimal Governor equivalent.
///
/// Solidity habit: a Governor contract stores proposals in mappings, tracks
/// `hasVoted[proposalId][voter]` to stop double-voting, and gates execution on
/// `block.number > deadline`.
///
/// Sui idiom: a proposal is a shared object. Double-voting is prevented by a
/// `VecSet<address>` of voters held right in the object, and the deadline is
/// read from the shared `Clock`. Execution simply checks the clock and tally —
/// the same logic, minus the storage-slot bookkeeping.
///
/// Teaching caveat: this is one-address-one-vote with no stake weighting. The
/// `VecSet` only stops the SAME address voting twice — it does nothing against
/// sybils, so an attacker votes from many fresh addresses for free, and `voted`
/// grows unbounded inside one object. Production governance weights votes by a
/// token-balance snapshot (the Solidity pair's `ERC20Votes` checkpoints) to make
/// both sybil and flash-loan manipulation ineffective.
module patterns::governance;
use sui::clock::Clock;
use sui::vec_set::{Self, VecSet};
#[error(code = 0)]
const EVotingClosed: vector<u8> = b"Voting period has ended";
#[error(code = 1)]
const EAlreadyVoted: vector<u8> = b"Address has already voted";
#[error(code = 2)]
const EStillVoting: vector<u8> = b"Voting period has not ended yet";
#[error(code = 3)]
const ENotPassed: vector<u8> = b"Proposal did not pass";
#[error(code = 4)]
const EAlreadyExecuted: vector<u8> = b"Proposal has already been executed";
/// A shared, one-address-one-vote proposal.
public struct Proposal has key {
id: UID,
deadline_ms: u64,
yes: u64,
no: u64,
voted: VecSet<address>, // enforces one vote per address
executed: bool,
}
public fun create(deadline_ms: u64, ctx: &mut TxContext) {
transfer::share_object(Proposal {
id: object::new(ctx),
deadline_ms,
yes: 0,
no: 0,
voted: vec_set::empty(),
executed: false,
});
}
/// Cast a vote. One address, one vote — the `voted` set rejects repeats.
public fun vote(proposal: &mut Proposal, support: bool, clock: &Clock, ctx: &mut TxContext) {
assert!(clock.timestamp_ms() < proposal.deadline_ms, EVotingClosed);
let voter = ctx.sender();
assert!(!proposal.voted.contains(&voter), EAlreadyVoted);
proposal.voted.insert(voter);
if (support) proposal.yes = proposal.yes + 1 else proposal.no = proposal.no + 1;
}
/// Execute after the deadline if yes-votes lead. Returns whether it passed;
/// real governance would perform the enacted action here.
public fun execute(proposal: &mut Proposal, clock: &Clock): bool {
assert!(clock.timestamp_ms() >= proposal.deadline_ms, EStillVoting);
assert!(proposal.yes > proposal.no, ENotPassed);
assert!(!proposal.executed, EAlreadyExecuted); // mirrors Solidity's require(!p.executed)
proposal.executed = true;
true
}