27. Event State Inconsistency

Overview

Event state inconsistency occurs when emitted events don’t accurately reflect the actual on-chain state changes. In Sui Move, events are the primary mechanism for off-chain systems (indexers, frontends, analytics) to track what happened on-chain. When events are missing, duplicated, emitted before state changes, or contain incorrect data, off-chain systems build an incorrect view of the protocol state.

Risk Level

Medium to High — Can lead to incorrect off-chain state, failed integrations, or user confusion.

OWASP / CWE Mapping

OWASP Top 10 MITRE CWE
A09 (Security Logging and Monitoring Failures) CWE-778 (Insufficient Logging), CWE-223 (Omission of Security-relevant Information)

The Problem

Common Event State Issues

Issue Risk Description
Event before state change High Event emitted but state change aborts
Missing events High State changes without corresponding events
Incorrect event data High Event values don’t match actual changes
Duplicate events Medium Same event emitted multiple times
Event ordering issues Medium Events don’t reflect execution order

Vulnerable Example

module vulnerable::exchange {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use sui::event;
    use sui::coin::{Self, Coin};
    use sui::balance::{Self, Balance};

    const E_INSUFFICIENT_BALANCE: u64 = 1;
    const E_INVALID_AMOUNT: u64 = 2;

    public struct Pool<phantom T> has key {
        id: UID,
        balance: Balance<T>,
        total_swaps: u64,
    }

    public struct SwapEvent has copy, drop {
        pool_id: ID,
        user: address,
        amount_in: u64,
        amount_out: u64,
    }

    /// VULNERABLE: Event emitted BEFORE state changes
    public entry fun swap<T, U>(
        pool_in: &mut Pool<T>,
        pool_out: &mut Pool<U>,
        coin_in: Coin<T>,
        min_out: u64,
        ctx: &mut TxContext
    ) {
        let amount_in = coin::value(&coin_in);
        let amount_out = calculate_output(amount_in);

        // VULNERABLE: Event emitted before validation and state change
        event::emit(SwapEvent {
            pool_id: object::id(pool_in),
            user: tx_context::sender(ctx),
            amount_in,
            amount_out,
        });

        // These assertions might fail AFTER event was emitted!
        assert!(amount_out >= min_out, E_INVALID_AMOUNT);
        assert!(balance::value(&pool_out.balance) >= amount_out, E_INSUFFICIENT_BALANCE);

        // State changes happen after event
        balance::join(&mut pool_in.balance, coin::into_balance(coin_in));
        let out_balance = balance::split(&mut pool_out.balance, amount_out);
        let coin_out = coin::from_balance(out_balance, ctx);

        transfer::public_transfer(coin_out, tx_context::sender(ctx));
    }
}

module vulnerable::auction {
    use sui::event;

    public struct BidEvent has copy, drop {
        auction_id: ID,
        bidder: address,
        amount: u64,
        is_winning: bool,  // VULNERABLE: May be wrong
    }

    /// VULNERABLE: Event data may not reflect final state
    public entry fun place_bid(
        auction: &mut Auction,
        amount: u64,
        ctx: &mut TxContext
    ) {
        let bidder = tx_context::sender(ctx);

        // Emit with current assumption
        let is_winning = amount > auction.highest_bid;

        event::emit(BidEvent {
            auction_id: object::id(auction),
            bidder,
            amount,
            is_winning,  // Could be wrong if concurrent bids
        });

        // State update
        if (amount > auction.highest_bid) {
            auction.highest_bid = amount;
            auction.highest_bidder = bidder;
        };
        // is_winning in event might not match actual outcome!
    }
}

module vulnerable::vault {
    use sui::event;

    public struct DepositEvent has copy, drop {
        vault_id: ID,
        user: address,
        amount: u64,
        new_balance: u64,
    }

    /// VULNERABLE: Missing event on some code paths
    public entry fun deposit(
        vault: &mut Vault,
        coins: Coin<SUI>,
        ctx: &mut TxContext
    ) {
        let amount = coin::value(&coins);

        // Early return without event!
        if (amount == 0) {
            coin::destroy_zero(coins);
            return  // No event emitted for zero deposit
        };

        balance::join(&mut vault.balance, coin::into_balance(coins));

        // Only emit for non-zero deposits
        event::emit(DepositEvent {
            vault_id: object::id(vault),
            user: tx_context::sender(ctx),
            amount,
            new_balance: balance::value(&vault.balance),
        });
    }

