An Overview of Aptos Smart Contract and Token Standard

Osikhena OshomahOsikhena Oshomah
15 min read

Move is a programming language designed to write smart contracts on the Aptos blockchain. It is to Aptos what Solidity is to Ethereum. The Aptos blockchain is a layer-1 blockchain known for its fast transaction finality.

This post will highlight the similarities and differences between Move and Solidity smart contracts and their environments. It will also examine Aptos token standards for digital assets and fungible assets and explain the Aptos object, as a sound understanding of the Aptos object is needed to fully understand the token standard and implementation.

Comparing Aptos Move to Ethereum Solidity

Ethereum pioneered smart contracts and on-chain decentralized finance (DeFi) products, setting the standard many blockchains have since emulated and sought to improve upon by using the Ethereum Virtual Machine (EVM) with optimized backends. In contrast, Aptos isn’t based on EVM but utilizes parallel execution, significantly enhancing its speed and scalability.

This article will evaluate Solidity and Move in this comparison across the following parameters:

Language Design

Solidity is Ethereum's language for writing smart contracts. It is a high-level, object-oriented programming language with styles similar to JavaScript, C++, and Python.

Move is a resource-oriented programming language for writing smart contracts for the Aptos blockchain. It focuses on security and resource safety, ensuring that assets are never accidentally duplicated or lost.

Data Structures in Move and Solidity

Smart contract data are managed and organized via data structures.

Solidity supports various data types like uint, int, address, mapping, struct, array etc.

Move provides primitives such as u8, u64, u128, bool, address, vector similar to array in Solidity, table<K, V> similar to mapping in Solidity. Yet, Move also has the concept of a resource, user-defined types saved in an account represented by an address.

Resources are controlled by code and they cannot be accidentally destroyed or transferred as the Aptos VM guards against it.

Move has an inbuilt pool data structure that tracks the share of participants in a shared pool. In DeFi, a pool data structure tracks tokens deposited by users in a shared pool. This data structure is usually implemented by the developer or by using a library, but it comes out of the box in Move.

Contract storage

Persistent data in Solidity is stored in contract storage. Any variable declared outside of a function in Solidity is persistent and therefore stored in the blockchain.

Data in Move is stored in an account which is represented by an address. An account can store code modules (i.e., smart contract code) and resources.

Figure 1 represents account storage in Move, showing the storage of code and resources. The resource stored in an account can only be modified by the code that created it.

Figure 1: Aptos account

Gas Fee Structure

On the Ethereum network, gas refers to the cost of executing a transaction on a smart contract.

It is calculated as: Total Gas Cost = Units of Gas Used * (Base Fee + Priority Fee)

  • The Units of Gas Used depend on the complexity and resources required by the smart contract.

  • The Base Fee is a network-determined constant (currently 21,000 WEI for simple transactions) that represents the minimum transaction cost.

  • The Priority Fee, a tip, is an extra fee users can pay miners to prioritize their transactions. The higher the priority fee, the greater the chance that the transaction will be processed sooner. However, this system can lead to front running, where users pay higher fees to influence the order of transactions in a block, potentially extracting value unfairly.

On Aptos, gas functions similarly as a cost for network activities, but with some differences.

The total gas cost is calculated as:

Total Gas Cost = (Execution & IO Cost + Storage Fees) * Gas Unit Price − Storage Refund

  • Execution and IO Costs are the costs for executing instructions, accessing storage, and transferring resources on-chain.

  • The Gas Unit Price is the amount a user is willing to pay per unit of gas consumed (paid in native token Octas). The gas unit price can be increased to incentivize validators to include the transaction in the next block. Still, the order of execution in the block is not determined by a higher gas unit price but by the system, making front running in a Move contract impossible to execute.

  • Storage Fees are charged when data is written to the blockchain. However, when data is deleted from global storage, all the gas spent storing and accessing that data is refunded. If the Total Gas Cost is negative, a refund is transferred to the user balance as refunds acquired from deleting data from global storage.

NFT and Digital Assets

On Ethereum, non-fungible tokens (NFTs) follow the ERC-721 or ERC-1155 standards, which define the interface for their structure and behaviour. A developer building an NFT on Ethereum is assumed to adhere to these standards, but variations or modifications can occur, and the Ethereum network does not enforce them.

