Feb 10, 2025
Hitchhiker's Guide to Aptos Fungible Assets
We take a deep dive into Aptos’ implementation of fungible assets, exploring the intricacies hidden within its functions, objects, and interactions. While the Fungible Asset model was designed to address the limitations and security flaws of the legacy Coin standard, it also introduced new challenges and vulnerabilities that developers should be aware of.

Aptos’ fungible asset model is a complex component of its ecosystem, designed to address the limitations of its predecessor — the coin
standard. While the new model aims to enhance functionality and security, it also comes with its own set of challenges.
In this blog post, we'll closely examine Aptos's coin and fungible asset models, exploring their history and connection. We will examine key aspects of the fungible asset framework, including real-world examples of vulnerabilities that were identified and addressed, with the goal of improving security and reliability — all to help you build more secure and reliable applications.
All issues mentioned were identified and addressed during Aptos' rigorous pre-release audits, demonstrating the project's dedication to delivering a robust and secure environment from day one.
Aptos Coin standard
In the beginning, Aptos used Coin
. It is still in use, although it is now considered "legacy". Coin
is defined in Aptos as follows:
struct Coin<phantom CoinType> has store {
value: u64,
}
Aptos distinguishes coins by their type (CoinType
) at compile time. For example, Coin<Otter>
and Coin<Weasel>
represent different coins, and you cannot pass a Coin<Weasel>
to a function expecting Coin<Otter>
.
The type signature reveals why Coin
has become a legacy standard. Coin
has only the store
ability and uses a CoinStore
wrapper to store the coin and metadata:
struct CoinStore<phantom CoinType> has key {
coin: Coin<CoinType>,
frozen: bool,
deposit_events: EventHandle<DepositEvent>,
withdraw_events: EventHandle<WithdrawEvent>,
}
However, an astute reader would note that this isn't the only place a Coin
can be stored. You can create your own Coin
wallet, which could look like this:
struct DefinitelyLegitCoinStore<phantom CoinType> has key {
coin: Coin<CoinType>
}
CoinStore
includes a frozen
field, allowing the issuer to block transfers to and from the store. CoinStore
is also required for a burn_from
operation, which withdraws the coin
from the store and destroys it. Freezing and burning operations are essential i.e. for stablecoin issuers, using them as compliance tools to prevent unauthorized or illegal transactions and adhere to legal orders. Being able to bypass these restrictions with a custom wallet is an issue and can lead to severe consequences.
Storing coin
in a custom wallet is also a problem in terms of off-chain observability, as finding the stored coins in such setup is a difficult task. This is how the fungible asset AIP-21 summarizes the coin
problems:
... coin module has been deemed insufficient for current and future needs due to the rigidity of Move structs and the inherently poor extensibility.
The existing Coin struct leverages the store ability allowing for assets on-chain to become untraceable. Creating challenges to off-chain observability and on-chain management, such as freezing or burning.
And declares, that:
Fungible assets addresses these issues.
Let's find out whether this is indeed the case.
The fungible assets
Aptos designed fungible assets as a new token standard to solve these problems. A FungibleAsset
uses the hot-potato pattern:
struct FungibleAsset {
metadata: Object<Metadata>,
amount: u64,
}
Unlike Coin
, FungibleAsset
types are defined at runtime through the Metadata
field. This change was meant to enhance extensibility:
An object can have other resources attached to provide additional context. For example, the metadata could define a gem of a given type, color, quality, and rarity, where ownership indicates the quantity or total weight owned of that type of gem.
An important implication is that functions accepting FungibleAssets
must verify the metadata to ensure valid assets.
Let's consider a possible implementation of a protocol that takes in assets.
public fun deposit<T: key>(
sender: &signer, fa: FungibleAsset
) acquires [...] {
assert_not_paused();
let fa_amount = fungible_asset::amount(&fa);
let sender_address = address_of(sender);
check_compliance(fa_amount, sender_address);
increase_deposit(get_vault(sender_address), fa_amount);
primary_fungible_store::deposit(global_vault_address(), fa);
event::emit(Deposit {sender_address, fa_amount})
}
Do you see any problems here? The application does not validate or differentiate fungible assets using their metadata, which causes all fungible asset deposits to be treated as identical.
While these bugs aren't partiularly complex, they do represent an additional vulnerability class that must be checked for.
Fungible stores
As mentioned, fungible assets are hot potatoes, meaning they must be destroyed after each transaction. If they lack abilities, how can they be used?
Meet the FungibleStore
.
struct FungibleStore has key {
metadata: Object<Metadata>,
balance: u64,
frozen: bool,
}
FungibleStore
manages balances and metadata instead of holding the actual FungibleAsset
(it can't because FungibleAsset
doesn't have store
). Withdrawals create temporary FungibleAsset
resources, while deposits destroy them and update the balance. This design prevents freezing bypasses and improves observability.
A curious reader might wonder, is there any other way to create or destroy a FungibleAsset
besides withdrawing, depositing or minting it? There is — anyone can create and destroy a zero-value FungibleAsset
.
public fun destroy_zero(fungible_asset: FungibleAsset) {
let FungibleAsset { amount, metadata: _ } = fungible_asset;
assert!(amount == 0, error::invalid_argument(EAMOUNT_IS_NOT_ZERO));
}
public fun zero<T: key>(metadata: Object<T>): FungibleAsset {
FungibleAsset {
metadata: object::convert(metadata),
amount: 0,
}
}
In theory, this shouldn’t pose a problem. After all, having zero of something doesn’t exactly qualify as ownership.
In practice, the ability to freely mint and burn zero FungibleAssets
of any type could present a significant risk. During our reviews, we enountered many protocols that did not account for this possibility, leading to arithmetic errors, DoS logic bugs or inaccurate calculations. Keep in mind that edge case, we'll come back to this.
Primary and secondary stores
FungibleStores
in comparison to CoinStores
are not unique. Each user can have multiple FungibleStore
objects for a given token!
A primary fungible store is maintained via the aptly named primary_fungible_store module. It's "primary" because of its deterministic location, which is calculated using the owner and the fungible asset's Metadata
addresses. Users can also create a number of "secondary" fungible stores by themselves.
One key feature of the primary fungible stores is their permissionless creation. This can lead to surprising denial of service bugs!
public entry fun register(
user: &signer, [...]
) acquires [...] {
[...]
let wallet_store = create_primary_store(signer::address_of(sender), get_metadata());
[...]
}
The create_primary_store
function can introduce DoS vulnerabilities because it aborts if the store already exists. Using ensure_primary_store_exists
is recommended to avoid such issues.
Fungible assets and objects
The fungible asset standard is not a standalone module. It has heavy dependencies on a sibling module, the Object
module, introduced in AIP-10.
AIP-21 proposes a standard for Fungible Assets (FA) using Move Objects. In this model, any on-chain asset represented as an object can also be expressed as a fungible asset allowing for a single object to be represented by many distinct, yet interchangeable units of ownership.
These two modules are closely intertwined, and their connection can be surprisingly intricate.
Creation and deletion
To create a fungible resource, an undeletable object must first be created. "Undeletable" means, that it's not possible to get a permission to delete it. This is verified in fungible_asset::add_fungibility
:
assert!(!object::can_generate_delete_ref(constructor_ref), error::invalid_argument(EOBJECT_IS_DELETABLE));
This object serves as the foundation for ownership tokens in the form of a FungibleAsset
. This means that allowing it to be deletable wouldn't make sense and would impact the usability of such fungible assets, restricting users from accessing critical functionalities such as creating new stores. In the past the fungible_asset::add_fungibility
lacked this assert, which we discovered and reported.
fungible_asset::add_fungibility
transfers the Metadata
and associated resources to this new object. After that, with the appropriate permissions, the FungibleAsset
can be minted, representing a share of ownership in that object.
/// Make an existing object fungible by adding the Metadata resource.
public fun add_fungibility(
[...]
): Object<Metadata> {
[...]
move_to(metadata_object_signer,
Metadata {
name,
symbol,
decimals,
icon_uri,
project_uri,
}
);
[...]
}
Deletions can be a big issue even when dealing with objects that are eligible for deletion. For example, a FungibleStore
is also an object, and a "secondary" FungibleStore
can be created as deletable if empty. The catch is that deletion can occur both at the fungible asset level and at the object level.
//Fungible asset
public fun remove_store(delete_ref: &DeleteRef)
//Object
public fun delete(ref: DeleteRef)
When object::delete
removes the Object
from a FungibleStore
object, the FungibleStore
resource becomes permanently undeletable. This is because remove_store
can't create an Object<FungibleStore>
without an Object
underneath, causing the operation to fail.
public fun remove_store(delete_ref: &DeleteRef) acquires [...] {
let store = &object::object_from_delete_ref<FungibleStore>(delete_ref);
[...]
}
In addition, such "deleted" FungibleStore
objects remain at least partially operable. For instance, fungible_asset::deposit
does not check the Object
existence.
Ownership
Each object has an owner. Fungible assets rely on the Object
ownership model. For example, during a withdrawal operation, the signer is validated using object::owns
to confirm ownership of the FungibleStore
object.
public(friend) fun withdraw_sanity_check<T: key>(
owner: &signer,
store: Object<T>,
abort_on_dispatch: bool,
) acquires FungibleStore, DispatchFunctionStore {
assert!(object::owns(store, signer::address_of(owner)), error::permission_denied(ENOT_STORE_OWNER));
[...]
}
The thing to note is that defining ownership with object::owns
can be tricky. The burn
function was one of the reasons behind that. It allowed changing the object's owner to the BURN_ADDRESS
while bypassing transfer restrictions:
public entry fun burn<T: key>(owner: &signer, object: Object<T>) acquires ObjectCore {
let original_owner = signer::address_of(owner);
assert!(is_owner(object, original_owner), error::permission_denied(ENOT_OBJECT_OWNER));
let object_addr = object.inner;
move_to(&create_signer(object_addr), TombStone { original_owner });
transfer_raw_inner(object_addr, BURN_ADDRESS);
}
unburn
is a way to restore the previous object owner. In a past audit, this mechanism could be exploited to bypass fungible store owner blacklisting by temporarily setting ownership to the unblacklisted BURN_ADDRESS
. AIP-99 is a proposal to roll back the burn
feature, but previously burned objects will remain restorable.
This AIP-99 seeks to disable safe object burn, as it caused extra complexity, and sometimes unexpected consequences. As a result of this AIP, users will still be able to unburn their burnt objects, but will not be able to burn any new objects.
Another important thing is that fungible_asset::set_untransferable
can be used to make all new FungibleStores
for this asset untransferable, preventing ownership changes. However, this restriction doesn't apply to the parent object, allowing a transferable parent to be moved even if it owns a non-transferable FungibleStore
.
Do we need to care about this case? We do, because ownership is transitive. If entity X owns an object that owns a FungibleStore
, X can withdraw from that store. This is because fungible_asset::withdraw
uses object::owns
to verify both direct and indirect ownership of the FungibleStore
object.
fun verify_ungated_and_descendant(owner: address, destination: address) acquires ObjectCore {
[...]
while (owner != current_address) {
count = count + 1;
[...]
assert!(
exists<ObjectCore>(current_address),
error::permission_denied(ENOT_OBJECT_OWNER),
);
let object = borrow_global<ObjectCore>(current_address);
current_address = object.owner;
};
}
This could allow for bypassing assumptions about FungibleStore
true ownership and its non-transferability.
public fun untransferable_transfer(caller: &signer, receipient: address) {
let constructor_ref = object::create_object(signer::address_of(caller));
let object_addr = object::address_from_constructor_ref(&constructor_ref);
let store = primary_fungible_store::ensure_primary_store_exists(object_addr, get_metadata());
object::transfer_raw(caller, object_addr, receipient);
//receipient can interact with store by using their signer
}
The ownership transfer issue also showed up during our review of the fungible asset standard, where we identified an interesting edge case involving the transfer of a non-transferable fungible store.
public fun transfer_with_ref(ref: LinearTransferRef, to: address) acquires ObjectCore {
assert!(!exists<Untransferable>(ref.self), error::permission_denied(ENOT_MOVABLE));
let object = borrow_global_mut<ObjectCore>(ref.self);
assert!(
object.owner == ref.owner,
error::permission_denied(ENOT_OBJECT_OWNER),
);
[...]
object.owner = to;
}
A user could exploit this by creating an object and a transfer permission, burning the object (changing its ownership to the BURN_ADDRESS
), transferring it to another user, and then registering a non-transferable fungible store with that object. While the store could no longer be moved using the owner's signer
or the transfer permission due to non-transferable restrictions, it could be unburned to restore the original ownership!
References
References
are a permission type resource that authenticate a caller for security-critical operations. Refs
are based on the Object
model, but they are also adapted by fungible assets. Some of these are defined by the Object
itself, while others are created through the fungible asset module. What's more, some are shared between them, while others appear shared but aren’t.
Let's get back to the FungibleStore
deletion example. Both object::delete
and fungible_asset::remove_store
use the same object-specific DeleteRef
permission. It can be created only during object creation. There is no separate DeleteRef
for fungible assets.
//Fungible asset
public fun remove_store(delete_ref: &DeleteRef)
//Object
public fun delete(ref: DeleteRef)
On the other hand, the "frozen" status of a FungibleStore
is toggled using a TransferRef
, which is defined in both models (and not interchangeable). They also can be created only during object creation.
public fun set_frozen_flag<T: key>(
ref: &TransferRef,
store: Object<T>,
frozen: bool,
)
The Object
TransferRef
is used to transfer object ownership:
/// Used to create LinearTransferRef, hence ownership transfer.
struct TransferRef has drop, store {
self: address,
}
While the fungible asset's TransferRef
manages the transfer of fungible assets and the (un)freezing of fungible stores:
/// TransferRef can be used to allow or disallow the owner of fungible assets from transferring the asset
/// and allow the holder of TransferRef to transfer fungible assets from any account.
struct TransferRef has drop, store {
metadata: Object<Metadata>
}
Additionally, there are fungible asset-specific references such as MintRef
for minting and BurnRef
for burning. These references are used exclusively by the fungible asset model, but they still must be created when the fungible asset object is initialized.
Dispatchable fungible assets
Dispatchable fungible assets enhance the functionality of fungible assets by enabling the overloading of operations like deposits and withdrawals.
Hooks registered during the creation of a dispatchable fungible asset override the default logic for these operations, allowing for custom features like access control, fee mechanisms, or granular pausing.
Overloading the core fungible asset functions introduces potential security risks; for example, during a deposit, funds may not end up at the intended address. The dispatchable fungible asset API provides functions like transfer_assert_minimum_deposit
that can help mitigate such risks.
Hook functions for dispatchable fungible assets must have the correct type signature. They must also be declared public
to ensure their signature remains immutable. An example implementation might look like this:
public fun withdraw_hook<T: key>(
store: Object<T>,
amount: u64,
transfer_ref: &TransferRef,
): FungibleAsset {
//check paused, gather fees etc.
fungible_asset::withdraw_with_ref(transfer_ref, store, amount)
}
public fun deposit_hook<T: key>(
store: Object<T>,
fa: FungibleAsset,
transfer_ref: &TransferRef,
) {
//check paused, gather fees etc.
fungible_asset::deposit_with_ref(transfer_ref, store, fa);
}
Why hook functions rely on *_with_ref
calls? What would happen if the hook function called dispatchable_fungible_asset::withdraw
instead of a fungible_asset::withdraw_with_ref
?
A1: Hook functions rely on *_with_ref
calls because the default fungible asset functions verify if the fungible asset is not dispatchable.
A2: A dispatchable_fungible_asset::withdraw
would result in RUNTIME_DISPATCH_ERROR (code 4037) error with error message: "Re-entrancy detected".
In one of our reviews, we encountered a dispatchable fungible asset where the hooked withdrawal set a "blocked" flag, which was cleared by the corresponding deposit. This design was used to ensure that each withdrawal was tied to a deposit, effectively preventing simultaneous withdrawals.
public fun deposit<T: key>(store: Object<T>, fa: FungibleAsset, transfer_ref: &TransferRef) {
assert_withdraw_flag(true);
[...]
set_withdraw_flag(false);
fungible_asset::deposit_with_ref(transfer_ref, store, amount);
[...]
}
public fun withdraw<T: key>(store: Object<T>, amount: u64, transfer_ref: &TransferRef): FungibleAsset acquires [...] {
assert_withdraw_flag(false);
[...]
set_withdraw_flag(true);
fungible_asset::withdraw_with_ref(transfer_ref, store, amount)
}
At first glance, this code appears valid, but not to an astute reader.
Can you spot the bug? Hint: We mentioned the root cause previously.
The developer overlooked an important detail, which we already mentioned earlier: a fungible asset with a value of zero can also be burned! An attacker could exploit this by withdrawing 0 FungibleAsset
(since withdraw doesn’t verify if the value is greater than 0) and then burning it using fungible_asset::destroy_zero
. This would complete the transaction while keeping the "blocked" flag set, effectively preventing further withdrawals.
It's important to understand all the features in the standard.
Migrating from coins to fungible assets
If a fungible asset is considered an upgrade to Coin
, a transition mechanism becomes necessary. This is addressed through a conversion map, establishing a relationship between specific coin and fungible asset. This duality is not without its challenges.
While the Coin
API recognizes and integrates with fungible assets, the fungible asset APIs do not have awareness of the linked Coin
.
The coin_to_fungible_asset
converting function automatically generates a corresponding fungible asset for a Coin
if one does not already exist. Manual creation of a fungible asset and its linkage to a Coin
is not allowed.
public fun coin_to_fungible_asset<CoinType>(
coin: Coin<CoinType>
): FungibleAsset acquires CoinConversionMap, CoinInfo {
let metadata = ensure_paired_metadata<CoinType>();
let amount = burn_internal(coin);
fungible_asset::mint_internal(metadata, amount)
}
When creating a fungible asset, several pieces of information are required, such as the asset’s name, symbol, or maximum supply. During our audit of the fungible asset standard, we noticed an overlooked detail in the linking process.
[...]
primary_fungible_store::create_primary_store_enabled_fungible_asset(
&metadata_object_cref,
option::map(coin_supply<CoinType>(), |_| MAX_U128),
name<CoinType>(),
symbol<CoinType>(),
decimals<CoinType>(),
string::utf8(b""),
string::utf8(b""),
);
[...]
When the linked fungible asset was created, the current Coin
supply was incorrectly passed as the maximum fungible asset supply, preventing the minting of additional fungible assets beyond the existing coin circulation.
Users can manually migrate their CoinStore
to a primary fungible store. This creates a store for the paired fungible asset (if one doesn’t exist) and removes the <CoinStore<CoinType>>
from the caller. All coins in the CoinStore
are exchanged and transferred to the new store during the migration.
/// Voluntarily migrate to fungible store for `CoinType` if not yet.
public entry fun migrate_to_fungible_store<CoinType>(
account: &signer
) acquires CoinStore, CoinConversionMap, CoinInfo {
maybe_convert_to_fungible_store<CoinType>(signer::address_of(account));
}
A curious reader might wonder about the fate of the CoinStore
"frozen" status during migration. Unsurprisingly tough, the "frozen" status of the primary fungible store is matched to that of the CoinStore
to ensure consistency.
Could an attacker convert their CoinStore
to a primary fungible store and then register another CoinStore
only to convert it again to manipulate the "frozen" status of the linked primary fungible store?
The coin::register function first checks is_account_registered, which exits early if true. is_account_registered determines if the account has a primary fungible store for the linked fungible asset when the CoinStore doesn’t exist. If the fungible store has been converted, a primary fungible store and linked fungible asset will already exist, preventing re-registration.
Conclusion
Aptos's implementation of fungible assets does indeed resolve the original problems with Coin
.
However, this solution comes with its own challenges, in part because of the numerous layers that interact with each other. Before using the fungible asset standard, it's important to understand these different APIs and potential pitfalls.
As a final exercise to the reader, how many different ways are there to withdraw a fungible asset?1
Footnotes
- There are at least four functions that can withdraw a fungible asset:
- fungible_asset::withdraw
- dispatchable_fungible_asset::withdraw
- primary_fungible_store::withdraw
- coin::withdraw