Community Vote

I spent the last three weeks building Frontier Flow, a low-code programming environment for EVE Frontier. I believe this is a gamechanger for the future of Frontier. Please consider voting for me. Every vote counts.

18. PTB Refund Issues

Overview

Improper refund or undo patterns in Programmable Transaction Blocks (PTBs) can leave state inconsistent when partial execution occurs. Write-then-undo patterns are particularly dangerous as they can be exploited.

Risk Level

Medium — Can lead to inconsistent state and protocol manipulation.

OWASP / CWE Mapping

OWASP Top 10 MITRE CWE
A04 (Insecure Design) CWE-841 (Improper Enforcement of Behavioral Workflow), CWE-662 (Improper Synchronization)

The Problem

In PTBs, if a later operation fails, earlier operations are NOT rolled back within custom logic. Sui’s atomicity ensures the transaction fails entirely, but if your code has a “refund” or “undo” function, partial execution becomes possible.

Dangerous Pattern

1. debit(account, 100)    // Subtract from balance
2. credit(other, 100)     // Add to other (might fail)
3. If step 2 fails, state is inconsistent

Vulnerable Example

module vulnerable::escrow {
    use sui::object::{Self, UID};
    use sui::tx_context::TxContext;

    public struct Escrow has key {
        id: UID,
        deposited: u64,
        refund_pending: bool,
    }

    public struct UserBalance has key {
        id: UID,
        balance: u64,
    }

    /// VULNERABLE: Debit before credit confirmed
    public entry fun deposit_to_escrow(
        user: &mut UserBalance,
        escrow: &mut Escrow,
        amount: u64,
    ) {
        // Debit happens first
        assert!(user.balance >= amount, E_INSUFFICIENT);
        user.balance = user.balance - amount;

        // Credit to escrow
        // If this somehow fails (e.g., invariant check),
        // user lost funds with no escrow increase
        escrow.deposited = escrow.deposited + amount;
    }

    /// VULNERABLE: Refund can be called independently
    public entry fun request_refund(escrow: &mut Escrow) {
        escrow.refund_pending = true;
    }

    /// VULNERABLE: Process refund without proper state checks
    public entry fun process_refund(
        user: &mut UserBalance,
        escrow: &mut Escrow,
        amount: u64,
    ) {
        assert!(escrow.refund_pending, E_NO_REFUND);

        // Credit user first
        user.balance = user.balance + amount;

        // Then debit escrow — what if this fails?
        assert!(escrow.deposited >= amount, E_INSUFFICIENT_ESCROW);
        escrow.deposited = escrow.deposited - amount;
    }
}

module vulnerable::trading {
    /// VULNERABLE: Partial fill can leave orders in bad state
    public entry fun fill_order(
        order: &mut Order,
        fill_amount: u64,
        payment: Coin<SUI>,
    ) {
        // Update order first
        order.filled = order.filled + fill_amount;
        order.status = if (order.filled == order.amount) { 1 } else { 0 };

        // Process payment — might fail
        let required = fill_amount * order.price;
        assert!(coin::value(&payment) >= required, E_INSUFFICIENT);

        // If payment assertion fails, order.filled is already updated!
    }

    /// VULNERABLE: Undo function can be called in PTB
    public entry fun undo_fill(
        order: &mut Order,
        undo_amount: u64,
    ) {
        // Attacker can call fill() then undo() in same PTB
        // Getting the goods without actually paying
        order.filled = order.filled - undo_amount;
    }
}

Attack: PTB Partial Execution

// Attacker's PTB
Transaction {
    commands: [
        // Fill order without proper payment
        Call(trading::fill_order, [order, 1000, insufficient_payment]),
        // If above fails, no problem — atomic rollback

        // Or: Fill then immediately undo
        Call(trading::fill_order, [order, 1000, payment]),
        Call(trading::undo_fill, [order, 1000]),
        // Attacker got something for nothing
    ]
}

Secure Example

module secure::escrow {
    use sui::object::{Self, UID, ID};
    use sui::tx_context::{Self, TxContext};
    use sui::coin::{Self, Coin};
    use sui::sui::SUI;

    public struct Escrow has key {
        id: UID,
        depositor: address,
        beneficiary: address,
        coins: Coin<SUI>,
        state: u8,  // 0 = active, 1 = released, 2 = refunded
    }

    /// SECURE: Check-then-write, all in one atomic operation
    public entry fun deposit(
        depositor_coins: Coin<SUI>,
        beneficiary: address,
        ctx: &mut TxContext
    ) {
        // All validation first
        let amount = coin::value(&depositor_coins);
        assert!(amount > 0, E_ZERO_AMOUNT);

        // Single atomic state creation
        let escrow = Escrow {
            id: object::new(ctx),
            depositor: tx_context::sender(ctx),
            beneficiary,
            coins: depositor_coins,
            state: 0,
        };

        transfer::share_object(escrow);
    }

    /// SECURE: Atomic release — all or nothing
    public entry fun release(
        escrow: Escrow,
        ctx: &TxContext
    ) {
        let Escrow { id, depositor, beneficiary, coins, state } = escrow;

        // All checks before any state change
        assert!(tx_context::sender(ctx) == depositor, E_NOT_DEPOSITOR);
        assert!(state == 0, E_ALREADY_PROCESSED);

        // Atomic: destroy escrow and transfer coins
        object::delete(id);
        transfer::public_transfer(coins, beneficiary);
    }

