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