Move smart contract security: testing patterns for Aptos and Sui
Move Smart Contract Security: Testing Patterns for Aptos and Sui
Author: deivitto | Guide
Move is different. If you're coming from Solidity or Rust, some of your instincts will be wrong. The type system does heavy lifting — no reentrancy, no raw pointer manipulation, no implicit type coercion. But Move introduces its own class of bugs that don't exist in other ecosystems. Let's walk through how to test for them.
Why Move security is different
Move was designed for digital assets. Its resource model means you can't accidentally duplicate or destroy tokens — the compiler literally won't let you. A Coin<APT> must go somewhere. You can't just forget about it.
That's great. It kills entire vulnerability classes:
- No reentrancy (the runtime prevents it)
- No token duplication (linear types enforce this)
- No storage collision (typed global storage)
- No delegate call exploits (no concept exists)
But Move has its own problems. Object ownership in Sui, capability patterns in Aptos, module upgrade mechanics, and the relationship between public and public(friend) visibility create attack surface that static analysis alone won't cover.
The Move prover: formal verification built in
Move is one of the few smart contract languages with a built-in formal verification tool. The Move Prover checks mathematical properties of your code, not just "does it compile" but "does it satisfy this specification."
Writing prover specifications
module example::token_vault {
use std::signer;
use aptos_framework::coin;
use aptos_framework::coin::Coin;
struct Vault<phantom CoinType> has key {
balance: Coin<CoinType>,
total_shares: u64,
share_balance: table::Table<address, u64>,
}
public fun deposit<CoinType>(
account: &signer,
vault_addr: address,
coins: Coin<CoinType>,
) acquires Vault {
let vault = borrow_global_mut<Vault<CoinType>>(vault_addr);
let deposit_amount = coin::value(&coins);
// Calculate shares
let shares = if (vault.total_shares == 0) {
deposit_amount
} else {
(deposit_amount * vault.total_shares)
/ coin::value(&vault.balance)
};
coin::merge(&mut vault.balance, coins);
vault.total_shares = vault.total_shares + shares;
let sender = signer::address_of(account);
let current = table::borrow_mut_with_default(
&mut vault.share_balance, sender, 0
);
*current = *current + shares;
}
// PROVER SPECIFICATION
spec deposit {
// Vault balance increases by exactly the deposited amount
ensures coin::value(
global<Vault<CoinType>>(vault_addr).balance
) == old(coin::value(
global<Vault<CoinType>>(vault_addr).balance
)) + coin::value(coins);
// Total shares can never decrease on deposit
ensures global<Vault<CoinType>>(vault_addr).total_shares
>= old(global<Vault<CoinType>>(vault_addr).total_shares);
// Depositor's shares increase
ensures table::spec_get(
global<Vault<CoinType>>(vault_addr).share_balance,
signer::address_of(account)
) >= old(table::spec_get(
global<Vault<CoinType>>(vault_addr).share_balance,
signer::address_of(account)
));
}
// Global invariant: total_shares is the sum of all individual shares
spec module {
invariant forall vault_addr: address
where exists<Vault<APT>>(vault_addr):
global<Vault<APT>>(vault_addr).total_shares > 0
==> coin::value(global<Vault<APT>>(vault_addr).balance) > 0;
}
}
Run it with:
aptos move prove
The prover will either confirm your specs hold for ALL possible inputs or give you a concrete counterexample. That's stronger than any fuzzer, it's mathematical proof. But it only works for properties you can express in the specification language, and complex cross-module properties are hard to specify.
Aptos testing patterns
Unit testing
Aptos has a built-in test framework. Every function annotated with #[test] runs in a simulated Move VM:
#[test_only]
module example::vault_tests {
use example::token_vault;
use aptos_framework::coin;
use aptos_framework::account;
#[test(admin = @example, user1 = @0x42, user2 = @0x43)]
fun test_deposit_withdraw_roundtrip(
admin: &signer,
user1: &signer,
user2: &signer,
) {
// Setup accounts
account::create_account_for_test(signer::address_of(admin));
account::create_account_for_test(signer::address_of(user1));
account::create_account_for_test(signer::address_of(user2));
// Initialize vault
token_vault::initialize(admin);
// User1 deposits 1000
let coins = coin::mint_for_testing<APT>(1000);
token_vault::deposit(user1, @example, coins);
// User1 withdraws everything
let withdrawn = token_vault::withdraw(user1, @example, 1000);
assert!(coin::value(&withdrawn) == 1000, 0);
coin::burn_for_testing(withdrawn);
}
#[test(admin = @example, attacker = @0x666)]
#[expected_failure(abort_code = 0x50001)] // ENOT_AUTHORIZED
fun test_unauthorized_admin_call(
admin: &signer,
attacker: &signer,
) {
account::create_account_for_test(signer::address_of(admin));
account::create_account_for_test(signer::address_of(attacker));
token_vault::initialize(admin);
// Attacker tries to call admin function -- should fail
token_vault::update_fee(attacker, 500);
}
}
Testing capability abuse
Capabilities in Aptos are powerful, they grant permission to perform privileged operations. If a capability leaks, anyone can use it:
module example::governance {
struct AdminCap has key, store {
can_pause: bool,
can_upgrade: bool,
}
// DANGEROUS: This returns the capability -- caller can store it anywhere
public fun get_admin_cap(account: &signer): AdminCap {
// If this checks are wrong, the cap leaks
assert!(signer::address_of(account) == @admin, ENOT_ADMIN);
AdminCap { can_pause: true, can_upgrade: true }
}
}
// Test that capability can't be obtained by non-admins
#[test(fake_admin = @0x999)]
#[expected_failure]
fun test_cap_leak(fake_admin: &signer) {
let cap = governance::get_admin_cap(fake_admin);
// If we get here, the capability leaked
// Must explicitly handle the cap since Move won't let us drop it
governance::destroy_cap(cap);
}
The key question: can a non-privileged account obtain or fabricate a capability? Test every code path that creates, transfers, or checks capabilities.
Sui testing patterns
Sui's object model introduces unique testing challenges. Objects have ownership, they can be owned by an address, shared, or immutable. Getting ownership wrong is a whole vulnerability class.
Object ownership tests
module example::nft_market {
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};
struct Listing has key {
id: UID,
nft_id: ID,
price: u64,
seller: address,
}
// Create a listing -- the NFT should be locked until sold or cancelled
public fun create_listing(
nft: NFT,
price: u64,
ctx: &mut TxContext,
) {
let listing = Listing {
id: object::new(ctx),
nft_id: object::id(&nft),
price,
seller: tx_context::sender(ctx),
};
// Transfer NFT to a shared object (escrow)
// BUG POTENTIAL: if we transfer to seller instead,
// they keep the NFT AND can sell it
transfer::public_share_object(listing);
transfer::public_transfer(nft, @escrow);
}
// Cancel listing -- only seller should be able to
public fun cancel_listing(
listing: Listing,
ctx: &mut TxContext,
): NFT {
let Listing { id, nft_id: _, price: _, seller } = listing;
// CRITICAL CHECK: only seller can cancel
assert!(seller == tx_context::sender(ctx), ENotSeller);
object::delete(id);
// Return the NFT to the seller
// ... retrieve from escrow
}
}
Testing with sui's test framework
#[test_only]
module example::market_tests {
use sui::test_scenario::{Self as ts, Scenario};
use example::nft_market;
#[test]
fun test_only_seller_can_cancel() {
let seller = @0x1;
let buyer = @0x2;
let attacker = @0x3;
let mut scenario = ts::begin(seller);
// Seller creates listing
ts::next_tx(&mut scenario, seller);
{
let nft = mint_test_nft(ts::ctx(&mut scenario));
nft_market::create_listing(nft, 1000, ts::ctx(&mut scenario));
};
// Attacker tries to cancel -- should fail
ts::next_tx(&mut scenario, attacker);
{
let listing = ts::take_shared<Listing>(&scenario);
// This should abort with ENotSeller
// In test, we'd use expected_failure
};
ts::end(scenario);
}
#[test]
fun test_double_spend_prevention() {
let seller = @0x1;
let buyer1 = @0x2;
let buyer2 = @0x3;
let mut scenario = ts::begin(seller);
// Create listing
ts::next_tx(&mut scenario, seller);
{
let nft = mint_test_nft(ts::ctx(&mut scenario));
nft_market::create_listing(nft, 1000, ts::ctx(&mut scenario));
};
// Buyer1 purchases
ts::next_tx(&mut scenario, buyer1);
{
let listing = ts::take_shared<Listing>(&scenario);
let payment = coin::mint_for_testing<SUI>(
1000, ts::ctx(&mut scenario)
);
nft_market::purchase(listing, payment, ts::ctx(&mut scenario));
};
// Buyer2 tries to purchase same listing -- should fail
// because the listing object was consumed in the previous tx
// Sui's object model prevents this at the runtime level
// but we test it anyway to make sure our logic is correct
ts::end(scenario);
}
}
Common Move vulnerability patterns
1. module upgrade risks
Both Aptos and Sui allow module upgrades. This is a huge attack surface:
// A module can be upgraded to change behavior
// But upgraded code can access existing resources
// If your module stores a Treasury capability,
// a malicious upgrade could drain it
module example::treasury {
struct Treasury has key {
id: UID,
balance: Balance<SUI>,
// An upgrade could add a function that drains this
}
// FIX: Make the module immutable after deployment
// Or use upgrade policies that restrict what can change
}
Test upgrade scenarios: what happens if a dependency module upgrades? Does your module's security still hold?
2. phantom type confusion
Move's phantom types are compile-time only. They don't exist at runtime:
struct Pool<phantom X, phantom Y> has key {
id: UID,
reserve_x: Balance<X>,
reserve_y: Balance<Y>,
}
// BUG: If you don't verify type parameters correctly,
// someone might create Pool<USDC, USDC>
// and exploit identical type parameters
public fun swap<X, Y>(
pool: &mut Pool<X, Y>,
input: Coin<X>,
ctx: &mut TxContext,
): Coin<Y> {
// Need to assert X != Y here, or the math breaks
// The compiler won't catch this for you
}
3. missing signer checks
The signer parameter in Move is how you verify authorization. Missing it is like having a Solidity function without onlyOwner:
// VULNERABLE: Anyone can call this
public fun set_oracle_price(
oracle: &mut Oracle,
new_price: u64,
) {
oracle.price = new_price;
}
// FIXED: Requires the oracle admin's signature
public fun set_oracle_price(
admin: &signer,
oracle: &mut Oracle,
new_price: u64,
) {
assert!(signer::address_of(admin) == oracle.admin, ENOT_ADMIN);
oracle.price = new_price;
}
4. flash loan in object model
Sui's object model creates an interesting flash loan variant. If a shared object can be borrowed and returned within a single transaction:
// Hot potato pattern -- must be consumed in same transaction
struct FlashLoanReceipt {
pool_id: ID,
amount: u64,
// No 'drop' ability -- MUST be consumed
}
public fun flash_borrow(
pool: &mut Pool,
amount: u64,
ctx: &mut TxContext,
): (Coin<SUI>, FlashLoanReceipt) {
let coins = balance::split(&mut pool.balance, amount);
let receipt = FlashLoanReceipt {
pool_id: object::id(pool),
amount,
};
(coin::from_balance(coins, ctx), receipt)
}
public fun flash_repay(
pool: &mut Pool,
payment: Coin<SUI>,
receipt: FlashLoanReceipt,
) {
let FlashLoanReceipt { pool_id, amount } = receipt;
assert!(object::id(pool) == pool_id, EWrongPool);
// BUG: Should check payment >= amount + fee
assert!(coin::value(&payment) >= amount, EInsufficientRepayment);
balance::join(&mut pool.balance, coin::into_balance(payment));
}
The hot potato pattern (no drop ability) forces the receipt to be consumed, but you still need to verify the repayment amount. Test with exact boundary values.
How move's type system helps (and where it doesn't)
What Move prevents that EVM doesn't:
- Reentrancy, the VM simply doesn't allow it
- Token duplication, linear types mean you can't copy a Coin
- Storage collision, each resource type has its own global storage
- Unchecked external calls, no
callopcode, just typed function calls
What Move doesn't prevent:
- Logic errors, wrong math is wrong math in any language
- Access control mistakes, you still have to check signers correctly
- Economic attacks, flash loans, oracle manipulation, sandwich attacks all work
- Upgrade attacks, module upgrades can change behavior of existing resources
- Phantom type confusion, compile-time types don't guarantee runtime safety
The bottom line: Move contracts need fewer tests for low-level memory/reentrancy issues, but the same rigor on business logic, access control, and economic invariants.
Putting it all together
For smart contract security on Move:
-
Write prover specs first. Before you write tests, spec your invariants. The Move Prover catches bugs no amount of testing would find.
-
Unit test every access control path. Test that authorized users can act and unauthorized users can't. Every function, every role.
-
Test object ownership transitions. Especially on Sui, objects moving between owned/shared/immutable states need thorough coverage.
-
Test upgrade scenarios. What happens when your module or a dependency upgrades? Write tests that simulate the upgrade path.
-
Don't trust the type system blindly. It prevents whole classes of bugs, but the bugs that remain are the subtle, logic-level ones that only careful testing catches.
Move is a better language for digital assets than Solidity. Full stop. But "better" doesn't mean "safe by default." The type system is your first line of defense. Testing and verification are your second and third. Use all three.
Get a Move Security Audit
Try Recon Pro
Related Posts
Fuzzing ZK circuits: testing Noir and Circom with property-based approaches
ZK circuits need testing too. Under-constrained circuits, missing range checks, and witness mismatch...
Halmos symbolic execution for smart contracts: setup, limitations, and when it beats fuzzing
Fuzzers sample randomly. Symbolic execution explores every path. Halmos brings symbolic execution to...