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.

Heading image of Hitchhiker's Guide to Aptos Fungible Assets

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.

Important

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.

⚠️ Warning

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);
}
Question

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.

Question

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.

Note

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.

Question

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

  1. There are at least four functions that can withdraw a fungible asset: