How Sui Move rethinks flash loan security
Flash loans, a fundamental DeFi primitive that enables collateral-free borrowing as long as repayment occurs within the same transaction, have historically been a double-edged sword. While they allow honest borrowers to perform arbitrage and debt refinancing, they have also enabled attackers to amplify the impact of their exploits and increase the amount of funds stolen. We found that Sui’s Move language significantly improves flash loan security by replacing Solidity’s reliance on callbacks and runtime checks with a “hot potato” model that enforces repayment at the compiler level. This shift makes flash loan security a language guarantee rather than a developer responsibility.
This post analyzes the flash loan implementation from DeepBookV3, Sui’s native order book DEX. We compare Sui’s implementation to common Solidity patterns and show how Move’s design philosophy of making security the default rather than the developer’s responsibility provides stronger safety guarantees while simplifying the developer experience.
The Solidity approach: Callbacks and runtime checks
Solidity flash loan protocols traditionally rely on a callback pattern, which provides maximum flexibility but places the entire security burden on developers. The process requires the lending protocol to temporarily trust the borrower before it can validate repayment.
The typical flow involves these steps:
- A borrower contract calls
flashLoan
on the lending protocol. - The protocol transfers the tokens to the borrower contract.
- The protocol then calls an
onFlashLoan
function on the borrower contract. - The borrower contract performs its logic with the borrowed tokens.
- The borrower contract repays the loan.
- The original lending protocol checks its balance to confirm repayment and reverts the entire transaction if the funds haven’t been returned.
This callback-based model places security responsibilities on the lending protocol developer, who must implement a balance check at the end of the function to ensure the safety of the loan (figure 2). Because the protocol makes an external call to the borrower’s contract, developers must carefully manage the state to prevent reentrancy risks. The Fei Protocol developers learned this lesson at a cost of $80M in 2022 when a hacker exploited a flaw in the system (in particular, that it did not follow the check-effect-interaction (CEI) pattern) to borrow funds and then withdraw their collateral before the borrow was recorded Even the borrower can be at risk if the access control on their receiver contract isn’t properly implemented.
function flashLoan(uint256 amount, address borrowerContract) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transfer(borrowerContract, amount);
borrowerContract.onFlashloan();
if (token.balanceOf(address(this)) < balanceBefore) {
revert RepayFailed();
}
}
Additionally, the lack of a standard interface initially caused fragmentation. Although EIP-3156 later proposed a standard for single-asset flash loans, where the lender pulls the funds back from the borrower instead of expecting the funds to be sent by the borrower, it has yet to be adopted by all major DeFi protocols and comes with its own set of security challenges.
The Sui Move approach: Composable safety
Sui’s implementation of flash loans is fundamentally different. It leverages two core features of the platform—a unique object model and Programmable Transaction Blocks (PTBs)—to provide flash loan security at the language level.
Sui’s object model and Move’s abilities
To understand Move’s safety guarantees, one must first understand Sui’s object model. In Ethereum’s account-based model, a token balance is just a number in a ledger (the ERC20 contract) that keeps track of who owns what. A user’s wallet doesn’t hold the tokens directly, but instead holds a key that allows it to ask the central contract what its balance is.
In contrast, Sui’s object-centric model treats every asset (a token, an NFT, admin rights, or a liquidity pool position) as a distinct, independent object. In Sui, everything is an object, carrying properties, ownership rights, and the ability to be transferred or modified. A user’s account directly owns these objects. There is no central contract ledger; ownership is a direct relationship between the account and the object itself.
This object-centric approach (which is specific to Sui, not the Move language itself) is what enables parallel transaction processing and allows objects to be passed directly as arguments to functions. This is where Move’s abilities system comes into play. Abilities are compile-time attributes that define how an object can be used.
There are four key abilities:
key
: Allows the object to be used as a key in a storage.store
: Allows the object to be stored in objects that have the key ability.copy
: Allows the object to be copied.drop
: Allows the object to be discarded or ignored at the end of a transaction.
In the case of our flash loan, the key advantage comes from omitting abilities. An object with no abilities cannot be stored, copied, or dropped. It becomes a “hot potato”: a temporary proof or receipt that must be consumed by another function within the same transaction. In Move, “consuming” an object means passing it to a function that takes ownership and destroys it, removing it from circulation. If it isn’t, the transaction is invalid and will not execute. While Move’s abilities system provides the safety mechanism for flash loans, Sui’s PTBs enable the composability that makes them practical.
How PTBs work
In Ethereum, until EIP-7702 (account abstraction) becomes the norm, interactions with DeFi protocols require multiple, separate transactions (e.g., one for token approval and another for the swap). This creates friction and potential failure points.
Sui’s PTBs solve this by allowing multiple operations to be chained into a single, atomic transaction. While this may sound like Solidity’s multicall()
pattern, PTBs are natively integrated and far more powerful. The key difference is that PTBs allow the output of one operation to be used as the input for the next, all within the same block.
Here is an example, through the Sui CLI, of a flash loan arbitrage that uses the results from the previous transaction command in the subsequent one. (Note that actual function signatures and parameters would be more complex.)
# This PTB borrows from one DEX, swaps on two others, and repays the loan all in one atomic transaction
$ sui client ptb \
# 0 - Borrow 1,000 USDC (returns: borrowed_coin, receipt)
--move-call $DEEPBOOK::vault::borrow_flashloan_base @$POOL 1000000000 \
# 1 - Swap USDC→SUI using borrowed_coin from step 0
--move-call $CETUS::swap result(0,0) @$CETUS_POOL \
# 2 - Swap SUI→USDC using SUI from step 1
--move-call $TURBOS::swap result(1,0) @$TURBOS_POOL \
# 3 - Split repayment amount from total USDC
--move-call 0x2::coin::split result(2,0) 1000000000 \
# 4 - Repay using split coin and receipt from step 0
--move-call $DEEPBOOK::vault::return_flashloan_base @$POOL result(3,0) result(0,1) \
# 5 - Send remaining profit to user
--transfer-objects [result(2,0)] @$SENDER
This atomic execution model is the foundation for Sui’s flash loans, but the safety mechanism lies in how the Move language handles assets.
The hot potato pattern in action: DeepBookV3
DeepBookV3’s flash loan implementation uses this “hot potato” pattern to create a secure system that requires no callbacks or runtime balance checks.
The flow is simple:
- A user calls
borrow_flashloan_base
. - The function returns two objects
(Coin<BaseAsset>, FlashLoan)
: theCoin
object for the borrowed funds and aFlashLoan
receipt object. - The user performs operations with the
Coin
. - The user calls
return_flashloan_base
, passing back the borrowed funds and theFlashLoan
receipt. - This final function consumes the receipt, and the transaction successfully completes.
Let’s look at the code of the borrow_flashloan_base
function that returns the borrowed assets and the FlashLoan
struct:
public(package) fun borrow_flashloan_base<BaseAsset, QuoteAsset>(
self: &mut Vault<BaseAsset, QuoteAsset>,
pool_id: ID,
borrow_quantity: u64,
ctx: &mut TxContext,
): (Coin<BaseAsset>, FlashLoan) {
assert!(borrow_quantity > 0, EInvalidLoanQuantity);
assert!(self.base_balance.value() >= borrow_quantity, ENotEnoughBaseForLoan);
let borrow_type_name = type_name::get<BaseAsset>();
let borrow: Coin<BaseAsset> = self.base_balance.split(borrow_quantity).into_coin(ctx);
let flash_loan = FlashLoan {
pool_id,
borrow_quantity,
type_name: borrow_type_name,
};
event::emit(FlashLoanBorrowed {
pool_id,
borrow_quantity,
type_name: borrow_type_name,
});
(borrow, flash_loan)
}
The trick lies in the definition of the FlashLoan
struct. Notice what’s missing?… No abilities!
public struct FlashLoan {
pool_id: ID,
borrow_quantity: u64,
type_name: TypeName,
}
Because this struct is a “hot potato,” the only way for a transaction to be valid is to consume it by passing it to the corresponding return_flashloan_base
function, which destroys it.
public(package) fun return_flashloan_base<BaseAsset, QuoteAsset>(
self: &mut Vault<BaseAsset, QuoteAsset>,
pool_id: ID,
coin: Coin<BaseAsset>,
flash_loan: FlashLoan,
) {
assert!(pool_id == flash_loan.pool_id, EIncorrectLoanPool);
assert!(type_name::get<BaseAsset>() == flash_loan.type_name, EIncorrectTypeReturned);
assert!(coin.value() == flash_loan.borrow_quantity, EIncorrectQuantityReturned);
self.base_balance.join(coin.into_balance<BaseAsset>());
let FlashLoan {
pool_id: _,
borrow_quantity: _,
type_name: _,
} = flash_loan;
}
How the hot potato pattern ensures repayment
This pattern, combined with PTBs’ atomicity, creates built-in safety guarantees. Instead of relying on runtime checks, the Move compiler prevents invalid transactions from ever being executed.
For instance, if a transaction calls borrow_flashloan_base
but does not subsequently consume the returned FlashLoan
object, the transaction is invalid and fails. Because the struct lacks the drop
ability, it cannot be discarded. Since it also cannot be stored or transferred, the transaction logic is incomplete, and the entire operation fails before it can be processed.
Similarly, if a developer constructs a PTB that borrows funds but omits the final return_flashloan_base
call, the transaction is invalid as well. The MoveVM identifies the unhandled “hot potato” and aborts the entire transaction, reverting all preceding operations.
Failing to repay is not a risk that needs to be prevented by the developers, but a logical impossibility that the system prevents by design. A valid, executable transaction must include the repayment logic.
Implementing safety by design
With Sui Move, the compiler itself becomes the primary security guard. Where Solidity requires developers to implement runtime checks and careful state management to prevent exploits, Move’s type system makes unsafe code difficult to write for this use case in the first place. A parallel can be drawn with Rust’s safety model: just as Rust’s compiler guarantees memory safety, Sui Move’s type system guarantees asset safety. This model shifts security enforcement from developer-implemented runtime checks to the language’s own compile-time rules.