Building an Auction App With Aptos

Osikhena OshomahOsikhena Oshomah
12 min read

My first introduction to Aptos was through Developer DAO’s writing competition on Aptos. My article secured one of the top positions, and Aptos sponsored me to write six more articles, which helped me continue learning about the Aptos ecosystem.

The following articles were written and published;

In this sixth and final article of the series, though not the last I will write about Aptos; I will discuss an improved auction dapp created in the first article. In this auction dapp, a user can create an auction and bidders can bid for any auction. I will also explain how to build a much better auction smart contract, focusing on using different data structures in Aptos. Using the Aptos SDK, we will integrate the auction dapp smart contract with a frontend. The demo application created can be found on GitHub. You can run the application by cloning the repo and installing dependencies.

Prerequisite

To follow along with the demo explanation, clone the repository on GitHub and install the Aptos CLI on your machine if it's not already installed. The demo auction application is created using the create-aptos-dapp tool. You can refer to this article to learn how to use the create-aptos-dapp.

Auction Smart Contract Init Module

The smart contract code is located in contracts/sources/auction.move. We defined the contract module at the top of the file and imported the relevant packages. We create a resource account to hold and manage the APT deposits in the auction contract.

A resource account is a signer account capable of signing transactions. It functions like a normal account but the difference is that it does not have a private key and the account is controlled by code. A resource account can own resources and also sign transactions. Let’s see how we implemented a resource account in the auction contract.

fun create_contract_resource(creator: &signer){
        //create a resource account to hold the contract funds
        let (_, signer_capability) = account::create_resource_account(creator, WALLET_SEED);
        move_to(creator, SignerCapabilityStore { signer_capability });
        move_to(
            creator,
            Registry {
                auction_objects: vector::empty<Object<AuctionMetadata>>()
            }
        )
    }

We create the resource account in the init_module, which is called when the contract creator deploys the contract. Creating a resource account returns a signer capability stored as a resource in the contract deployer’s account.
The signer capability allows the resource account to sign transactions, so we must keep it secure by storing it under the contract creator’s account. Also, in the init_module, we create a Registry resource to store the created auctions and save them in the contract creator’s account.

Auction Contract Entry Function

The smart contract consists of four entry functions called from the frontend. There’s a function to create a new auction, a function to bid on an auction, a function to collect the winning bid, and finally, a function to close an auction. Let’s explore these functions in-depth and see how they were implemented.

The Auction Creation Function

The create_new_auction function is called when creating a new auction. It creates an object and stores an AuctionMetadata resource inside.

public entry fun create_new_auction(auction_creator: &signer,
                           auction_brief_description: String,
                           auction_description_url: String,
                           auction_end_date: u64) acquires OwnerAuctions, Registry {
        let auction_creator_address = signer::address_of(auction_creator);
        let obj_constructor_ref = object::create_object(auction_creator_address);
        let obj_constructor_signer = object::generate_signer(&obj_constructor_ref);
        //move the AuctionMetadata to the object
        move_to(
            &obj_constructor_signer,
            AuctionMetadata{
                owner: signer::address_of(auction_creator),
                auction_brief_description,
                highest_bidder: option::none(),
                highest_bid: option::none(),
                auction_end_time: auction_end_date,
                created_date: timestamp::now_microseconds(),
                auction_ended: false,
                pending_returns: smart_table::new<address, u64>(),
                auction_description_url,
                bidders: smart_table::new<address, u64>()
            }
        );
        //get the object reference and save to the creator address
        let  obj_auction_ref= object::object_from_constructor_ref<AuctionMetadata>(&obj_constructor_ref);
        if (exists<OwnerAuctions>(auction_creator_address)){
            let auctions_list = &mut borrow_global_mut<OwnerAuctions>(auction_creator_address).auction_list;
            vector::push_back(auctions_list, obj_auction_ref);
        } else{
            //initialize the
            let auctions_list = vector::empty<Object<AuctionMetadata>>();
            vector::push_back(&mut auctions_list, obj_auction_ref);
            move_to(
                auction_creator,
                OwnerAuctions {
                    auction_list: auctions_list
                }
            );
        };
        //save the object inside the contract registry
        let auction_registry =&mut borrow_global_mut<Registry>(@auction).auction_objects;
        vector::push_back( auction_registry, obj_auction_ref);
    }

The AuctionMetadata resource saves information about the created auction, as shown in the code above. After creating the object to store the AuctionMetadata, we create a reference to the object using the constructor ref of the created object.

let  obj_auction_ref= object::object_from_constructor_ref<AuctionMetadata>(&obj_constructor_ref);