    /// VULNERABLE: No event at all
    public entry fun withdraw(
        vault: &mut Vault,
        amount: u64,
        ctx: &mut TxContext
    ) {
        let coins = coin::take(&mut vault.balance, amount, ctx);
        transfer::public_transfer(coins, tx_context::sender(ctx));
        // No WithdrawEvent emitted!
        // Off-chain systems won't know about withdrawals
    }
}

module vulnerable::token {
    use sui::event;

    /// VULNERABLE: Event doesn't include all relevant data
    public struct TransferEvent has copy, drop {
        token_id: ID,
        to: address,
        // Missing: from address, amount, timestamp
    }

    /// VULNERABLE: Duplicate events in loops
    public entry fun batch_transfer(
        tokens: vector<Token>,
        recipients: vector<address>,
        ctx: &mut TxContext
    ) {
        let len = vector::length(&tokens);
        let mut i = 0;

        while (i < len) {
            let token = vector::pop_back(&mut tokens);
            let recipient = *vector::borrow(&recipients, i);

            // Emitting inside loop - potential duplicate if same token transferred twice
            event::emit(TransferEvent {
                token_id: object::id(&token),
                to: recipient,
            });

            transfer::public_transfer(token, recipient);
            i = i + 1;
        };

        vector::destroy_empty(tokens);
    }
}

Secure Example

module secure::exchange {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use sui::event;
    use sui::coin::{Self, Coin};
    use sui::balance::{Self, Balance};
    use sui::clock::{Self, Clock};

    const E_INSUFFICIENT_BALANCE: u64 = 1;
    const E_INVALID_AMOUNT: u64 = 2;
    const E_SLIPPAGE_EXCEEDED: u64 = 3;

    public struct Pool<phantom T> has key {
        id: UID,
        balance: Balance<T>,
        total_swaps: u64,
    }

    /// SECURE: Comprehensive event with all relevant data
    public struct SwapExecuted has copy, drop {
        pool_in_id: ID,
        pool_out_id: ID,
        user: address,
        amount_in: u64,
        amount_out: u64,
        fee_amount: u64,
        pool_in_balance_after: u64,
        pool_out_balance_after: u64,
        timestamp_ms: u64,
        tx_digest: vector<u8>,
    }

    /// SECURE: Event emitted AFTER all state changes succeed
    public entry fun swap<T, U>(
        pool_in: &mut Pool<T>,
        pool_out: &mut Pool<U>,
        coin_in: Coin<T>,
        min_out: u64,
        clock: &Clock,
        ctx: &mut TxContext
    ) {
        let amount_in = coin::value(&coin_in);
        let fee_amount = calculate_fee(amount_in);
        let amount_out = calculate_output(amount_in - fee_amount);

        // Validate BEFORE any changes
        assert!(amount_out >= min_out, E_SLIPPAGE_EXCEEDED);
        assert!(balance::value(&pool_out.balance) >= amount_out, E_INSUFFICIENT_BALANCE);

        // Perform state changes
        balance::join(&mut pool_in.balance, coin::into_balance(coin_in));
        let out_balance = balance::split(&mut pool_out.balance, amount_out);
        let coin_out = coin::from_balance(out_balance, ctx);

        pool_in.total_swaps = pool_in.total_swaps + 1;

        // Transfer output
        let user = tx_context::sender(ctx);
        transfer::public_transfer(coin_out, user);

        // SECURE: Event emitted AFTER all changes complete
        // Event data reflects actual final state
        event::emit(SwapExecuted {
            pool_in_id: object::id(pool_in),
            pool_out_id: object::id(pool_out),
            user,
            amount_in,
            amount_out,
            fee_amount,
            pool_in_balance_after: balance::value(&pool_in.balance),
            pool_out_balance_after: balance::value(&pool_out.balance),
            timestamp_ms: clock::timestamp_ms(clock),
            tx_digest: tx_context::digest(ctx),
        });
    }
}

module secure::auction {
    use sui::event;
    use sui::clock::{Self, Clock};

    /// SECURE: Event reflects actual outcome
    public struct BidPlaced has copy, drop {
        auction_id: ID,
        bidder: address,
        bid_amount: u64,
        previous_highest: u64,
        is_new_highest: bool,
        timestamp_ms: u64,
    }

    public struct BidOutbid has copy, drop {
        auction_id: ID,
        outbid_bidder: address,
        outbid_amount: u64,
        new_highest_bidder: address,
        new_highest_amount: u64,
    }