Smart contracts track ownership of NFT, and while secure, they are more vulnerable to issues like reentrancy attacks if not properly coded. The transfer of ownership and security of Ethereum NFT depend on the contract's implementation, and the transaction cost of transferring NFTs is higher than the Aptos ecosystem.

Aptos Digital Assets are resources created by the Move object. They are managed by the blockchain's native resource model, ensuring they cannot be duplicated or lost. Each object is uniquely tied to an account and cannot be transferred or modified without explicit permission.

Digital assets benefit from Aptos blockchain's parallel execution capabilities, leading to higher throughput and lower transaction costs than Ethereum.

Similarities Between Solidity and Move

Let's examine the similarities of these programming languages.

Purpose

Both Solidity and Move are designed for writing smart contracts on their respective blockchains. These contracts manage assets, execute transactions, and facilitate decentralized applications (DApps).

Ownership and Asset Management

They allow for the creation and management of digital assets. These assets can represent tokens, NFTs, or other forms of value, and their ownership can be transferred between users according to the rules defined in the smart contract.

Modularity and Reusability

Both support modular programming. In Solidity, you structure code using contracts, libraries, and inheritance. In Move, modules and resources allow for reusable and organized code. Both approaches encourage developers to write modular, maintainable code.

Security Considerations

Both emphasize security. Solidity includes safeguards like access control and error-handling mechanisms to protect against vulnerabilities but requires developers to follow best practices to implement secure smart contracts. Move is designed from the ground up to be safe with its resource model, which prevents double spending and accidental asset loss.

Code Verification

Both ecosystems support formal code verification tools to ensure the correctness of smart contracts. Solidity offers tools like Slither, while Move provides the Move Prover, allowing developers to verify that their contracts work as intended.

In summary, both ecosystems are powerful and thriving with communities that drive their adoption and use.

Understanding Move Object

Move object groups resources together so they can be easily transferred as a single entity. Object have an address and can hold both resources and modules. The creator of an object owns it until it is transferred to another account. You need a solid understanding of objects to understand Aptos digital assets and fungible assets.

Figure 2 lists the object types, their differences, and how they are created. Objects are of three variants with different properties based on their type.

Figure 2: Types of object

Generally, a Move object can be owned by an account, a resource account, or even by another object. One use of an object is to group resources as explained and make it easier to transfer resources to an account. Object can also be used as a parameter in entry functions callable from the front end of your application.

Let's look at an example of how to create an object, move resources into it, transfer the object to another account and use the object reference as a parameter for an entry function.

module addr::object_example {

    use aptos_framework::object::{Self, Object, ExtendRef, TransferRef, ObjectCore};
    use std::signer;
    struct Data has key {
        num: u64
    }
    struct Message has key {
        message: vector<u8>
    }
    struct ObjectController has key {
        extend_ref: ExtendRef,
        transfer_ref: TransferRef,
        object_ref: Object<ObjectCore>
    }
    public fun createObject(creator: &signer){
        let object_creator_address = signer::address_of(creator);
        let constructor_ref = object::create_object(object_creator_address);
        let obj_signer = object::generate_signer(&constructor_ref);
        //generate extendRef to later add more resources to the object
        let extend_ref = object::generate_extend_ref(&constructor_ref);
        //transfer ref could be used to prevent an object from been transferred
        let transfer_ref = object::generate_transfer_ref(&constructor_ref);
        //get a reference to the object
        let object_ref = object::object_from_constructor_ref<ObjectCore>(&constructor_ref);
        move_to(
            &obj_signer,     //The Data resources is moved to the object
            Data {
                num: 147
            }
        );
        move_to(
            &obj_signer,    //The Message resource is moved to the object
            Message {
                message: b"Learn Aptos and move and break even!"
            }
        );

        move_to(
            creator,
            ObjectController {
                extend_ref,
                transfer_ref,
                object_ref
            }
        );
    }

    public entry fun transferObject(object: Object<ObjectCore>, object_owner: &signer, receiver_address: address) {
        let object_address = object::object_address<ObjectCore>(&object);
        let object = object::address_to_object<ObjectCore>(object_address);
        object::transfer(object_owner, object, receiver_address);
    }
    //add unit test here
 }