A reference can be passed as a parameter to an entry function to retrieve the object and remember that a constructor ref is generated when an object is created.
Note: For more understanding of the Move object, you can look at this post [link to the published article on the move object].

The created object reference of type AuctionMetadata points to that object, and is saved in a move resource, OwnerAuctions, under the auction creator account. OwnerAuctions contains a vector of AuctionMetadata reference type created by the auction creator’s account. Suppose the OwnerAuctions resource does not exist on the auction creator account. In that case, we create it and push AuctionMetadata reference into it, but if it does exist, we push AuctionMetadata into it.

The object reference AuctionMetadata is also saved in the Registry resource, which is owned by the contract account. This is done to track all the auction objects created in the contract.

The Bidding Function

The make_auction_bid function is called when a user wants to bid on an auction. This function accepts as a parameter, an object reference of type AuctionMetadata. In the section above, a function to create an auction was defined. This function creates an auction object reference that is saved and used to refer to and retrieve information about an auction resource.

public entry fun make_auction_bid(bidder: &signer, auction_object: Object<AuctionMetadata>, bid_amount: u64) acquires AuctionMetadata, UserAuctionBid,SignerCapabilityStore {
        //get the auction object and check if it exists
        let auction_address = object::object_address(&auction_object);
        let resource_account_signer = &get_signer();
        let resource_account_address = signer::address_of(resource_account_signer);
        if (!object::object_exists<AuctionMetadata>(auction_address)) {
           abort(ERR_OBJECT_DONT_EXIST)
        };
        let auction = borrow_global_mut<AuctionMetadata>(auction_address);
        if ( timestamp::now_microseconds() > auction.auction_end_time ){
          abort(ERR_AUCTION_TIME_LAPSED)
        };
        if ( auction.auction_ended ){
            abort(ERR_AUCTION_ENDED)
        };
        //check if the bid is greater than the existing highest bid
        let former_highest_bid: u64 = 0;
        if (option::is_some(&auction.highest_bid)){
             former_highest_bid = *option::borrow(&auction.highest_bid);
        };
        //check if the user bid is greater than the former bid
        if ( former_highest_bid > bid_amount ){
            abort(ERR_BID_SMALLER_THAN_HIGHEST_BID)
        };
        //we are good, we now update the auction data
        auction.highest_bid = option::some(bid_amount);
        let bidder_address = signer::address_of(bidder);
        auction.highest_bidder = option::some(bidder_address);
        let sm_table_bidders = &mut auction.bidders;
        //check if the address is on the smart table
        let added_bid_amount = 0;
        if ( smart_table::contains(sm_table_bidders, bidder_address)){
            //return the old amount
            let old_amount = smart_table::borrow(sm_table_bidders, bidder_address);
            //get the object that owns the money
            let bids_vector = &mut borrow_global_mut<UserAuctionBid>(bidder_address).bids;
            //removed the old value
            added_bid_amount = bid_amount - *old_amount;
            vector::remove_value(bids_vector, &AuctionBid {
                bid_amount: *old_amount,
                auction_address
            });
        };
        smart_table::upsert(sm_table_bidders, bidder_address, bid_amount);
        if ( added_bid_amount > 0 ){ //we have previously transferred, so we topping up our bid
            aptos_account::transfer(bidder, resource_account_address, added_bid_amount);
        } else { //first time transferring
            aptos_account::transfer(bidder, resource_account_address, bid_amount);
        };
        //save the user bid made to the UserAuctionBid state
        if (exists<UserAuctionBid>(bidder_address)){
            let bids_vector = &mut borrow_global_mut<UserAuctionBid>(bidder_address).bids;
            vector::push_back(bids_vector, AuctionBid {
                bid_amount,
                auction_address
            });
        } else{
            //does not exist
            let bid_vector = vector::empty<AuctionBid>();
            vector::push_back(&mut bid_vector, AuctionBid{
                bid_amount,
                auction_address
            });
            move_to(
                bidder,
                UserAuctionBid {
                    bids: bid_vector
                }
            );
        }
    }

The make_auction_bid function retrieves an auction by using the passed parameter, auction_object: Object<AuctionMetadata>, which is a reference of AuctionMetadata to get the auction owner's object address. The auction resource is retrieved using the object address, as an object owns each auction.

The private function get_signer() retrieves the signer of the resource account created when the contract was deployed. APT Coin will be transferred from the bidder account to the resource account address.

  fun get_signer(): signer acquires SignerCapabilityStore {
      let signer_capability = &borrow_global<SignerCapabilityStore>(@auction).signer_capability;
      account::create_signer_with_capability(signer_capability)
  }

