Transfer API misuse occurs when developers incorrectly use Sui’s object transfer functions, leading to objects being sent to wrong addresses, locked permanently, or transferred with incorrect ownership semantics. Sui provides multiple transfer functions (transfer::transfer, transfer::public_transfer, transfer::share_object, transfer::freeze_object) each with specific requirements and behaviors that must be understood.
Risk Level
Critical — Can result in permanent loss of assets or complete protocol failure.
OWASP / CWE Mapping
OWASP Top 10
MITRE CWE
A01 (Broken Access Control)
CWE-284 (Improper Access Control)
The Problem
Common Transfer API Issues
Issue
Risk
Description
Using transfer without store
Critical
Compile error or runtime panic
Sharing owned objects incorrectly
High
Can break ownership invariants
Freezing mutable state
Critical
Permanently locks needed functionality
Transfer to wrong address
Critical
Assets sent to unrecoverable address
public_transfer vs transfer confusion
High
Security implications differ
Transfer API Quick Reference
Function
Requires store
Use Case
transfer::transfer
No
Internal module transfers of objects without store
transfer::public_transfer
Yes
External transfers of objects with store
transfer::share_object
No
Making objects shared (accessible by anyone)
transfer::public_share_object
Yes
External sharing of objects with store
transfer::freeze_object
No
Making objects immutable
transfer::public_freeze_object
Yes
External freezing of objects with store
Vulnerable Example
modulevulnerable::token{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::coin::{Self,Coin};constE_NOT_OWNER: u64=1;/// Has `store` ability - can use public_transfer
publicstructTokenhaskey,store{id: UID,value: u64,}/// VULNERABLE: Transfers to sender without verification
publicentryfunclaim_airdrop(amount: u64,ctx: &mutTxContext){lettoken=Token{id: object::new(ctx),value: amount,};// Who is sender? Could be anyone, including a contract
// that can't receive objects
transfer::public_transfer(token,tx_context::sender(ctx));}/// VULNERABLE: Typo in address loses funds forever
publicentryfunsend_to_treasury(token: Token,_ctx: &mutTxContext){// Hardcoded address - typo = permanent loss
transfer::public_transfer(token,@0x1234567890abcdef);}/// VULNERABLE: Shares object that should stay owned
publicentryfunmake_accessible(token: Token,_ctx: &mutTxContext){// Now ANYONE can access this token!
transfer::share_object(token);}}modulevulnerable::vault{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::balance::{Self,Balance};/// Missing `store` ability
publicstructVault<phantomT>haskey{id: UID,balance: Balance<T>,owner: address,}/// VULNERABLE: Can't use public_transfer without `store`
publicentryfuntransfer_vault<T>(vault: Vault<T>,new_owner: address,ctx: &mutTxContext){// This will fail! Vault doesn't have `store`
// transfer::public_transfer(vault, new_owner);
// And using transfer from outside the module won't work either
// Only this module can call transfer::transfer on Vault
}/// VULNERABLE: Freezes vault, locking funds forever
publicentryfunsecure_vault<T>(vault: Vault<T>,_ctx: &mutTxContext){// Frozen = immutable forever = funds locked!
transfer::freeze_object(vault);}}modulevulnerable::nft{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;publicstructNFThaskey,store{id: UID,name: vector<u8>,}publicstructNFTCollectionhaskey{id: UID,nfts: vector<NFT>,// Owned NFTs inside collection
}/// VULNERABLE: Shares collection, exposing all NFTs
publicentryfunpublish_collection(collection: NFTCollection,_ctx: &mutTxContext){// All NFTs in the collection are now accessible!
transfer::share_object(collection);}/// VULNERABLE: No validation of recipient
publicentryfungift_nft(nft: NFT,recipient: address,_ctx: &mutTxContext){// What if recipient is @0x0?
// What if recipient is a module address that can't hold objects?
transfer::public_transfer(nft,recipient);}}
Attack Scenario
moduleattack::steal_shared{usevulnerable::token::{Self,Token};usesui::tx_context::TxContext;/// After token is incorrectly shared, anyone can take it
publicentryfunsteal_shared_token(token: &mutToken,// Shared object = mutable by anyone
ctx: &mutTxContext){// Attacker can now manipulate the token
// that was accidentally shared
}}
Secure Example
modulesecure::token{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::event;constE_ZERO_ADDRESS: u64=1;constE_SELF_TRANSFER: u64=2;constE_NOT_OWNER: u64=3;publicstructTokenhaskey,store{id: UID,value: u64,original_owner: address,}publicstructTokenTransferredhascopy,drop{token_id: ID,from: address,to: address,}/// SECURE: Validates recipient before transfer
publicentryfunsafe_transfer(token: Token,recipient: address,ctx: &mutTxContext){letsender=tx_context::sender(ctx);// Validate recipient
assert!(recipient!=@0x0,E_ZERO_ADDRESS);assert!(recipient!=sender,E_SELF_TRANSFER);lettoken_id=object::id(&token);// Emit event for tracking
event::emit(TokenTransferred{token_id,from: sender,to: recipient,});transfer::public_transfer(token,recipient);}/// SECURE: Treasury address as a verified constant
constTREASURY: address=@0xTREASURY_ADDRESS_HERE;publicentryfunsend_to_treasury(token: Token,ctx: &mutTxContext){lettoken_id=object::id(&token);letsender=tx_context::sender(ctx);event::emit(TokenTransferred{token_id,from: sender,to: TREASURY,});transfer::public_transfer(token,TREASURY);}}modulesecure::vault{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::balance::{Self,Balance};usesui::coin::{Self,Coin};constE_NOT_OWNER: u64=1;constE_ZERO_ADDRESS: u64=2;constE_HAS_BALANCE: u64=3;/// Note: No `store` - transfers controlled by this module only
publicstructVault<phantomT>haskey{id: UID,balance: Balance<T>,owner: address,}/// SECURE: Module-controlled transfer with validation
publicentryfuntransfer_vault<T>(vault: Vault<T>,new_owner: address,ctx: &mutTxContext){// Verify current ownership
assert!(vault.owner==tx_context::sender(ctx),E_NOT_OWNER);// Validate new owner
assert!(new_owner!=@0x0,E_ZERO_ADDRESS);// Update internal owner tracking
letVault{id,balance,owner: _}=vault;letnew_vault=Vault{id,balance,owner: new_owner,};// Use transfer (not public_transfer) since no `store`
transfer::transfer(new_vault,new_owner);}/// SECURE: Withdraw before destroying, never freeze with balance
publicentryfunclose_vault<T>(vault: Vault<T>,ctx: &mutTxContext){assert!(vault.owner==tx_context::sender(ctx),E_NOT_OWNER);letVault{id,balance,owner}=vault;// Return any remaining balance to owner
if(balance::value(&balance)>0){letcoins=coin::from_balance(balance,ctx);transfer::public_transfer(coins,owner);}else{balance::destroy_zero(balance);};object::delete(id);// Vault is properly destroyed, not frozen with funds
}}modulesecure::nft{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::dynamic_object_fieldasdof;constE_NOT_OWNER: u64=1;constE_ZERO_ADDRESS: u64=2;publicstructNFThaskey,store{id: UID,name: vector<u8>,creator: address,}/// Collection owns NFTs via dynamic fields, not vector
publicstructNFTCollectionhaskey{id: UID,owner: address,nft_count: u64,}/// SECURE: Share collection but NFTs remain owned separately
publicentryfuncreate_collection(ctx: &mutTxContext){letcollection=NFTCollection{id: object::new(ctx),owner: tx_context::sender(ctx),nft_count: 0,};// Sharing collection is safe - it only contains metadata
// Individual NFTs are transferred separately
transfer::share_object(collection);}/// SECURE: Add NFT to collection as dynamic field
publicentryfunadd_to_collection(collection: &mutNFTCollection,nft: NFT,ctx: &mutTxContext){assert!(collection.owner==tx_context::sender(ctx),E_NOT_OWNER);letnft_id=object::id(&nft);dof::add(&mutcollection.id,nft_id,nft);collection.nft_count=collection.nft_count+1;}/// SECURE: Validated gift with proper checks
publicentryfungift_nft(nft: NFT,recipient: address,ctx: &mutTxContext){// Validate recipient
assert!(recipient!=@0x0,E_ZERO_ADDRESS);// Additional validation could include:
// - Checking recipient is not a known module address
// - Requiring recipient acknowledgment via separate flow
transfer::public_transfer(nft,recipient);}}
Transfer Pattern Guidelines
Pattern 1: Choosing the Right Transfer Function
/// Object WITHOUT store - use transfer (module-only)
publicstructInternalAssethaskey{id: UID,}/// Object WITH store - can use public_transfer
publicstructTransferableAssethaskey,store{id: UID,}/// Correct usage:
funtransfer_internal(asset: InternalAsset,recipient: address){// Only callable from within this module
transfer::transfer(asset,recipient);}publicentryfuntransfer_external(asset: TransferableAsset,recipient: address){// Callable from PTBs or other modules
transfer::public_transfer(asset,recipient);}
Pattern 2: Safe Sharing Decisions
/// SAFE to share: Configuration/registry with no sensitive data
publicstructPublicRegistryhaskey{id: UID,entries: Table<ID,RegistryEntry>,}/// UNSAFE to share: Objects containing value or authority
publicstructVaulthaskey{id: UID,balance: Balance<SUI>,// Don't share!
}/// When in doubt, keep objects owned
publicfunshould_share(obj_type: &str): bool{// Share: registries, oracles, public state
// Don't share: vaults, tokens, capabilities, user assets
false// Default to not sharing
}
Pattern 3: Freeze vs Delete
/// Use freeze for truly immutable data
publicstructImmutableMetadatahaskey{id: UID,name: vector<u8>,image_url: vector<u8>,// No balance, no mutable state
}publicentryfunpublish_metadata(metadata: ImmutableMetadata,_ctx: &mutTxContext){// Safe to freeze - no funds, metadata is permanent
transfer::freeze_object(metadata);}/// Use delete for objects with value
publicentryfunclose_account(vault: Vault,ctx: &mutTxContext){// Withdraw value first, then delete
letVault{id,balance}=vault;letcoins=coin::from_balance(balance,ctx);transfer::public_transfer(coins,tx_context::sender(ctx));object::delete(id);// Never freeze a vault!
}
Pattern 4: Transfer with Receipts
publicstructTransferReceipthaskey{id: UID,asset_id: ID,from: address,to: address,timestamp: u64,}publicentryfuntracked_transfer(asset: TransferableAsset,recipient: address,clock: &Clock,ctx: &mutTxContext){letasset_id=object::id(&asset);letsender=tx_context::sender(ctx);// Create receipt before transfer
letreceipt=TransferReceipt{id: object::new(ctx),asset_id,from: sender,to: recipient,timestamp: clock::timestamp_ms(clock),};// Keep receipt with sender for records
transfer::transfer(receipt,sender);// Transfer asset
transfer::public_transfer(asset,recipient);}