24. Transfer API Misuse
Overview
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
module vulnerable::token {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::coin::{Self, Coin};
const E_NOT_OWNER: u64 = 1;
/// Has `store` ability - can use public_transfer
public struct Token has key, store {
id: UID,
value: u64,
}
/// VULNERABLE: Transfers to sender without verification
public entry fun claim_airdrop(
amount: u64,
ctx: &mut TxContext
) {
let token = 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
public entry fun send_to_treasury(
token: Token,
_ctx: &mut TxContext
) {
// Hardcoded address - typo = permanent loss
transfer::public_transfer(token, @0x1234567890abcdef);
}
/// VULNERABLE: Shares object that should stay owned
public entry fun make_accessible(
token: Token,
_ctx: &mut TxContext
) {
// Now ANYONE can access this token!
transfer::share_object(token);
}
}
module vulnerable::vault {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::balance::{Self, Balance};
/// Missing `store` ability
public struct Vault<phantom T> has key {
id: UID,
balance: Balance<T>,
owner: address,
}
/// VULNERABLE: Can't use public_transfer without `store`
public entry fun transfer_vault<T>(
vault: Vault<T>,
new_owner: address,
ctx: &mut TxContext
) {
// 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
public entry fun secure_vault<T>(
vault: Vault<T>,
_ctx: &mut TxContext
) {
// Frozen = immutable forever = funds locked!
transfer::freeze_object(vault);
}
}
module vulnerable::nft {
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
public struct NFT has key, store {
id: UID,
name: vector<u8>,
}
public struct NFTCollection has key {
id: UID,
nfts: vector<NFT>, // Owned NFTs inside collection
}
/// VULNERABLE: Shares collection, exposing all NFTs
public entry fun publish_collection(
collection: NFTCollection,
_ctx: &mut TxContext
) {
// All NFTs in the collection are now accessible!
transfer::share_object(collection);
}
/// VULNERABLE: No validation of recipient
public entry fun gift_nft(
nft: NFT,
recipient: address,
_ctx: &mut TxContext
) {
// What if recipient is @0x0?
// What if recipient is a module address that can't hold objects?
transfer::public_transfer(nft, recipient);
}
}
Attack Scenario
module attack::steal_shared {
use vulnerable::token::{Self, Token};
use sui::tx_context::TxContext;
/// After token is incorrectly shared, anyone can take it
public entry fun steal_shared_token(
token: &mut Token, // Shared object = mutable by anyone
ctx: &mut TxContext
) {
// Attacker can now manipulate the token
// that was accidentally shared
}
}
Secure Example
module secure::token {
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::event;
const E_ZERO_ADDRESS: u64 = 1;
const E_SELF_TRANSFER: u64 = 2;
const E_NOT_OWNER: u64 = 3;
public struct Token has key, store {
id: UID,
value: u64,
original_owner: address,
}
public struct TokenTransferred has copy, drop {
token_id: ID,
from: address,
to: address,
}
/// SECURE: Validates recipient before transfer
public entry fun safe_transfer(
token: Token,
recipient: address,
ctx: &mut TxContext
) {
let sender = tx_context::sender(ctx);
// Validate recipient
assert!(recipient != @0x0, E_ZERO_ADDRESS);
assert!(recipient != sender, E_SELF_TRANSFER);
let token_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
const TREASURY: address = @0xTREASURY_ADDRESS_HERE;
public entry fun send_to_treasury(
token: Token,
ctx: &mut TxContext
) {
let token_id = object::id(&token);
let sender = tx_context::sender(ctx);
event::emit(TokenTransferred {
token_id,
from: sender,
to: TREASURY,
});
transfer::public_transfer(token, TREASURY);
}
}
module secure::vault {
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::balance::{Self, Balance};
use sui::coin::{Self, Coin};
const E_NOT_OWNER: u64 = 1;
const E_ZERO_ADDRESS: u64 = 2;
const E_HAS_BALANCE: u64 = 3;
/// Note: No `store` - transfers controlled by this module only
public struct Vault<phantom T> has key {
id: UID,
balance: Balance<T>,
owner: address,
}
/// SECURE: Module-controlled transfer with validation
public entry fun transfer_vault<T>(
vault: Vault<T>,
new_owner: address,
ctx: &mut TxContext
) {
// 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
let Vault { id, balance, owner: _ } = vault;
let new_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
public entry fun close_vault<T>(
vault: Vault<T>,
ctx: &mut TxContext
) {
assert!(vault.owner == tx_context::sender(ctx), E_NOT_OWNER);
let Vault { id, balance, owner } = vault;
// Return any remaining balance to owner
if (balance::value(&balance) > 0) {
let coins = 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
}
}
module secure::nft {
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::dynamic_object_field as dof;
const E_NOT_OWNER: u64 = 1;
const E_ZERO_ADDRESS: u64 = 2;
public struct NFT has key, store {
id: UID,
name: vector<u8>,
creator: address,
}
/// Collection owns NFTs via dynamic fields, not vector
public struct NFTCollection has key {
id: UID,
owner: address,
nft_count: u64,
}
/// SECURE: Share collection but NFTs remain owned separately
public entry fun create_collection(
ctx: &mut TxContext
) {
let collection = 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
public entry fun add_to_collection(
collection: &mut NFTCollection,
nft: NFT,
ctx: &mut TxContext
) {
assert!(collection.owner == tx_context::sender(ctx), E_NOT_OWNER);
let nft_id = object::id(&nft);
dof::add(&mut collection.id, nft_id, nft);
collection.nft_count = collection.nft_count + 1;
}
/// SECURE: Validated gift with proper checks
public entry fun gift_nft(
nft: NFT,
recipient: address,
ctx: &mut TxContext
) {
// 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)
public struct InternalAsset has key {
id: UID,
}
/// Object WITH store - can use public_transfer
public struct TransferableAsset has key, store {
id: UID,
}
/// Correct usage:
fun transfer_internal(asset: InternalAsset, recipient: address) {
// Only callable from within this module
transfer::transfer(asset, recipient);
}
public entry fun transfer_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
public struct PublicRegistry has key {
id: UID,
entries: Table<ID, RegistryEntry>,
}
/// UNSAFE to share: Objects containing value or authority
public struct Vault has key {
id: UID,
balance: Balance<SUI>, // Don't share!
}
/// When in doubt, keep objects owned
public fun should_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
public struct ImmutableMetadata has key {
id: UID,
name: vector<u8>,
image_url: vector<u8>,
// No balance, no mutable state
}
public entry fun publish_metadata(
metadata: ImmutableMetadata,
_ctx: &mut TxContext
) {
// Safe to freeze - no funds, metadata is permanent
transfer::freeze_object(metadata);
}
/// Use delete for objects with value
public entry fun close_account(
vault: Vault,
ctx: &mut TxContext
) {
// Withdraw value first, then delete
let Vault { id, balance } = vault;
let coins = 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
public struct TransferReceipt has key {
id: UID,
asset_id: ID,
from: address,
to: address,
timestamp: u64,
}
public entry fun tracked_transfer(
asset: TransferableAsset,
recipient: address,
clock: &Clock,
ctx: &mut TxContext
) {
let asset_id = object::id(&asset);
let sender = tx_context::sender(ctx);
// Create receipt before transfer
let receipt = 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);
}
Recommended Mitigations
1. Always Validate Recipients
assert!(recipient != @0x0, E_ZERO_ADDRESS);
assert!(recipient != tx_context::sender(ctx), E_SELF_TRANSFER);
2. Use the Correct Transfer Function
// Check if object has `store` ability
// - With store: use public_transfer
// - Without store: use transfer (module-internal only)
3. Never Freeze Objects with Value
// BAD: Funds locked forever
transfer::freeze_object(vault_with_balance);
// GOOD: Withdraw first, then delete
let coins = withdraw_all(vault);
delete_vault(vault);
4. Be Intentional About Sharing
// Ask: "Should anyone be able to access this?"
// If no, use transfer to specific address
// If yes, carefully consider the implications
5. Emit Events for Transfers
event::emit(TransferEvent {
object_id: object::id(&obj),
from: sender,
to: recipient,
});
Testing Checklist