An Overview of Aptos Smart Contract and Token Standard
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 transferObjec
t 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.
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.