evm-sui:~/patterns $ walrus patterns github
Chapter 2 · Solidity → Sui

Everyday Solidity, done on Sui.

Thirteen of the most common Solidity/EVM patterns — ranked by real-world prevalence — each paired with its idiomatic Sui Move equivalent. Read the left pane in a language you know; read the right to see how the object model, capabilities, and native features change the shape of the answer. Where OpenZeppelin Contracts for Sui applies, we use it.

pattern 01/13

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.

solidity/src/01_FungibleToken.solsolidity
// 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);
    }
}
move/patterns/sources/fungible_token.movemove
/// 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);
}
pattern 02/13

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.

solidity/src/02_Nft.solsolidity
// 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);
    }
}
move/patterns/sources/nft.movemove
/// 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);
}
pattern 03/13

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.

solidity/src/03_AccessControl.solsolidity
// 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 {}
}
move/patterns/sources/access_control.movemove
/// 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
}
pattern 04/13

Upgradeability

UUPS proxies delegatecall an implementation so code changes while storage stays — append-only forever. Sui upgrades packages natively; the chain enforces layout compatibility.

solidity/src/04_UpgradeableCounter.solsolidity
// 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");
    }
}
move/patterns/sources/versioned.movemove
/// 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;
}
pattern 05/13

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.

solidity/src/05_Factory.solsolidity
// 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);
    }
}
move/patterns/sources/no_factory.movemove
/// 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 }
}
pattern 06/13

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.

solidity/src/06_Escrow.solsolidity
// 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);
    }
}
move/patterns/sources/escrow.movemove
/// 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
}
pattern 07/13

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.

solidity/src/07_Vesting.solsolidity
// 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)
    {}
}
move/patterns/sources/vesting.movemove
/// 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);
}
pattern 08/13

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.

solidity/src/08_Multisig.solsolidity
// 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 {}
}
native/multisig.shbash
#!/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.
pattern 09/13

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.

solidity/src/09_MerkleAirdrop.solsolidity
// 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);
    }
}
move/patterns/sources/airdrop.movemove
/// 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)
}
pattern 10/13

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.

solidity/src/10_Permit.solsolidity
// 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;
    }
}
native/sponsored-tx.tstypescript
// 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],
  });
}
pattern 11/13

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.

solidity/src/11_FlashLoan.solsolidity
// 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, "");
    }
}
move/patterns/sources/flash_loan.movemove
/// 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());
}
pattern 12/13

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.
solidity/src/12_SecurityPatterns.solsolidity
// 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();
    }
}
move/patterns/sources/security.movemove
/// 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()
}
pattern 13/13

Governance

Token-weighted propose/vote/execute. On Sui a shared Proposal object is mutated concurrently by voters, with the Clock as deadline.

solidity/src/13_Governance.solsolidity
// 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);
    }
}
move/patterns/sources/governance.movemove
/// 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
}
01/13 · fungible_token [j/k] pattern · [d]iff · [m] panes · [⌘k] jump · [?] help