We perform some checks on the existence of the auction resource before retrieving it and we also check if the auction is still running and not ended. If all checks pass, we proceed else an appropriate error is thrown on a failed check.

After we retrieve the auction resource we want to bid on, we compare the highest bid with the passed parameter bid_amount. If the bid_amount is smaller than the highest bid on the auction, the contract throws an error, aborting further execution, but if the bid_amount is bigger than the previous highest bid, the new bid amount becomes the new highest bid.

 if ( former_highest_bid > bid_amount ){
      abort(ERR_BID_SMALLER_THAN_HIGHEST_BID)
 };

Each auction resource has a field, bidders, which is a smart table “key-value” data structure. This auction contract implementation saves the bid's value and the bidder's address in a smart table, using the bidder's address as a key and the bid amount as a value.

As someone bids on an auction, the bidders’ smart table is updated to reflect how much an address has deposited as a bid amount. This value will be returned to the bidder if they are not successful in the auction, so it is saved and tracked in a smart table data structure.

We save a mutable reference to the auction bidders’ smart table.

let sm_table_returns = &mut auction.bidders;

The bidder's address is checked in the bidder's smart table. If it is present, they have bid in that auction before. So we update their bid amount and transfer the difference between their previous bid and current bid from their account to the resource account address. However, if the user has not made any bid in that auction before, the full bid amount is transferred from their account, and the bidders' smart table is updated.

Finally, we create a UserAuctionBid resource and move it to the bidder account, if it does not exist; if it does we update the existing one. UserAuctionBid contains a vector of AuctionBids type that includes the auction address the bidder is bidding on and the amount of the bid. This is done to display a UI of all the bids an account has made.

struct AuctionBid has store, drop, copy{
        auction_address: address,
        bid_amount: u64
}

The Winning Bid Collector Function

This is a simple function executed by the owner of an auction. The function accepts an object reference of type AuctionMetadata and a signer. The object address is obtained using the object reference, and the address is used to retrieve the auction resource. If the auction has ended, the highest bid amount from the auction is transferred from the resource account to the address of the function caller. (the creator of the auction)

In a real-world application, the auction item is transferred to the highest bidder after an auction ends and a winner is determined. This could be done by transferring an NFT, where owning the NFT represents owning the actual asset. This auction demo application was created for learning purposes, so such implementations and real-life scenarios were not included.

public entry fun collect_winning_bid(auction_owner: &signer, auction_object: Object<AuctionMetadata>) acquires AuctionMetadata, SignerCapabilityStore{
        let auction_address = object::object_address(&auction_object);
        let resource_account_signer = &get_signer();
        let resource_account_address = signer::address_of(resource_account_signer);
        if (!object::object_exists<AuctionMetadata>(auction_address)) {
            abort(ERR_OBJECT_DONT_EXIST)
        };
        let auction = borrow_global<AuctionMetadata>(auction_address);
        if ( !auction.auction_ended ){
            abort(ERR_AUCTION_STILL_RUNNING)
        };
        assert!(auction.owner == signer::address_of(auction_owner), ERR_NOT_THE_OWNER);
        //collect the highest bid
        coin::transfer<AptosCoin>(resource_account_signer, auction.owner, *option::borrow(&auction.highest_bid))
    }

The Close Auction Function

This function can be called by anyone to close an auction after the time has elapsed. The function takes a single parameter of object reference to retrieve the auction. The auction_ended field of the auction resource is set to true and bidders smart table field which contains a list of bidders and the amount they bid on the auction is passed to a utility function refund_money_back_to_non_win_bids to make refunds to non-winning bids.

public entry fun close_auction(auction_object: Object<AuctionMetadata>) acquires AuctionMetadata, SignerCapabilityStore
    {
        //get the auction
        let auction_address = object::object_address(&auction_object);
       if (!object::object_exists<AuctionMetadata>(auction_address)) {
            abort(ERR_OBJECT_DONT_EXIST)
        };
        let auction = borrow_global_mut<AuctionMetadata>(auction_address);
        if ( timestamp::now_microseconds() < auction.auction_end_time ){
            abort(ERR_AUCTION_TIME_NOT_LAPSED)
        };
        if ( auction.auction_ended ){
            abort(ERR_AUCTION_ENDED)
        };
        //end the auction
        auction.auction_ended = true;
        //return the bidders money that didn't win back
        let highest_bidder = option::borrow(&auction.highest_bidder);
        refund_money_back_to_non_win_bids(&auction.bidders, *highest_bidder);
    }
 fun refund_money_back_to_non_win_bids(bidders: &SmartTable<address, u64>, bid_winner: address) acquires SignerCapabilityStore {
        //loop through the smart table
        //retrieve the signer for the wallet object
        let resource_account_signer = &get_signer();
        let resource_account_address = signer::address_of(resource_account_signer);
        smart_table::for_each_ref(bidders, |key, value| {
            if ( *key != bid_winner){
                coin::transfer<AptosCoin>(resource_account_signer, *key, *value);
            }
        })
    }