    /// SECURE: Atomic refund
    public entry fun refund(
        escrow: Escrow,
        ctx: &TxContext
    ) {
        let Escrow { id, depositor, beneficiary: _, coins, state } = escrow;

        assert!(tx_context::sender(ctx) == depositor, E_NOT_DEPOSITOR);
        assert!(state == 0, E_ALREADY_PROCESSED);

        object::delete(id);
        transfer::public_transfer(coins, depositor);
    }
}

module secure::trading {
    use sui::object::{Self, UID, ID};
    use sui::coin::{Self, Coin};
    use sui::sui::SUI;

    public struct Order has key {
        id: UID,
        maker: address,
        amount: u64,
        price: u64,
        coins_escrowed: Coin<SUI>,
    }

    /// Hot potato ensures fill completes
    public struct FillReceipt {
        order_id: ID,
        fill_amount: u64,
        payment_required: u64,
    }

    /// SECURE: Start fill, get receipt
    public fun start_fill(
        order: &Order,
        fill_amount: u64,
    ): FillReceipt {
        assert!(fill_amount <= order.amount, E_OVERFILL);

        FillReceipt {
            order_id: object::id(order),
            fill_amount,
            payment_required: fill_amount * order.price,
        }
    }

    /// SECURE: Complete fill with receipt and payment
    public fun complete_fill(
        order: &mut Order,
        receipt: FillReceipt,
        payment: Coin<SUI>,
        ctx: &mut TxContext
    ): Coin<SUI> {
        let FillReceipt { order_id, fill_amount, payment_required } = receipt;

        // Verify receipt matches order
        assert!(order_id == object::id(order), E_WRONG_ORDER);

        // Verify payment
        assert!(coin::value(&payment) >= payment_required, E_INSUFFICIENT);

        // All validated — now update state
        order.amount = order.amount - fill_amount;

        // Return escrowed coins to taker
        let filled_coins = coin::split(&mut order.coins_escrowed, fill_amount, ctx);

        // Payment to maker
        transfer::public_transfer(payment, order.maker);

        filled_coins
    }

    // NO undo_fill function — fills are final
}

Safe State Update Patterns

Pattern 1: Check-Effects-Interactions

public entry fun operation(state: &mut State, input: u64, payment: Coin<SUI>) {
    // 1. CHECKS - All validation
    assert!(input > 0, E_ZERO_INPUT);
    assert!(coin::value(&payment) >= calculate_cost(input), E_INSUFFICIENT);

    // 2. EFFECTS - Update state
    state.processed = state.processed + input;

    // 3. INTERACTIONS - External transfers last
    coin::join(&mut state.treasury, payment);
}

Pattern 2: Hot Potato for Multi-Step

/// Receipt ensures operation completes
public struct OperationReceipt {
    required_payment: u64,
}

public fun start(amount: u64): OperationReceipt {
    OperationReceipt { required_payment: amount * PRICE }
}

public fun finish(receipt: OperationReceipt, payment: Coin<SUI>) {
    let OperationReceipt { required_payment } = receipt;
    assert!(coin::value(&payment) >= required_payment, E_INSUFFICIENT);
    // Operation guaranteed to complete
}

Pattern 3: No External Undo Functions

// BAD: Undo can be called by attacker
public entry fun undo_action(state: &mut State, ...) { }

// GOOD: Undo is internal only
fun internal_undo(state: &mut State, ...) { }

// GOOD: Or use hot potato that must be consumed
public struct Action { }
public fun commit_action(action: Action) { let Action {} = action; }

Pattern 4: Atomic Object Operations

/// Use object lifecycle for atomicity
public entry fun process(
    item: Item,  // Consume object
    payment: Coin<SUI>,
    ctx: &mut TxContext
) {
    // Validate payment
    assert!(coin::value(&payment) >= item.price, E_INSUFFICIENT);

    // Destroy old object, create new
    let Item { id, data, price: _ } = item;
    object::delete(id);

    // Create result
    let result = ProcessedItem { id: object::new(ctx), data };
    transfer::transfer(result, tx_context::sender(ctx));
}

1. Use Check-Then-Write Pattern

// All checks before any writes
assert!(condition1, E1);
assert!(condition2, E2);
assert!(condition3, E3);
// Now safe to modify state
state.value = new_value;

2. Use Hot Potatoes for Finalization

// Start returns receipt that must be consumed
public fun start(): Receipt { }
// Finish consumes receipt — cannot skip
public fun finish(receipt: Receipt, payment: Coin) { }

3. Never Provide Public Undo Functions

// If undo is needed, make it internal and time-locked
fun internal_undo(...) { }

// Or require admin capability
public entry fun emergency_undo(admin_cap: &AdminCap, ...) { }

4. Make Operations Atomic

// Instead of debit() then credit()
public entry fun transfer(from: &mut Balance, to: &mut Balance, amount: u64) {
    // Single function, single transaction
    assert!(from.value >= amount, E_INSUFFICIENT);
    from.value = from.value - amount;
    to.value = to.value + amount;
}

Testing Checklist

  • Test what happens if a function aborts mid-execution
  • Verify no “undo” functions can be called in PTBs
  • Test that hot potatoes cannot be dropped
  • Verify state remains consistent after any abort
  • Test PTBs with reordered operations