    /// SECURE: Emit events that accurately reflect what happened
    public entry fun place_bid(
        auction: &mut Auction,
        bid_coin: Coin<SUI>,
        clock: &Clock,
        ctx: &mut TxContext
    ) {
        let bid_amount = coin::value(&bid_coin);
        let bidder = tx_context::sender(ctx);
        let previous_highest = auction.highest_bid;
        let previous_bidder = auction.highest_bidder;

        // Determine outcome first
        let is_new_highest = bid_amount > previous_highest;

        // Update state
        if (is_new_highest) {
            // Refund previous highest bidder
            if (previous_highest > 0) {
                let refund = coin::take(&mut auction.escrow, previous_highest, ctx);
                transfer::public_transfer(refund, previous_bidder);

                // Emit outbid event for previous leader
                event::emit(BidOutbid {
                    auction_id: object::id(auction),
                    outbid_bidder: previous_bidder,
                    outbid_amount: previous_highest,
                    new_highest_bidder: bidder,
                    new_highest_amount: bid_amount,
                });
            };

            auction.highest_bid = bid_amount;
            auction.highest_bidder = bidder;
            balance::join(&mut auction.escrow, coin::into_balance(bid_coin));
        } else {
            // Return bid to sender (not high enough)
            transfer::public_transfer(bid_coin, bidder);
        };

        // SECURE: Event emitted after state finalized
        event::emit(BidPlaced {
            auction_id: object::id(auction),
            bidder,
            bid_amount,
            previous_highest,
            is_new_highest,  // Now accurately reflects outcome
            timestamp_ms: clock::timestamp_ms(clock),
        });
    }
}

module secure::vault {
    use sui::event;
    use sui::clock::{Self, Clock};

    public struct DepositExecuted has copy, drop {
        vault_id: ID,
        user: address,
        amount: u64,
        balance_before: u64,
        balance_after: u64,
        timestamp_ms: u64,
    }

    public struct WithdrawExecuted has copy, drop {
        vault_id: ID,
        user: address,
        amount: u64,
        balance_before: u64,
        balance_after: u64,
        timestamp_ms: u64,
    }

    /// SECURE: Events on ALL code paths
    public entry fun deposit(
        vault: &mut Vault,
        coins: Coin<SUI>,
        clock: &Clock,
        ctx: &mut TxContext
    ) {
        let amount = coin::value(&coins);
        let balance_before = balance::value(&vault.balance);

        // Handle zero deposits consistently
        if (amount == 0) {
            coin::destroy_zero(coins);
            // Still emit event for zero deposit (for consistency)
            event::emit(DepositExecuted {
                vault_id: object::id(vault),
                user: tx_context::sender(ctx),
                amount: 0,
                balance_before,
                balance_after: balance_before,  // Unchanged
                timestamp_ms: clock::timestamp_ms(clock),
            });
            return
        };

        balance::join(&mut vault.balance, coin::into_balance(coins));
        let balance_after = balance::value(&vault.balance);

        event::emit(DepositExecuted {
            vault_id: object::id(vault),
            user: tx_context::sender(ctx),
            amount,
            balance_before,
            balance_after,
            timestamp_ms: clock::timestamp_ms(clock),
        });
    }

    /// SECURE: Withdraw has corresponding event
    public entry fun withdraw(
        vault: &mut Vault,
        amount: u64,
        clock: &Clock,
        ctx: &mut TxContext
    ) {
        let balance_before = balance::value(&vault.balance);

        let coins = coin::take(&mut vault.balance, amount, ctx);
        let user = tx_context::sender(ctx);

        transfer::public_transfer(coins, user);

        let balance_after = balance::value(&vault.balance);

        // SECURE: Withdrawal event emitted
        event::emit(WithdrawExecuted {
            vault_id: object::id(vault),
            user,
            amount,
            balance_before,
            balance_after,
            timestamp_ms: clock::timestamp_ms(clock),
        });
    }
}

module secure::token {
    use sui::event;

    /// SECURE: Comprehensive transfer event
    public struct TokenTransferred has copy, drop {
        token_id: ID,
        from: address,
        to: address,
        timestamp_ms: u64,
    }

    /// SECURE: Batch event for batch operations
    public struct BatchTransferCompleted has copy, drop {
        transfer_count: u64,
        from: address,
        timestamp_ms: u64,
    }