The public function createObject takes a signer as a parameter. The address of the signer account is used to create the object. The object::create_object(object_creator_address) function returns a constructor reference.

A constructor reference is returned when an object is created, regardless of the object type. The constructor reference is used to generate a signer for the object, which is needed to move resources into the object. The object is owned by its creator and can only be transferred by its creator.

The constructor reference is also used to create an ExtendRef, TransferRef, and DeleteRef for the object. Since the constructor reference can not be saved and is destroyed after creation, a developer should create and save the needed reference for the object.

After creating the object and the associated references, you move the created, extend_ref, transfer_ref and object_ref, references to the creator account via the ObjectController resource type.

Take a look at this line; the line creates an object reference.

let object_ref = object::object_from_constructor_ref<ObjectCore>(&constructor_ref);

The transferObject function shows how to use an object as a parameter for an entry function. An object can be referenced by the resources it contains or by the object type ObjectCore which all object possesses.

Finally, the object is transferred from the creator to the new recipient.

Add the following test after the transferObject function. The unit test creates an object and transfers the object to a new recipient.

#[test(creator = @addr, receiver = @0x122 )]

    fun create_object_and_transfer(creator: &signer, receiver: &signer) acquires ObjectController{
        createObject(creator);
        let object_ref = borrow_global<ObjectController>(@addr).object_ref;
        let owner_before = object::owner(object_ref);
        let receiver_address = signer::address_of(receiver);
        transferObject(object_ref, creator, receiver_address);
        //get the object reference saved in global storage
        let owner_after = object::owner(object_ref);
        assert!(owner_before == @addr,  200);
        assert!(owner_after == receiver_address, 202);
    }

Open the project terminal and run the test by executing the following command:

aptos move test

Aptos Digital Assets Standards

Figure 3 shows that a token belongs to a collection, and tokens are of two variants: named and unnamed. Both the token and the collection are implemented via the object.

Figure 3: Digital Asset

Aptos digital assets (i.e., Apots’ NFTs) are implemented using objects. Creating digital assets involves two steps: creating a collection that the token belongs to and minting the token to transfer to a receiver address.

Aptos digital assets are fully customizable because you can add custom properties to them. You can add custom properties using a MutatorRef generated via a ConstructorRef. Remember, a ConstructorRef is returned when you create an object. Since both the collection and token are implemented with an object, you can access a ConstructorRef that can generate the needed references.

A collection could have a fixed supply of tokens or an infinite token amount. This is set when the collection is created and cannot be changed after creation.

Let's examine an example NFT:

module addr::digital_assets {
    use aptos_token_objects::collection;
    use aptos_token_objects::token;
    use aptos_framework::object::{Self, Object, ConstructorRef, ObjectCore};
    use std::signer;
    use std::string::{Self, String};
    use std::option;
    use aptos_token_objects::property_map;
    #[test_only]
    use aptos_framework::object::object_address;
    use aptos_token_objects::token::create_token_address;
    const COLLECTION_NAME:vector<u8> = b"Meme Kingdom";
    const COLLECTION_DESCRIPTION: vector<u8> = b"This is a collection of fantastic memes scattered over the internet";
    const COLLECTION_URI: vector<u8> = b"http://Igonowhere.com";
    const BASE_URI: vector<u8> = b"http://Igonowhere.com";
    const MAX_SUPPLY: u64 = 2;

    #[resource_group_member(group = aptos_framework::object::ObjectGroup)]
    /// The ticket token

    struct MemeRarity has key {
        /// Used to mutate properties
        rarity_mutator_ref: property_map::MutatorRef,
    }

    public fun create_token_collection(creator: &signer){
        let description = string::utf8(COLLECTION_DESCRIPTION);
        let collection_name = string::utf8(COLLECTION_NAME);
        let uri = string::utf8(COLLECTION_URI);
        //creates a collection that has a fixed number of tokens
        collection::create_fixed_collection(
            creator,    //the owner of the collection. The collection owner is not usually the token owner
            description,  // the description of the collection
            MAX_SUPPLY, // the number of tokens that this collection can contain
            collection_name, // the name of the collection
            option::none(), //royalty to send to the creator of the collection
            uri // a url that contain the description of the collection
        );
    }

