Building an Auction App With Aptos
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!
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.