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.Ifstep2fails,stateisinconsistent
Vulnerable Example
modulevulnerable::escrow{usesui::object::{Self,UID};usesui::tx_context::TxContext;publicstructEscrowhaskey{id: UID,deposited: u64,refund_pending: bool,}publicstructUserBalancehaskey{id: UID,balance: u64,}/// VULNERABLE: Debit before credit confirmed
publicentryfundeposit_to_escrow(user: &mutUserBalance,escrow: &mutEscrow,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
publicentryfunrequest_refund(escrow: &mutEscrow){escrow.refund_pending=true;}/// VULNERABLE: Process refund without proper state checks
publicentryfunprocess_refund(user: &mutUserBalance,escrow: &mutEscrow,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;}}modulevulnerable::trading{/// VULNERABLE: Partial fill can leave orders in bad state
publicentryfunfill_order(order: &mutOrder,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
letrequired=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
publicentryfunundo_fill(order: &mutOrder,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
modulesecure::escrow{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::coin::{Self,Coin};usesui::sui::SUI;publicstructEscrowhaskey{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
publicentryfundeposit(depositor_coins: Coin<SUI>,beneficiary: address,ctx: &mutTxContext){// All validation first
letamount=coin::value(&depositor_coins);assert!(amount>0,E_ZERO_AMOUNT);// Single atomic state creation
letescrow=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
publicentryfunrelease(escrow: Escrow,ctx: &TxContext){letEscrow{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
publicentryfunrefund(escrow: Escrow,ctx: &TxContext){letEscrow{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);}}modulesecure::trading{usesui::object::{Self,UID,ID};usesui::coin::{Self,Coin};usesui::sui::SUI;publicstructOrderhaskey{id: UID,maker: address,amount: u64,price: u64,coins_escrowed: Coin<SUI>,}/// Hot potato ensures fill completes
publicstructFillReceipt{order_id: ID,fill_amount: u64,payment_required: u64,}/// SECURE: Start fill, get receipt
publicfunstart_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
publicfuncomplete_fill(order: &mutOrder,receipt: FillReceipt,payment: Coin<SUI>,ctx: &mutTxContext): Coin<SUI>{letFillReceipt{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
letfilled_coins=coin::split(&mutorder.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
publicentryfunoperation(state: &mutState,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(&mutstate.treasury,payment);}
// BAD: Undo can be called by attacker
publicentryfunundo_action(state: &mutState,...){}// GOOD: Undo is internal only
funinternal_undo(state: &mutState,...){}// GOOD: Or use hot potato that must be consumed
publicstructAction{}publicfuncommit_action(action: Action){letAction{}=action;}
Pattern 4: Atomic Object Operations
/// Use object lifecycle for atomicity
publicentryfunprocess(item: Item,// Consume object
payment: Coin<SUI>,ctx: &mutTxContext){// Validate payment
assert!(coin::value(&payment)>=item.price,E_INSUFFICIENT);// Destroy old object, create new
letItem{id,data,price: _}=item;object::delete(id);// Create result
letresult=ProcessedItem{id: object::new(ctx),data};transfer::transfer(result,tx_context::sender(ctx));}
Recommended Mitigations
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
publicfunstart(): Receipt{}// Finish consumes receipt — cannot skip
publicfunfinish(receipt: Receipt,payment: Coin){}
3. Never Provide Public Undo Functions
// If undo is needed, make it internal and time-locked
funinternal_undo(...){}// Or require admin capability
publicentryfunemergency_undo(admin_cap: &AdminCap,...){}
4. Make Operations Atomic
// Instead of debit() then credit()
publicentryfuntransfer(from: &mutBalance,to: &mutBalance,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