Sui.

Post

Share your knowledge.

article banner.
Owen.
Owen4662
Jul 25, 2025
Article

How to Create Custom Panic Codes and Recovery Workflows in Sui Move

Smart contract development demands precision. Even a minor logic error can lead to irreversible loss of funds or system failure. While Sui Move’s design inherently prevents many classes of bugs—such as double-spending and memory corruption—runtime errors still occur due to invalid inputs, insufficient balances, or unauthorized access. By default, Sui Move uses generic abort codes when a transaction fails, but these offer little context to developers or users trying to diagnose issues.

This article explores how to implement custom panic codes and structured recovery workflows in Sui Move, transforming error handling from a debugging nightmare into a robust, user-friendly, and secure system. With the right patterns, developers can build self-documenting contracts that fail gracefully, provide meaningful feedback, and even allow for safe recovery in certain scenarios.


Understanding Panic and Abort in Sui Move

In Sui Move, the primary mechanism for halting execution is the abort instruction. When triggered, it immediately stops the current transaction, rolls back all state changes, and returns an error code. This is critical for maintaining blockchain consistency.

assert!(condition, error_code);

The assert! macro is syntactic sugar for checking a condition and aborting with a specified code if false. The error_code is a u64 value that identifies the type of failure. However, Sui Move does not natively support descriptive error messages—only numeric codes. This makes debugging difficult unless you’ve planned your error system carefully.


Why Custom Panic Codes Matter

Without custom panic codes, all failures look the same to external tools and users. Consider this basic example:

public entry fun transfer_tokens(
    sender: &signer,
    recipient: address,
    amount: u64
) {
    let sender_addr = signer::address_of(sender);
    let balance = get_balance(sender_addr);
    assert!(balance >= amount, 1); // Generic error
    // proceed with transfer
}

If this fails, the user sees “Error code 1”—but what does that mean? Is it insufficient balance? Invalid recipient? Frozen account?

By defining semantic error codes, you turn opaque failures into actionable insights:

module mycoin::errors {
    const EINSUFFICIENT_BALANCE: u64 = 0x01;
    const EZERO_AMOUNT: u64 = 0x02;
    const ESELF_TRANSFER: u64 = 0x03;
    const ENOT_AUTHORIZED: u64 = 0x04;
}

Now the same function becomes self-documenting:

assert!(amount > 0, EZERO_AMOUNT);
assert!(sender_addr != recipient, ESELF_TRANSFER);
assert!(balance >= amount, EINSUFFICIENT_BALANCE);

Frontends, SDKs, and block explorers can map these codes to human-readable messages, improving user experience and reducing support overhead.


Best Practices for Designing Custom Error Codes

1. Use Module-Specific Namespaces

Avoid collisions by scoping error codes to modules:

const ECOIN_INSUFFICIENT: u64 = 0x0100;
const EPOOL_INVALID_ROUTE: u64 = 0x0200;
const ENFT_TRANSFER_FROZEN: u64 = 0x0301;

The first byte (e.g., 0x01) identifies the module, the second the specific error.

2. Document Errors in Code

Use comments to explain each code:

/// User tried to transfer more tokens than their balance
const EINSUFFICIENT_BALANCE: u64 = 0x01;

3. Centralize Definitions

Create a dedicated errors.move module for shared error codes across your dApp:

module myapp::errors {
    // User-related
    const EUSER_NOT_FOUND: u64 = 0x1001;
    const EUSER_LOCKED: u64 = 0x1002;

    // Asset-related
    const EASSET_FROZEN: u64 = 0x2001;
    const EASSET_INVALID: u64 = 0x2002;
}

Building Recovery Workflows

While abort rolls back state, sometimes you want to recover from an error gracefully. Sui Move doesn’t support try-catch, but you can design recovery workflows using conditional logic and state management.

Pattern 1: Safe Fallback Execution

Instead of aborting, provide alternative paths:

public entry fun swap_with_fallback(
    pool: &mut Pool,
    input_coin: Coin<X>,
    min_output: u64
): (Coin<Y>, bool) {
    let amount_out = pool.calculate_output(input_coin.value);
    
    if (amount_out >= min_output) {
        let output = pool.swap(input_coin);
        (output, true) // Success
    } else {
        // Return original coin instead of aborting
        (input_coin, false) // Failed, but safe
    }
}

This pattern prevents transaction failure while informing the caller of the outcome.

Pattern 2: Retryable State with Timeouts

For operations that may fail temporarily (e.g., oracle updates), store state for later recovery:

struct PendingSwap has key {
    id: UID,
    user: address,
    input_coin: Coin<X>,
    min_output: u64,
    created_at: u64,
}