    public fun mint_meme_token(
        creator: &signer,
        name: String,
        receipient: address,
        description: String,
        rarity: String

    ): Object<MemeRarity> {

        // The collection name is used to locate the collection object and to create a new token object.
        let collection = string::utf8(COLLECTION_NAME);
        let uri = string::utf8(BASE_URI);
        let constructor_ref: ConstructorRef = token::create_named_token(creator, collection,
            description, name, option::none(), uri);
        // Generates the object signer and the refs. The object signer is used to publish a resource
        let object_signer = object::generate_signer(&constructor_ref);
        let rarity_mutator_ref = property_map::generate_mutator_ref(&constructor_ref);
        let properties = property_map::prepare_input(vector[], vector[], vector[]);
        property_map::init(&constructor_ref, properties);
        property_map::add_typed(
            &rarity_mutator_ref,
            string::utf8(b"Rarity"),
            rarity
        );
        move_to(&object_signer, MemeRarity {
            rarity_mutator_ref,
        });
        //get the object from the constructor ref
        let token_object =  object::object_from_constructor_ref<MemeRarity>(&constructor_ref);
        //transfer the token to the receiver
        object::transfer(creator, token_object, receipient);
        token_object
    }
        //add unit test after this line
}

The above code sample creates a digital asset with a custom property. The custom property you want to include in the NFT is the rarity of a meme. Create a MemeRarity state with a field, rarity_mutator_ref, with a value of property_map::MutatorRef. property_map::MutatorRef is created for each custom property of your digital asset.

struct MemeRarity has key {
  /// Used to mutate properties
        rarity_mutator_ref: property_map::MutatorRef,
}

Look at the create_token_collection function, which creates a fixed collection with a maximum supply of tokens. The collection::create_fixed_collection function is responsible for creating the token collection. The function accepts six parameters, including the optional royalty parameter, which specifies the royalty the collection owner should receive when a token is minted.

An account can only create one collection with the same name.

After creating the collection, you can now create a meme token. As explained in Figure 3, a token must belong to a collection.

The mint_meme_token function mints a token; as digital assets are objects, you can access all the references generated via a ConstructorRef and a MutatorRef to add custom properties to the digital assets.

The ConstructorRef returned from token::create_named_token lets you create a signer for moving data into the token object and a MutatorRef for adding a custom rarity property.

The created token is transferred to the receiver address, and the function returns a reference to the token. The return of the token reference is only done for testing purposes.

Add the following unit test and go through it to understand, how to retrieve the token data and its custom property.

  #[test(creator = @addr, receipent = @0x1, aptos_framework = @0x1)]
    fun test_meme_token(creator: &signer, receipent: &signer, aptos_framework: &signer) {
        create_token_collection(creator);
        //mints a ticket for user
        let token_name = string::utf8(b"Kratos Axe");
        let description = string::utf8(b"Kratos Axe on level maximum damage");
        let receipient_addr = signer::address_of(receipent);
        let rarity = string::utf8(b"Rare");
       let token =  mint_meme_token(
            creator,
            token_name,
            receipient_addr,
            description,
            rarity
        );

        let owner = object::owner(token);
        let object_address = object_address(&token);
        assert!(owner == receipient_addr, 900);
        let rarity = property_map::read_string(&token, &string::utf8(b"Rarity"));
        //let token: Object<MemeRarity> = object::address_to_object(token_address);
        let exist = object::object_exists<MemeRarity>(object_address);
        let token_creator = token::creator(token);
        let collection_name = token::collection_name(token);
        let token_name_2 = token::name(token);

        assert!(exist, 400);
        assert!(rarity == string::utf8(b"Rare"), 401);
        assert!(token_creator == @addr, 404);
        assert!(collection_name == string::utf8(COLLECTION_NAME), 403);
        assert!(token_name_2 == token_name, 408);
    }

Execute the unit test from the project terminal by running the following command:

aptos move test

Fungible Asset

FAs in Aptos are the ERC-20 equivalent in Ethereum. Aptos fungible asset standard (FA) replaces the coin module. FA is implemented with Move object because objects are more customizable, allowing creators to add custom logic for on-chain assets.

Two objects create FA: The Object<Metadata> and the Object<FungibleStore>.