    /// SECURE: Efficient batch event handling
    public entry fun batch_transfer(
        tokens: vector<Token>,
        recipients: vector<address>,
        clock: &Clock,
        ctx: &mut TxContext
    ) {
        let len = vector::length(&tokens);
        assert!(len == vector::length(&recipients), E_LENGTH_MISMATCH);

        let from = tx_context::sender(ctx);
        let timestamp = clock::timestamp_ms(clock);
        let mut i = 0;

        while (i < len) {
            let token = vector::pop_back(&mut tokens);
            let recipient = *vector::borrow(&recipients, i);

            // Individual transfer event
            event::emit(TokenTransferred {
                token_id: object::id(&token),
                from,
                to: recipient,
                timestamp_ms: timestamp,
            });

            transfer::public_transfer(token, recipient);
            i = i + 1;
        };

        vector::destroy_empty(tokens);

        // Summary event for batch
        event::emit(BatchTransferCompleted {
            transfer_count: len,
            from,
            timestamp_ms: timestamp,
        });
    }
}

Event Design Patterns

Pattern 1: Before/After State in Events

public struct StateChangeEvent has copy, drop {
    object_id: ID,
    field_name: vector<u8>,
    value_before: u64,
    value_after: u64,
    changed_by: address,
}

public fun update_with_event(obj: &mut MyObject, new_value: u64, ctx: &TxContext) {
    let before = obj.value;
    obj.value = new_value;

    event::emit(StateChangeEvent {
        object_id: object::id(obj),
        field_name: b"value",
        value_before: before,
        value_after: new_value,
        changed_by: tx_context::sender(ctx),
    });
}

Pattern 2: Event Versioning

const EVENT_VERSION: u8 = 2;

public struct DepositEventV2 has copy, drop {
    version: u8,  // For indexer compatibility
    vault_id: ID,
    user: address,
    amount: u64,
    // V2 additions
    deposit_type: u8,
    referrer: Option<address>,
}

public fun emit_deposit_event(/* ... */) {
    event::emit(DepositEventV2 {
        version: EVENT_VERSION,
        // ...
    });
}

Pattern 3: Failure Events

public struct OperationFailed has copy, drop {
    operation: vector<u8>,
    user: address,
    reason: u64,
    timestamp_ms: u64,
}

public fun try_operation(/* ... */): bool {
    if (!validate_preconditions()) {
        event::emit(OperationFailed {
            operation: b"swap",
            user: tx_context::sender(ctx),
            reason: E_VALIDATION_FAILED,
            timestamp_ms: clock::timestamp_ms(clock),
        });
        return false
    };

    // Proceed with operation...
    true
}

Pattern 4: Correlation IDs

public struct OrderCreated has copy, drop {
    order_id: ID,
    correlation_id: vector<u8>,  // Link related events
    user: address,
}

public struct OrderFilled has copy, drop {
    order_id: ID,
    correlation_id: vector<u8>,  // Same correlation ID
    fill_amount: u64,
}

public struct OrderCancelled has copy, drop {
    order_id: ID,
    correlation_id: vector<u8>,  // Same correlation ID
    reason: u8,
}

1. Emit Events After State Changes

// Do all state changes first
state.value = new_value;
balance::join(&mut state.balance, deposit);

// Then emit event reflecting final state
event::emit(StateChanged { /* final values */ });

2. Include Before/After Values

event::emit(BalanceChanged {
    balance_before: old_balance,
    balance_after: new_balance,
    delta: new_balance - old_balance,
});

3. Emit Events on All Code Paths

if (condition) {
    // path A
    event::emit(PathAEvent { /* ... */ });
} else {
    // path B
    event::emit(PathBEvent { /* ... */ });
};
// No silent paths!

4. Include Timestamps and Transaction Info

event::emit(MyEvent {
    timestamp_ms: clock::timestamp_ms(clock),
    tx_digest: tx_context::digest(ctx),
    epoch: tx_context::epoch(ctx),
});

5. Use Structured Event Types

// Good: Specific event types
public struct DepositExecuted has copy, drop { /* ... */ }
public struct WithdrawExecuted has copy, drop { /* ... */ }

// Bad: Generic event with type field
public struct GenericEvent has copy, drop {
    event_type: u8,  // Harder to index/filter
}

Testing Checklist

  • Verify events are emitted after state changes complete
  • Confirm events on all code paths (including error paths)
  • Check event data matches actual state changes
  • Test that aborted transactions don’t emit events
  • Verify no duplicate events in loops
  • Confirm events include sufficient context (IDs, timestamps)
  • Test event ordering matches execution order
  • Verify before/after values are accurate