10. General Move Logic Errors
Overview
General logic errors in Move contracts include PTB (Programmable Transaction Block) reordering effects, incorrect mutation order, fee miscalculations, and state inconsistencies. These bugs are often subtle and can lead to fund loss or protocol manipulation.
Risk Level
Medium to Critical — Varies based on the specific logic error.
OWASP / CWE Mapping
| OWASP Top 10 | MITRE CWE |
|---|---|
| A01 (Broken Access Control), A04 (Insecure Design) | CWE-841 (Improper Enforcement of Behavioral Workflow), CWE-362 (Race Condition) |
The Problem
Categories of Logic Errors
- State mutation order — Operations performed in wrong sequence
- PTB assumptions — Expecting specific call ordering in transactions
- Arithmetic errors — Rounding, precision loss, fee calculations
- Invariant violations — Protocol rules not enforced consistently
- Edge cases — Zero values, empty collections, boundary conditions
Vulnerable Examples
Example 1: Incorrect Mutation Order
module vulnerable::lending {
use sui::object::UID;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
public struct LendingPool has key {
id: UID,
total_deposits: u64,
total_borrows: u64,
interest_rate: u64,
}
/// VULNERABLE: Interest calculated on old balance
public entry fun deposit(
pool: &mut LendingPool,
payment: Coin<SUI>,
) {
let amount = coin::value(&payment);
// WRONG: Accruing interest AFTER updating deposits
// New deposit earns interest it shouldn't
pool.total_deposits = pool.total_deposits + amount;
// Interest calculated on inflated total
accrue_interest(pool);
// ... store payment
}
/// VULNERABLE: Withdraw before interest accrual
public entry fun withdraw(
pool: &mut LendingPool,
amount: u64,
ctx: &mut TxContext
) {
// WRONG: Withdrawing before accruing interest
// User avoids paying accumulated interest
pool.total_deposits = pool.total_deposits - amount;
accrue_interest(pool); // Too late!
}
}Example 2: Fee Calculation Errors
module vulnerable::exchange {
const FEE_BPS: u64 = 30; // 0.3%
const BPS_DENOMINATOR: u64 = 10000;
public struct Exchange has key {
id: UID,
accumulated_fees: u64,
}
/// VULNERABLE: Precision loss in fee calculation
public fun calculate_fee(amount: u64): u64 {
// For small amounts, this can round to 0
// amount = 100, fee = 100 * 30 / 10000 = 0
amount * FEE_BPS / BPS_DENOMINATOR
}
/// VULNERABLE: Fee-on-fee calculation
public fun swap_with_fee(
exchange: &mut Exchange,
input_amount: u64,
): u64 {
let fee = calculate_fee(input_amount);
let net_input = input_amount - fee;
// Calculate output
let output = calculate_output(net_input);
// WRONG: Taking fee again on output!
let output_fee = calculate_fee(output);
let net_output = output - output_fee;
// User pays fee twice
exchange.accumulated_fees = exchange.accumulated_fees + fee + output_fee;
net_output
}
/// VULNERABLE: Integer division ordering
public fun calculate_share(amount: u64, user_balance: u64, total_balance: u64): u64 {
// WRONG: Division before multiplication loses precision
// If user_balance < total_balance, this might return 0
amount * (user_balance / total_balance)
// Should be: (amount * user_balance) / total_balance
}
}Example 3: State Invariant Violations
module vulnerable::amm {
public struct Pool has key {
id: UID,
reserve_a: u64,
reserve_b: u64,
k: u64, // Constant product: reserve_a * reserve_b = k
}
/// VULNERABLE: k not updated after liquidity change
public entry fun add_liquidity(
pool: &mut Pool,
amount_a: u64,
amount_b: u64,
) {
pool.reserve_a = pool.reserve_a + amount_a;
pool.reserve_b = pool.reserve_b + amount_b;
// FORGOT to update k!
// pool.k = pool.reserve_a * pool.reserve_b;
// Now k invariant is broken
// Swaps will use stale k value
}
/// VULNERABLE: No check that k is maintained after swap
public entry fun swap_a_for_b(
pool: &mut Pool,
amount_a_in: u64,
): u64 {
let new_reserve_a = pool.reserve_a + amount_a_in;
// Calculate output to maintain k
// But rounding might break the invariant
let amount_b_out = pool.reserve_b - (pool.k / new_reserve_a);
pool.reserve_a = new_reserve_a;
pool.reserve_b = pool.reserve_b - amount_b_out;
// No assertion that k is still valid!
// assert!(pool.reserve_a * pool.reserve_b >= pool.k, E_K_VIOLATED);
amount_b_out
}
}Secure Examples
Secure Lending Pool
module secure::lending {
use sui::object::UID;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::clock::{Self, Clock};
public struct LendingPool has key {
id: UID,
total_deposits: u64,
total_borrows: u64,
interest_rate_per_ms: u64,
last_update_ms: u64,
accumulated_interest: u64,
}
/// SECURE: Always accrue interest first
fun accrue_interest_internal(pool: &mut LendingPool, clock: &Clock) {
let now = clock::timestamp_ms(clock);
let elapsed = now - pool.last_update_ms;
if (elapsed > 0) {
let interest = (pool.total_borrows as u128)
* (pool.interest_rate_per_ms as u128)
* (elapsed as u128)
/ 1_000_000_000_000;
pool.accumulated_interest = pool.accumulated_interest + (interest as u64);
pool.last_update_ms = now;
}
}
/// SECURE: Interest accrued before state change
public entry fun deposit(
pool: &mut LendingPool,
payment: Coin<SUI>,
clock: &Clock,
) {
// FIRST: Accrue interest on existing state
accrue_interest_internal(pool, clock);
// THEN: Update deposits
let amount = coin::value(&payment);
pool.total_deposits = pool.total_deposits + amount;
// ... store payment
}
/// SECURE: Consistent ordering
public entry fun withdraw(
pool: &mut LendingPool,
amount: u64,
clock: &Clock,
ctx: &mut TxContext
) {
// FIRST: Accrue interest
accrue_interest_internal(pool, clock);
// THEN: Process withdrawal
assert!(pool.total_deposits >= amount, E_INSUFFICIENT_LIQUIDITY);
pool.total_deposits = pool.total_deposits - amount;
}
}Secure Fee Calculations
module secure::exchange {
const FEE_BPS: u64 = 30;
const BPS_DENOMINATOR: u64 = 10000;
const MIN_FEE: u64 = 1; // Minimum fee to prevent zero-fee exploits
/// SECURE: Handle precision loss
public fun calculate_fee(amount: u64): u64 {
let fee = (amount * FEE_BPS) / BPS_DENOMINATOR;
// Ensure minimum fee on non-zero amounts
if (amount > 0 && fee == 0) {
MIN_FEE
} else {
fee
}
}
/// SECURE: Use u128 for intermediate calculations
public fun calculate_share(
amount: u64,
user_balance: u64,
total_balance: u64
): u64 {
if (total_balance == 0) {
return 0
};
// Use u128 to prevent overflow and maintain precision
let result = ((amount as u128) * (user_balance as u128)) / (total_balance as u128);
(result as u64)
}
/// SECURE: Single fee, clear calculation
public fun swap_with_fee(
exchange: &mut Exchange,
input_amount: u64,
): u64 {
assert!(input_amount > 0, E_ZERO_INPUT);
let fee = calculate_fee(input_amount);
let net_input = input_amount - fee;
// Calculate output — no additional fee
let output = calculate_output(net_input);
exchange.accumulated_fees = exchange.accumulated_fees + fee;
output
}
}Secure AMM with Invariant Checks
module secure::amm {
const E_K_VIOLATED: u64 = 1;
const E_ZERO_LIQUIDITY: u64 = 2;
const E_SLIPPAGE: u64 = 3;
public struct Pool has key {
id: UID,
reserve_a: u64,
reserve_b: u64,
}
/// Calculate k from current reserves
fun get_k(pool: &Pool): u128 {
(pool.reserve_a as u128) * (pool.reserve_b as u128)
}
/// SECURE: Update reserves and verify invariant
public entry fun add_liquidity(
pool: &mut Pool,
amount_a: u64,
amount_b: u64,
) {
assert!(amount_a > 0 && amount_b > 0, E_ZERO_LIQUIDITY);
// For existing pool, require proportional deposit
if (pool.reserve_a > 0) {
let expected_b = ((amount_a as u128) * (pool.reserve_b as u128))
/ (pool.reserve_a as u128);
// Allow small deviation for rounding
assert!(
amount_b >= (expected_b as u64) - 1 &&
amount_b <= (expected_b as u64) + 1,
E_SLIPPAGE
);
};
pool.reserve_a = pool.reserve_a + amount_a;
pool.reserve_b = pool.reserve_b + amount_b;
}
/// SECURE: Verify k maintained after swap
public entry fun swap_a_for_b(
pool: &mut Pool,
amount_a_in: u64,
min_b_out: u64,
): u64 {
let k_before = get_k(pool);
let new_reserve_a = pool.reserve_a + amount_a_in;
// Calculate output using u128 for precision
let new_reserve_b = (k_before / (new_reserve_a as u128)) as u64;
let amount_b_out = pool.reserve_b - new_reserve_b;
// Slippage check
assert!(amount_b_out >= min_b_out, E_SLIPPAGE);
// Update reserves
pool.reserve_a = new_reserve_a;
pool.reserve_b = new_reserve_b;
// CRITICAL: Verify k invariant (with tolerance for rounding)
let k_after = get_k(pool);
assert!(k_after >= k_before, E_K_VIOLATED);
amount_b_out
}
}Logic Error Prevention Patterns
Pattern 1: Check-Effects-Interactions
public entry fun secure_operation(state: &mut State, input: u64) {
// 1. CHECKS - Validate all preconditions
assert!(input > 0, E_ZERO_INPUT);
assert!(state.balance >= input, E_INSUFFICIENT);
// 2. EFFECTS - Update state
state.balance = state.balance - input;
state.processed = state.processed + 1;
// 3. INTERACTIONS - External calls last
emit_event(...);
}Pattern 2: Invariant Assertions
/// Always assert invariants at function end
public entry fun modify_state(state: &mut State, ...) {
// ... make changes
// Assert invariants before returning
assert_invariants(state);
}
fun assert_invariants(state: &State) {
assert!(state.total == state.a + state.b + state.c, E_TOTAL_MISMATCH);
assert!(state.balance >= state.minimum_required, E_UNDERCOLLATERALIZED);
}Pattern 3: Use Receipts for Multi-Step Operations
/// Hot potato pattern for operations that must complete
public struct OperationReceipt {
expected_outcome: u64,
}
public fun start_operation(state: &mut State, amount: u64): OperationReceipt {
state.locked = state.locked + amount;
OperationReceipt { expected_outcome: calculate_expected(amount) }
}
public fun finish_operation(state: &mut State, receipt: OperationReceipt, actual: u64) {
let OperationReceipt { expected_outcome } = receipt;
assert!(actual >= expected_outcome, E_UNEXPECTED_OUTCOME);
// Receipt consumed — operation must complete
}Testing Checklist
- Test all functions with zero values
- Test with maximum (u64::MAX) values
- Verify interest/fee calculations across time boundaries
- Test invariants hold after every state-changing operation
- Test PTB with reordered calls
- Verify precision in all arithmetic with small and large values