Aptos Dapp Journey - Part 2
Understanding Token Standards by Building a Product Hunt X Contract
In my first article, I talked about using create-aptos-dapp. It's this cool tool that helps you start an Aptos project quickly. As I was working, I noticed something was missing. Back then, it only had Vite templates, there wasn't a Next.js template. I thought, "Hey, that could be really useful!" So, I decided to make one myself. I worked hard on it and then sent it to the Aptos team.
Guess what? They liked it!
As you can see in Figure 1, they included my template in the CLI. Soon, when you use the Aptos CLI, you can choose a Next.js template. This is great for people who want to use server-side rendering in their apps.
I've been thinking about my next steps with Aptos and have some exciting plans!
I want to learn more about how Aptos handles tokens. I will try to make something like ProductHunt but on the blockchain. Sounds cool, right?
Two Ways to Do It. I'm going to try this in two different ways:
Using something called Tables.
Using Digital Assets (which are like NFTs).
It's like trying to bake a cake with two different recipes. I'm curious to see which one turns out better!
I'm not just making one thing, but two:
A system to keep track of stuff.
A way to store extra information.
It's like building two different tools that work together. I think it'll be challenging but fun!
Why I'm Doing This? I want to understand Aptos better. I think I'll learn a lot by trying different ways to do things. Plus, who knows? Maybe I'll make something useful!
I'm excited to get started. It feels like I'm about to go on a new adventure in the Aptos world. Wish me luck!
Exploring Aptos and Move Concepts
Let's dive into some concepts first as prerequisites.
New Token Standard
Aptos has two token standards: aptos_token::token
is a legacy standard that has been superseded by aptos_token_objects::token
, a more flexible and extensible standard built on top of Aptos' object model.
The best resources I found to document the functionality seemed outdated. Later, I discovered that aptos_token::token
is from the legacy Aptos token standard. When I checked the documentation on GitHub, it wasn’t clear what the differences were.
To learn more about the differences, go to the Aptos website under "Smart Contracts (Move)" and then to the Aptos Standard Section.
Side Notes for the Aptos Team
I am on the build/smart-contracts/table
, and when I click on Accounts, it takes me to network/blockchain/accounts
, when I expected to go to build/smart-contracts/accounts
. It changed the entire side menu.
It's annoying that some items in the documentation redirect you to another section, changing the menu bar. A small external link icon could be helpful.
Using the Digital Asset Standard
The Digital Asset standard is implemented using two main types of Objects:
Collections: A set of NFTs with a name and context for the group.
Tokens: Digital assets representing unique items, often used for NFTs.
All tokens must reference a parent collection, but the collection does not own the token. Newly minted tokens are typically owned by the creator and can be transferred to other accounts.
Creating Tokens
There are two main ways to create a token:
Named tokens (non-deletable)
"Unnamed" tokens (deletable)
Named tokens are often the simpler choice for most general-purpose NFTs or tokens that you expect to persist long-term. For more complex projects, especially those that might need to remove tokens in the future or have specific privacy requirements, unnamed tokens provide greater flexibility. An indexer is required to find an unnamed token.
Fungible Store
A fungible store is an object that holds a balance of a specific fungible asset for an account on the Aptos blockchain. Its key features include per-asset storage for each account, tracking token balances, primary (auto-created) and optional secondary stores, and it is used for depositing, withdrawing, and transferring tokens.
Primary Fungible Store is automatically created when an account first receives an asset, serves as the default store for most token operations, and is enabled using primary_fungible_store::create_primary_store_enabled_fungible_asset
.
Move Language
Move modules are essential containers for organizing related types and functions. They consist of key elements that can appear in any order:
Types and functions: Core components defining data structures and operations.
Use
statements: Import types from other modules for code reuse.Friend declarations: Specify trusted modules for controlled access.
Const: Define private constants for use within module functions.
This flexible structure allows developers to organize code efficiently
Importing Modules
To import modules in Move, you can use a statement like use aptos_framework::object::{Self, Object};
, In this example we imported two things from the aptos_framework::object
module:
Self
: This imports the module itself, allowing us to use the module's functions directly with theobject::
prefix.Object
: This imports theObject
type from the module.
The Self
import is used when we want to call more functions defined in the object
module. For example, functions like object::is_owner()
, object::owner()
, object::object_address()
, or object::owns()
can be used in our module after use aptos_framework::object::{Self}
.
Unit Testing
Unit testing is the most basic form of testing for Move contracts on Aptos. It lets you test individual functions and modules in isolation.
You can write unit tests directly in your Move files using special annotations:
The #[test]
attribute marks a function as a test:
#[test]
fun this_is_a_test() {
assert!(2 + 2 == 4, 0);
}
#[test_only]
to mark a module or module member as test-only code.
#[test_only]
module sender::test_module {
#[test]
fun test_function() {
// Test code here
}
}
#[expected_failure]
to indicate that a test is expected to fail.
#[test]
#[expected_failure]
fun test_should_fail() {
assert!(1 == 2, 0);
}
Run unit tests using the Aptos CLI:
aptos move test
You can use additional flags with the test command. For instance, the following command will run only tests whose fully qualified name contains “zero_coin”:
aptos move test -f zero_coin
Creating Contracts
I'm using create-aptos-dapp to get started. I picked the basic boilerplate because I want to write my own contract code from scratch. It's like starting with a blank canvas - I can't wait to see what I'll create!
Learning About Data Storage
As I was getting ready to write my contract, I found out there are two main ways to store data in Aptos Move: PropertyMap
and Table
.
At first, they seemed pretty similar, but I've learned they're quite different. Let me share what I found out.
The Difference Between PropertyMap and Table
PropertyMap
is like a special box just for token information. It's only for storing info about tokens and can only hold simple stuff like numbers and text. There are also some limits on how much it can store. I learned that PropertyMap
is great for two things:
Setting up default info for all tokens.
Adding details to individual tokens.
Conversely, a Table
is more like a Swiss army knife that can store all sorts of data. As there are no limits on what it can hold, you can use it for many different things, not just tokens.
After learning about both, I realized:
If working with tokens,
PropertyMap
is the way to go.For everything else,
Table
is probably the better choice.
Table Contract Code With Tests
The smart contract lets people add products, upvote them, and get product information. It's like a mini Product Hunt on the blockchain! I built it to learn how Table works.
The contract consists of two structs.
The Product
struct is like a digital card for each product. It has these attributes:
An ID number.
A name.
A description.
The number of upvotes.
The address of its creator.
The Product
struct is the main part that runs everything. It keeps a list of all the products and a count of how many products exist.
The smart contract can do some cool things:
Start up the platform.
Let people add new products.
Allow users to upvote products they like.
Show information about products when asked.
Making Sure It Works
I didn't just write the code and hope for the best; I created tests to ensure everything worked correctly.
I tested the following features:
Starting up the platform.
Adding new products.
Upvoting products.
Checking how many products there are.
Here is the contract code:
module launchpad_addr::product_hunt {
use std::string::{Self, String};
use std::signer;
use aptos_framework::table::{Self, Table};
// Struct to represent a product
struct Product has store {
id: u64,
name: String,
description: String,
upvotes: u64,
creator: address
}
// Struct to represent the ProductHunt platform
struct ProductHunt has key {
products: Table<u64, Product>,
product_count: u64
}
// Initialize the ProductHunt platform
public fun initialize(account: &signer) {
move_to(
account,
ProductHunt { products: table::new(), product_count: 0 }
);
}
// Add a new product to the platform
public entry fun add_product(
account: &signer, name: String, description: String
) acquires ProductHunt {
let creator = signer::address_of(account);
let product_hunt = borrow_global_mut<ProductHunt>(signer::address_of(account));
let product_id = product_hunt.product_count + 1;
let new_product = Product { id: product_id, name, description, upvotes: 0, creator };
table::add(&mut product_hunt.products, product_id, new_product);
product_hunt.product_count = product_id;
}
// Get a product by its ID
public fun get_product(product_hunt: &ProductHunt, product_id: u64): &Product {
table::borrow(&product_hunt.products, product_id)
}
// Upvote a product
public entry fun upvote_product(account: &signer, product_id: u64) acquires ProductHunt {
let product_hunt = borrow_global_mut<ProductHunt>(@launchpad_addr);
let product = table::borrow_mut(&mut product_hunt.products, product_id);
product.upvotes = product.upvotes + 1;
}
// Get the total number of products
public fun get_product_count(product_hunt: &ProductHunt): u64 {
product_hunt.product_count
}
// Test-only imports
#[test_only]
use aptos_framework::account;
// Test initialization
#[test(admin = @launchpad_addr)]
public entry fun test_initialize(admin: &signer) {
initialize(admin);
assert!(exists<ProductHunt>(signer::address_of(admin)), 0);
}
// Test adding a product
#[test(admin = @launchpad_addr)]
public entry fun test_add_product(admin: &signer) acquires ProductHunt {
initialize(admin);
add_product(
admin,
string::utf8(b"Test Product"),
string::utf8(b"A test product")
);
let product_hunt = borrow_global<ProductHunt>(signer::address_of(admin));
assert!(get_product_count(product_hunt) == 1, 1);
let product = get_product(product_hunt, 1);
assert!(product.name == string::utf8(b"Test Product"), 2);
assert!(product.description == string::utf8(b"A test product"), 3);
assert!(product.upvotes == 0, 4);
assert!(product.creator == signer::address_of(admin), 5);
}
// Test upvoting a product
#[test(admin = @launchpad_addr, user = @0x456)]
public entry fun test_upvote_product(admin: &signer, user: &signer) acquires ProductHunt {
initialize(admin);
add_product(
admin,
string::utf8(b"Test Product"),
string::utf8(b"A test product")
);
upvote_product(user, 1);
let product_hunt = borrow_global<ProductHunt>(@launchpad_addr);
let product = get_product(product_hunt, 1);
assert!(product.upvotes == 1, 6);
upvote_product(admin, 1);
let product_hunt = borrow_global<ProductHunt>(@launchpad_addr);
let product = get_product(product_hunt, 1);
assert!(product.upvotes == 2, 7);
}
// Test getting product count
#[test(admin = @launchpad_addr)]
public entry fun test_get_product_count(admin: &signer) acquires ProductHunt {
initialize(admin);
let product_hunt = borrow_global<ProductHunt>(signer::address_of(admin));
assert!(get_product_count(product_hunt) == 0, 8);
add_product(
admin,
string::utf8(b"Product 1"),
string::utf8(b"Description 1")
);
let product_hunt = borrow_global<ProductHunt>(signer::address_of(admin));
assert!(get_product_count(product_hunt) == 1, 9);
add_product(
admin,
string::utf8(b"Product 2"),
string::utf8(b"Description 2")
);
let product_hunt = borrow_global<ProductHunt>(signer::address_of(admin));
assert!(get_product_count(product_hunt) == 2, 10);
}
}
Digital Asset Contract
The contract can do some amazing things, including creating collections (like albums for NFTs), making product NFTs (like digital cards for products), letting people upvote products they like, and keeping track of how popular products are.
The Building Blocks
I used some special Aptos features to make this work:
Collections: These are like folders for NFTs
Tokens: These are the actual NFTs, representing products
Property Maps: These let me add extra info to the NFTs
How it Works
Creating Collections: Anyone can make a new collection of products.
Minting Product NFTs: Users can create NFTs for their products. Each NFT has:
A name
A description
A picture (well, a link to one)
An upvote count
A status (like "New" or "Trending")
Changing Descriptions: Product owners can update their descriptions if they want.
Upvoting: People can upvote products they like. If a product gets over 100 upvotes, it becomes "Trending"!
Transferring Ownership: People can give their product NFTs to others.
Checking Popularity: Anyone can see how many upvotes a product has.
Here is the contract code:
module launchpad_addr::product_collection {
// Import necessary modules and functions
use std::string::{Self, String};
use std::string::utf8;
use std::signer;
use std::option::{Self};
use std::error;
use aptos_framework::object::{Self, Object};
use aptos_framework::resource_account;
use aptos_framework::account;
use aptos_framework::event;
use aptos_token_objects::token;
use aptos_token_objects::collection;
use aptos_token_objects::property_map;
// Define constants for the module
const MAX_DESCRIPTION_LENGTH: u64 = 2000;
const EDESCRIPTION_TOO_LONG: u64 = 1;
const PRODUCT_STATUS: vector<u8> = b"Product Status";
const UPVOTE_COUNT: vector<u8> = b"Upvote Count";
const STATUS_TRENDING: vector<u8> = b"Trending";
const STATUS_NEW: vector<u8> = b"New";
const COLLECTION_DESCRIPTION: vector<u8> = b"Product Hunt NFT Collection";
const COLLECTION_NAME: vector<u8> = b"Product Showcase";
const COLLECTION_URI: vector<u8> = b"https://productshowcase.com";
// Define structures for storing collection and token information
struct CollectionMutatorStore has key {
mutator_ref: collection::MutatorRef
}
struct TokenMutatorStore has key {
mutator_ref: token::MutatorRef,
property_mutator_ref: property_map::MutatorRef,
token_name: String
}
struct UpvoteCount has key {
value: u64
}
// Define an event structure for upvote updates
#[event]
struct UpvoteUpdate has drop, store {
product: address,
old_upvotes: u64,
new_upvotes: u64
}
// Function to create a new collection
public entry fun create_collection(creator: &signer) {
let royalty = option::none();
// Create an unlimited collection
let collection_constructor_ref =
&collection::create_unlimited_collection(
creator,
utf8(COLLECTION_DESCRIPTION),
utf8(COLLECTION_NAME),
royalty,
utf8(COLLECTION_URI)
);
// Generate a mutator reference for the collection
let mutator_ref = collection::generate_mutator_ref(collection_constructor_ref);
// Store the mutator reference
move_to(creator, CollectionMutatorStore { mutator_ref });
}
// Function to mint a new product (token) in the collection
public entry fun mint_product(
creator: &signer,
name: String,
description: String,
uri: String
) {
let royalty = option::none();
// Create a new named token
let token_constructor_ref =
&token::create_named_token(
creator,
utf8(COLLECTION_NAME),
description,
name,
royalty,
uri
);
// Generate mutator references for the token and its properties
let mutator_ref = token::generate_mutator_ref(token_constructor_ref);
let property_mutator_ref =
property_map::generate_mutator_ref(token_constructor_ref);
// Initialize the token's property map
let properties = property_map::prepare_input(vector[], vector[], vector[]);
property_map::init(token_constructor_ref, properties);
// Add initial properties to the token
property_map::add_typed(
&property_mutator_ref,
string::utf8(PRODUCT_STATUS),
string::utf8(STATUS_NEW)
);
property_map::add_typed(
&property_mutator_ref,
string::utf8(UPVOTE_COUNT),
1
);
// Store the token's mutator information
move_to(
creator,
TokenMutatorStore { mutator_ref, property_mutator_ref, token_name: name }
);
// Initialize the upvote count for the token
let object_signer = object::generate_signer(token_constructor_ref);
move_to(&object_signer, UpvoteCount { value: 1 });
}
// Function to modify a product's description
public entry fun modify_product_description(
owner: &signer, product_object: Object<token::Token>, new_description: String
) acquires TokenMutatorStore {
// Check if the new description is not too long
assert!(
string::length(&new_description) <= MAX_DESCRIPTION_LENGTH,
error::out_of_range(EDESCRIPTION_TOO_LONG)
);
// Verify that the signer is the owner of the product
let owner_address = signer::address_of(owner);
assert!(object::is_owner(product_object, owner_address), 0);
// Retrieve and update the token's mutator information
let TokenMutatorStore { mutator_ref, property_mutator_ref, token_name } =
move_from<TokenMutatorStore>(owner_address);
// Set the new description
token::set_description(&mutator_ref, new_description);
// Store the updated mutator information
move_to(
owner,
TokenMutatorStore { mutator_ref, property_mutator_ref, token_name }
);
}
// Function to upvote a product
public entry fun upvote_product(
user: &signer, product_object: Object<token::Token>, amount: u64
) acquires TokenMutatorStore, UpvoteCount {
// Verify that the signer is the owner of the product
let owner_address = signer::address_of(user);
assert!(object::is_owner(product_object, owner_address), 0);
let product_address = object::object_address(&product_object);
// Update the upvote count
let upvote_count = borrow_global_mut<UpvoteCount>(product_address);
let old_upvotes = upvote_count.value;
let new_upvotes = old_upvotes + amount;
upvote_count.value = new_upvotes;
// Update the token's properties
let TokenMutatorStore { mutator_ref: _, property_mutator_ref, token_name: _ } =
borrow_global<TokenMutatorStore>(owner_address);
property_map::update_typed(
property_mutator_ref,
&string::utf8(UPVOTE_COUNT),
new_upvotes
);
// Update the product status based on upvote count
let new_status = if (new_upvotes > 100) {
STATUS_TRENDING
} else {
STATUS_NEW
};
property_map::update_typed(
property_mutator_ref,
&string::utf8(PRODUCT_STATUS),
string::utf8(new_status)
);
// Emit an upvote update event
event::emit(UpvoteUpdate { product: product_address, old_upvotes, new_upvotes });
}
// Function to transfer ownership of an object
public entry fun transfer<T: key>(
owner: &signer, object: Object<T>, to: address
) {
let owner_address = signer::address_of(owner);
assert!(object::is_owner(object, owner_address), 0);
object::transfer(owner, object, to);
}
// Function to get the upvote count of a product
#[view]
public fun get_upvote_count(product_object: Object<token::Token>): u64 acquires UpvoteCount {
let product_address = object::object_address(&product_object);
borrow_global<UpvoteCount>(product_address).value
}
}
In the test
folder, I created a file to write the tests.
NFT Contract Tests
This test file covers the main functionalities of the product_collection
module, including:
Creating a collection
Minting a product with custom name, description, and URI
Modifying the product description
Upvoting the product
Checking the upvote count
Transferring the product to another use
#[test_only]
module launchpad_addr::product_collection_tests {
use std::string;
use std::signer;
use aptos_framework::account;
use aptos_framework::object;
use aptos_token_objects::token;
use launchpad_addr::product_collection;
#[test(creator = @0x123, user = @0x456)]
public fun test_product_collection(creator: &signer, user: &signer) {
// Setup
account::create_account_for_test(signer::address_of(creator));
account::create_account_for_test(signer::address_of(user));
// Create collection
product_collection::create_collection(creator);
// Mint product
let name = string::utf8(b"Test Product");
let description = string::utf8(b"This is a test product");
let uri = string::utf8(b"https://test-product.com/image.jpg");
product_collection::mint_product(creator, name, description, uri);
// Get the product token object
let token_address = token::create_token_address(
&signer::address_of(creator),
&string::utf8(b"Product Showcase"),
&name
);
let product_object = object::address_to_object<token::Token>(token_address);
// Modify product description
let new_description = string::utf8(b"Updated test product description");
product_collection::modify_product_description(creator, product_object, new_description);
// Upvote product
product_collection::upvote_product(creator, product_object, 5);
// Check upvote count
let upvote_count = product_collection::get_upvote_count(product_object);
assert!(upvote_count == 6, 0); // Initial count (1) + 5 upvotes
// Transfer product to user
product_collection::transfer(creator, product_object, signer::address_of(user));
// Verify new owner
assert!(object::is_owner(product_object, signer::address_of(user)), 1);
}
}
Thanks to starting with create-aptos-dapp
, I only needed to make a few small tweaks to adapt to my contract and update the contract name. I can now use the convenient NPM scripts for testing and publishing that come with the template:
move:init
to initialize an account, publish the contract, and configure the development environment.move:publish
to publish the Move contract.move:test
to run Move unit tests.move:compile
to compile the Move contract.
Move Impressions From a Solana and Solidity Developer
Aptos is a new, fast blockchain like Solana. Its programming language, Move, is easier for Solana coders to learn than Ethereum's Solidity. Aptos handles accounts and money differently from Ethereum. In Aptos, everything is in one place in an account, while Ethereum splits things up more. Aptos also deals with data in its own way, using "resources" instead of Ethereum's variables.
Overall, if you know Solana, you'll find Aptos easier to work with than Ethereum. It's a fresh take on blockchain that's more like Solana than Ethereum in how it feels to use and build on.
Move Challenges and Solutions
The main challenge is understanding what modules are available and what they can do. The documentation website is quite good for learning the theory, the Move language, and other concepts. However, when it comes to using a module, the first struggle I faced was figuring out if there was a module that did what I needed and how to integrate it.
The solution is clear for developers. Example code and documentation can be found in the large Aptos-core monorepo. The overall impression is that the code, module documentation, and implementation are available but could be more beginner-friendly.
The best entry point for newcomers is Aptos Learn. (Kudos to the Aptos team!) It is a very well-made learning platform. There are workshops to understand Aptos, examples to see how it works, and templates to get started.
To make it even better, the documentation should show all available modules, the most useful use case examples with explanations, and a list of available hooks, similar to the wagmi hooks section.
Additional Resources
During my research, I found several valuable resources that might be helpful for you.
Token minting is handled by a dedicated program (refer to token-minter documentation).
The Aptos AI Assistant is beneficial for summarizing documentation but unreliable for coding tasks.
The Aptos Developers site offers a user interface for collection creation.