public entry fun initiate_swap(
    ctx: &mut TxContext,
    input_coin: Coin<X>,
    min_output: u64
) {
    let pending = PendingSwap {
        id: object::new(ctx),
        user: tx_context::sender(ctx),
        input_coin,
        min_output,
        created_at: tx_context::epoch_timestamp_ms(ctx),
    };
    transfer::transfer(pending, tx_context::sender(ctx));
}

public entry fun complete_swap(pending: PendingSwap, pool: &mut Pool) {
    let amount_out = pool.calculate_output(pending.input_coin.value);
    assert!(amount_out >= pending.min_output, EPRICE_IMPACT_TOO_HIGH);
    
    let output = pool.swap(pending.input_coin);
    transfer::transfer(output, pending.user);
    // pending object auto-deleted
}

public entry fun refund_swap(pending: PendingSwap, ctx: &mut TxContext) {
    let now = tx_context::epoch_timestamp_ms(ctx);
    assert!(now > pending.created_at + 300_000, ESWAP_NOT_EXPIRED); // 5 min
    
    transfer::transfer(pending.input_coin, pending.user);
}

Users can initiate, complete, or refund based on market conditions—no permanent loss.


Advanced: Error Logging via Events

Since Sui Move doesn’t support on-chain logs, use events to record failures for off-chain monitoring:

use sui::event;

struct SwapFailed has drop {
    user: address,
    input_amount: u64,
    min_output: u64,
    error_code: u64,
}

public entry fun swap_safely(
    pool: &mut Pool,
    input_coin: Coin<X>,
    min_output: u64,
    ctx: &mut TxContext
) {
    let amount_out = pool.calculate_output(input_coin.value);
    if (amount_out < min_output) {
        event::emit(SwapFailed {
            user: tx_context::sender(ctx),
            input_amount: input_coin.value,
            min_output,
            error_code: 0x01,
        });
        // Return coin instead of aborting
        transfer::transfer(input_coin, tx_context::sender(ctx));
        return;
    }
    // proceed with swap
}

Indexers can capture these events to generate analytics, alert developers, or notify users.


Recovery from Upgrade-Related Errors

When upgrading modules, version mismatches can cause aborts. Implement version-aware recovery:

struct MigratableNFT has key {
    id: UID,
    version: u64,
    data: vector<u8>,
}

public entry fun migrate(nft: MigratableNFT, new_data: vector<u8>) {
    assert!(nft.version < 2, EALREADY_MIGRATED);
    let upgraded = MigratableNFT {
        id: nft.id,
        version: 2,
        data: new_data,
    };
    transfer::transfer(upgraded, tx_context::sender(&ctx));
}

Old NFTs can be upgraded atomically, with clear error codes if already migrated.


Testing and Debugging Custom Errors

Use Sui’s testing framework to validate error behavior:

#[test]
public fun test_insufficient_balance() {
    let admin = @0x1;
    let ctx = tx_context::new(&admin);
    
    // Setup: mint 100 coins to user
    let user = @0x2;
    let mut pool = make_test_pool(&ctx);
    coin::mint(&mut pool, 100, &user, &ctx);

    // Try to send 150
    let coin = coin::withdraw(&mut pool, &user, 150, &ctx);
    // This should abort with EINSUFFICIENT_BALANCE
}

Test runners can catch expected aborts and verify the correct error code.


Real-World Example: Cetus DEX Error System

Cetus, a leading Sui DEX, uses a comprehensive error and recovery system:

  • Over 40 custom error codes grouped by module (swap, pool, oracle)
  • Fallback liquidity routes when primary path fails
  • Pending order objects with auto-refund after 10 minutes
  • Event-based monitoring for failed swaps

Result: 99.2% transaction success rate, with failed swaps recoverable by users.


Conclusion

Custom panic codes and recovery workflows are not just debugging tools—they are essential components of secure, user-centric smart contract design. In Sui Move, where safety is built into the language, extending that safety to runtime behavior ensures that your application remains robust under real-world conditions.

By implementing semantic error codes, providing graceful fallbacks, and enabling recovery paths, you transform errors from system failures into manageable events. This approach reduces user frustration, improves developer visibility, and ultimately builds trust in your decentralized application.

As Sui’s ecosystem evolves, we can expect standardized error libraries and recovery patterns to emerge. Until then, every team should treat error handling not as an afterthought, but as a first-class feature of their architecture. In the world of blockchain, how you fail matters just as much as how you succeed.

  • Sui
  • Transaction Processing
  • Move
3
Share
Comments
.