Aptos Dapp Journey - Part 2

Understanding Token Standards by Building a Product Hunt X Contract

·

15 min read

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:

  1. Using something called Tables.

  2. 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:

  1. A system to keep track of stuff.

  2. 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:

  1. Collections: A set of NFTs with a name and context for the group.

  2. 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:

  1. Named tokens (non-deletable)

  2. "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:

  1. Types and functions: Core components defining data structures and operations.

  2. Use statements: Import types from other modules for code reuse.

  3. Friend declarations: Specify trusted modules for controlled access.

  4. 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:

  1. Self: This imports the module itself, allowing us to use the module's functions directly with the object:: prefix.

  2. Object: This imports the Object 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:

  1. Setting up default info for all tokens.

  2. 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

  1. Creating Collections: Anyone can make a new collection of products.

  2. 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")

  3. Changing Descriptions: Product owners can update their descriptions if they want.

  4. Upvoting: People can upvote products they like. If a product gets over 100 upvotes, it becomes "Trending"!

  5. Transferring Ownership: People can give their product NFTs to others.

  6. 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.