The View Functions

The smart contract has four view functions that read from the blockchain without modifying the state. A view function is a pure function that retrieves data and does not incur gas fees when called off-chain, making it ideal for querying information from smart contracts.

Below is an implementation of one of the view functions in the contract.

#[view]
public fun get_all_auctions_user_bidded_on(bidder_address: address): vector<UserAuctionBiddedOn> acquires AuctionMetadata, UserAuctionBid{
        let user_auctions = vector<UserAuctionBiddedOn>[];
        if (exists<UserAuctionBid>(bidder_address)){
            let auctions = borrow_global<UserAuctionBid>(bidder_address).bids;
            //get the auctionMetada
            let i = 0;
            while ( i < vector::length(&auctions)){
                //loop through the vector here
                let auction_data = vector::borrow(&auctions, i);
                let auction_ref = object::address_to_object<AuctionMetadata>(auction_data.auction_address);
                let auction = borrow_global<AuctionMetadata>(auction_data.auction_address);
                let user_auction_bidded_on = UserAuctionBiddedOn{
                    highest_bidder: auction.highest_bidder,
                    highest_bid: auction.highest_bid,
                    my_bid: auction_data.bid_amount,
                    auction_ended: auction.auction_ended,
                    total_bidders: smart_table::length(&auction.bidders),
                    auction_reference: auction_ref
                };
                vector::push_back(&mut user_auctions, user_auction_bidded_on);
                //increment the loop
                i = i + 1;
            };
        };
        user_auctions
    }

The function get_all_auctions_user_bidded_on retrieves all the auctions that an account has bid on. It receives as a parameter an address, and it returns a vector of type <UserAuctionBiddedOn>.

In the contract, an auction an address bids on is saved in a resource UserAuctionBid. We retrieve a vector of auction bids from the UserAuctionBid resource. We loop through the auction vector using a while loop. In each iteration of the while loop, we retrieved the auction. We pushed the auction details into a vector of type UserAuctionBiddedOn that is returned at the end of the view function execution.

Frontend Integration

The front-end integration was done using the Typescript SDK. Aptos Typescript SDK makes integrating the smart contract backend with the frontend easy. I have previously written about the SDK and how to get started.

We create and export a function for each smart contract entry function. In this function, the arguments are statically typed, making integration less error-prone. Below is an example of one such function.

import { InputTransactionData } from "@aptos-labs/wallet-adapter-react";
import { MODULE_ADDRESS } from "@/constants";
export type CreateNewAuctionArguments = {
    auction_brief_description: string; 
    auction_description_url: string;
    auction_end_date: number
};
export const createNewAuction = (args: CreateNewAuctionArguments): InputTransactionData => {
  const { auction_brief_description, auction_description_url, auction_end_date } = args;
  return {
    data: {
      function: `${MODULE_ADDRESS}::auction_contract::create_new_auction`,
      functionArguments: [auction_brief_description, auction_description_url, auction_end_date ],
    },
  };
};

To learn more about the Typescript SDK, refer to this tutorial.

My Experience with Aptos

Before participating in the writing competition, I knew nothing about Aptos. A presentation on Aptos by one of the DevRel convinced me to experiment and get my beaks wet.

There are a plethora of blog posts to help you on your journey as a beginner, but as of writing this blog piece most of the available tutorials are geared more towards beginner content. Intermediate and advanced content tutorials are limited for now which makes AI less efficient when searching for solutions to a problem. This will change though as the ecosystem expands and more developers adopt Aptos as the blockchain of choice.

My experience has been positive. I have delved deep into the well-written Aptos docs to gain valuable insight. The learning curve wasn’t too steep for me, as you can become productive with Aptos Move if you understand the concept of object and resource ownership.

I would like to express my gratitude to Developer DAO and Aptos Labs for the opportunity to write this series. It has been a wonderful experience that has sparked my deep interest in Aptos, and I look forward to continuing my journey of learning, building, and writing about Aptos.

Thank you!

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.