The Metadata contains information about the FA, like name, maximum supply, icon, url, decimal, etc.

If your FA asset does not already have a FungibleStore, one is created in the receiver's account. It tracks FA assets owned by an account.

Let's see an example of how to create an Aptos FA

module addr::FA {
    use aptos_framework::fungible_asset::{Self, MintRef, TransferRef, BurnRef, Metadata, FungibleAsset};
    use aptos_framework::object::{Self, Object};
    use aptos_framework::primary_fungible_store;
    use std::string::utf8;
    use std::option;
    use std::signer;
    struct FAController has key {
        mint_ref: fungible_asset::MintRef,
        burn_ref: fungible_asset::BurnRef,
        transfer_ref: fungible_asset::TransferRef,
    }
    struct FAObject has key {
        tk_ref: Object<Metadata>
    }

    const ASSET_SYMBOL: vector<u8> = b"TK";
    fun create_token(creator: &signer){
        let constructor_ref = &object::create_named_object(creator, ASSET_SYMBOL);
        primary_fungible_store::create_primary_store_enabled_fungible_asset(
            constructor_ref, //constructor ref from the created object
            option::none(), //the maximum supply of the FA asset token
            utf8(b"Teken Token"), //the token name
            utf8(ASSET_SYMBOL), // FA symbol
            8, // decimal
            utf8(b"http://example.com/favicon.ico"), //FA icon
            utf8(b"http://example.com"), //the project url
        );
        let obj_meta = object::object_from_constructor_ref<Metadata>(constructor_ref);
        let mint_ref = fungible_asset::generate_mint_ref(constructor_ref);
        let burn_ref = fungible_asset::generate_burn_ref(constructor_ref);
        let transfer_ref = fungible_asset::generate_transfer_ref(constructor_ref);
        move_to(creator, FAController {
            mint_ref, burn_ref, transfer_ref
        });
        move_to(creator, FAObject {
            tk_ref: obj_meta
        })
    }
    public fun mint_token(owner: &signer, amount: u64, receiver: address) acquires FAController {
        let sender_addr = signer::address_of(owner);
        let fa_controller = borrow_global<FAController>(@addr);
        primary_fungible_store::mint(&fa_controller.mint_ref, sender_addr, amount);
    }
    fun transfer_token(owner: &signer, receiver: address, amount: u64) acquires FAObject {
        let token_metadata = borrow_global<FAObject>(@addr).tk_ref;
        primary_fungible_store::transfer(owner, token_metadata, receiver, amount);
    }
    #[view]
    public fun get_token_balance(user_address: address):u64 acquires FAObject {
        let tk_token = borrow_global<FAObject>(@addr).tk_ref;
        let total = primary_fungible_store::balance(user_address, tk_token);
        total
    }
}

The contract above implements a minimalistic example of an FA. You have two pieces of state data, FAController for saving a reference to the TransferRef, MintRef and BurnRef of the FA object metadata. The second state FAObject stores a reference to the object metadata.

In the function create_token, you created a named object that returns a ConstructorRef. The ConstructorRef and other metadata parameters are passed to the function below to enable the automatic creation of a FungibleStore for FA holders.

primary_fungible_store::create_primary_store_enabled_fungible_asset

The FA metadata is created from the ConstructorRef and this is saved in the FAObject state in the creator account. You also generated three references, TransferRef, MintRef, and BurnRef, which were moved into the creator account. This reference, as explained in this post's object section, is used to transfer, mint, and burn the operation of the token. The mint_token and transfer_token functions are used to mint new FA passing in the reference of your created token metadata.

From the example contract module, you can see that the standard is very flexible because it uses object, allowing you to be creative with your FA implementation.

Final Word

Aptos introduces innovative capabilities in managing digital and fungible assets, surpassing some limitations of Ethereum's ERC standards. Its flexible framework empowers developers to build secure applications optimized for high performance.

As Aptos continues to advance, its potential to redefine blockchain development, particularly in DeFi and NFTs, positions it as a strong competitor in the smart contract space.

Thanks for reading.

0
Subscribe to my newsletter

Read articles from Osikhena Oshomah directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Osikhena Oshomah
Osikhena Oshomah

Javascript developer working hard to master the craft.