Creating a Tezos Auction — A Fi Tutorial

BriceAldrich
13 min readMar 18, 2019

In this tutorial I am going to introduce you to some new capabilities of Fi, as well as some concepts you may have already learned from the previous tutorials. To do this we are going to build a smart contract auction complete with a buyers claim system. Exiting right? Let’s get started.

Development

Describing an Auction Item

Let’s first figure out what kind of storage, and structure definitions we may need to make an auction, starting with the auction item itself.

struct Asset(
string name,
string desc,
mutez bid,
timestamp end
);

We’ll call our auction item Asset. The asset should probably have a name, and description note string name and string desc. The next thing the asset should have is a bid price, denoted mutez bid. And finally we should probably have a timestamp of when the auction for the asset ends, denoted timestamp end.

Now our asset that we are auctioning is coming together, but we are missing some key parts. What about the manager of the asset (aka the seller)? How do we tell who holds the current bid? And how do we see what claims are related to the asset if any?

struct Asset(
string name,
string desc,
mutez bid,
address manager,
address winner,
Claim claim,
timestamp end,
timestamp resPeriod
);

Take a look at our new asset definition, you should notice a few new fields. We added the asset manager (seller) and the bid winner (buyer), denoted address manager and address winner. You’ll also notice a field for a claim, denoted Claim claim. Claim is not a native type to Fi or Michelson, we will have to define a structure for this later. Finally you will notice a new field timestamp resPeriod, which will be used as a time framer ahead of the end timestamp to allow the buyer to submit a claim.

Now that we have a robust Asset object, let’s define the storage for it. We need a way to map Assets by an ID.

storage map[int=>Asset] auction;

In this case we are going to use an integer to asset map.

Describing a Claim

Now that we have our asset object, we need to define the Claim type that we used before.

struct Claim(
bool resolved,
string buyer,
string seller
);

The first thing we need a parity flag telling us whether or not to look at a claim. You should notice a field bool resolved. The purpose of resolved is to let us know if the claim is still active (False) or whether the claim was resolved (True). If there isn’t a claim active, we will default resolved to True later to note there is no claim.

Next you’ll notice two string fields string buyer and string seller. The purpose of this is two way communication between the buyer and seller. When a buyer submits a claim, the claim description is set to the buyer string, while resolved is switched to False (showing no resolution). The seller string is available for the seller to respond on the claim.

Adding an Asset to The Auction

Now that we have our Asset object with a built in claim system, let’s allow anyone to add an Asset to the Auction. To do this we will need to create a contract entry point called addAsset.

entry addAsset(Asset asset){
if (input.asset.end < NOW || input.asset.end > input.asset.resPeriod ) {
throw(string "Invalid end or resolution dates.");
}
let nat len = length(storage.auction);
if (len == nat 0) {
storage.auction.push(int 0, input.asset);
storage.total = int 1;
} else {
storage.auction.push(storage.total, input.asset);
storage.total.add(int 1);
}
}

Note the input parameter is an Asset called asset. The first thing we need to do in this function is validate whether the asset ending date is not historical, and whether the resolution period is not less than the end date. If either of these conditions are true, we need to throw a message back saying the end or resolutions dates are invalid.

The next thing we do is get the length of the auction map in storage. We need to check if this is the first Asset added to the auction and if it is we push the Asset with the initial Id of 0. Next you’ll notice a total storage variable, let’s define this now.

storage int total;

You’ll notice total is automatically set to 1 when the first Asset as added. This let’s us know there are a total of 1 Asset(s) in the auction. If it is not the first Asset added to the auction,else{} clause, we push the Asset to the auction with the current value of total as the Id. We then update the total immediately after pushing the asset, incrementing it by 1. This means the Asset’s indices's will always be 1 integer behind the total.

Bidding on an Asset

The next function we will focus on is the bid function, which allows anyone, except the manager of an asset to bid on an asset.

entry bid(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
let address oldWinner = asset.winner;
if (SENDER == asset.manager) {
throw(string "You cant bid against your own item.");
}
if (asset.end < NOW) {
throw(string "The auction for the item requested has already ended.");
}
if (AMOUNT == mutez 0) {
throw(string "You cannot bid 0.");
}
if (asset.bid >= AMOUNT){
transfer(SENDER, AMOUNT);
throw(string "Sending funds back - bid too low.");
} else {
if (asset.bid != mutez 0) {
transfer(oldWinner, AMOUNT);
}
asset.winner = SENDER;
asset.bid = AMOUNT;
storage.auction.push(input.id, asset)
}
}

Note a bid is placed on the identifying id, passed as input. The first thing we do is check whether the input id can map to an Asset by using the in function. If no Asset is found, we throw an error back to the SENDER.

If the Asset is found, we get the Asset off the map by input.id and store it into a local variable called asset, denoted let Asset asset = ---. After we retrieve the Asset being bid on from the map, we keep track of the current winner of the bid, by storing the winner in a variable called oldWinner.

The next thing we do before continuing is check whether the SENDER invoking this bid is the manager for the asset being bid on. If that’s the case we throw a message back “You cannot bid against your own item.” Nice try right?

The next thing we check for is whether or not the auction for this asset has ended, denoted if (asset.end < NOW). If the auction for this Asset has ended we notify the SENDER. The next thing we check for is a 0 transfer bid, denoted if (AMOUNT == mutez 0). If the SENDER sent 0 XTZ along with their bid, we throw saying you cannot bid 0.

The next thing we check is if the current bid on the Asset is greater than or equal to the bid being placed, if (asset.bid >= AMOUNT). If that’s the case, we transfer the funds sent along with the bid back to the bidder, transfer(SENDER, AMOUNT);. We then throw an message, letting the SENDER know that the bid to low.

If the SENDER’s bid is higher than the current bid for the Asset, else{}, we then check if this is the first bid on the Asset, to do this we assume the current bid amount is 0. If the current bid is not 0, if (asset.bid != mutez 0), we send the current bid amount back to the oldWinner, transfer(oldWinner, AMOUNT);.

The next thing we do assign asset.winner to the SENDER, showing that the SENDER now holds the highest bid. We update the Asset’s bid amount to the AMOUNT sent by the SENDER, asset.bid = AMOUNT. And finally, we update the Asset by pushing the new data to the map again, storage.auction.push(input.id, asset);.

Letting The Winner Add A Claim

The next functionality we will focus on in our smart contract is the ability for the winner (buyer) to add a claim if the contract ended, and the current timestamp is less than the resolution period.

entry addClaimBuyer(int id, string claim){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.winner) {
throw(string "You are not authorized to add to claim.");
}
if (asset.end > NOW) {
throw(string "You can't make a claim on an auction still live.");
}
if (asset.resPeriod > NOW) {
asset.claim.resolved = bool false;
asset.claim.buyer = input.claim;
storage.auction.push(input.id, asset);
}
}

Note we pass the id to the Asset being claimed against, and the claim string as input. We then then do the same in check for the id that we previously did for bid, and if the Asset is found we assign it to a local variable.

The next thing we check for is if the SENDER is in fact the winner of the auction, if (SENDER != asset.winner). If not, we throw an unauthorized message. The next thing we check for is if auction has ended, if (asset.end > NOW), because we don’t want to allow a claim to be made on a live auction. Finally the last check we make is if the claim is made within the resolution period, if (asset.resPeriod > NOW). If it is, we can add our claim.

To add the claim we set the asset.claim.resolved flag to False, asset.claim.resolved = bool false;, to show the claim status. We then take the input.claim string and assign it to the claim’s buyer string, asset.claim.buyer = input.claim;. Finally we update the Asset containing the claim to the auction map, storage.auction.push(input.id,asset);.

Letting the Seller Respond to A Claim

This function will be very similar to the addClaimBuyer entry defined before, but pertains to the Asset manager.

entry addClaimSeller(int id, string claim){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to add to claim.");
}
if (asset.end > NOW) {
throw(string "You can't make a claim on an auction still live.");
}
asset.claim.seller = input.claim;
storage.auction.push(input.id, asset);
}

Note the same input variables are passed as addClaimBuyer. We then check if the Asset requested exists like previously, and if it does we assign it to a local variable. We then authenticate the SENDER invoking this function as the manager, if (SENDER != asset.manager);.

We again check if the Asset is still in a live auction, and throw a message if so. Finally if all those conditions are met, we assign the input.claim to the claim.seller string. We then push the Asset containing the claim back to the auction map like before.

Letting The Buyer Resolve A Claim

In this contract, we are going to let the buyer resolve the claim. If the buyer is satisfied with the claim resolution they can invoke the resolveClaim function defined below.

entry resolveClaim(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.winner) {
throw(string "You are not authorized to resolve this claim.");
}
if (asset.end > NOW) {
throw(string "You can't resolve a claim on an auction still live.");
}
if (asset.claim.resolved == bool true) {
throw(string "There is nothing to resolve.");
}
asset.claim.resolved = bool true;
storage.auction.push(input.id, asset);
}

Letting the Manager Withdraw the Payment for The Last Bid

The next functionality we need to add, is allow the Manager of an Asset withdraw from the contract, if their auction has ended, if the resolution period has past, and if there are no current claims.

entry withdraw(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to withdraw from this auction.");
}
if (asset.claim.resolved == bool false) {
throw(string "You must resolve the claim with the buyer to receive funds.")
}
if (asset.end > NOW || NOW < asset.resPeriod) {
throw(string "The auction has not ended or the resolution period has not past.")
}
if (asset.bid != mutez 0) {
transfer(SENDER, asset.bid);
} else {
throw(string "You cannot withdraw 0.")
}
}

Note we take in an id as input, for the Asset the manager is withdrawing from. Then, like before, we check whether the input id is a key to our auction map. If we found an Asset at that Id in the auction storage we again assign it to a local variable. We then check if the SENDER invoking this entry is the manager of the Asset, if (SENDER != asset.manager).

The next thing we look for is if the Asset has a currently active claim, if (asset.claim.resolved == bool false. If the claim shows no resolution, we through back an error telling the SENDER that you can’t withdraw with an open resolution.

After the claim check, we then check whether the asset’s auction has ended, as well as if the resolution period has past, if (asset.end > NOW || NOW < asset.resPeriod). Finally the last thing we need check is if the current bid is 0, because you cannot transfer() 0, if (asset.bid != mutez 0). If the asset’s bid is not 0, we transfer the SENDER (manager/seller), the amount of the winning bid.

Letting the Manager of an Asset Refund the Winner

The last functionality we will add to the auction contract is the ability for a manager/seller to refund the winner of an asset.

entry refund(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to send a refund for this auction.");
}

if (asset.end > NOW) {
throw(string "The auction has not ended.");
}
if (asset.bid != mutez 0) {
transfer(asset.winner, asset.bid);
} else {
throw(string "You cannot refund 0.")
}
asset.claim.resolved = bool true;
storage.auction.push(input.id, asset);
}

Notice we again pass an id as input, this time to represent the asset we are refunding for. We again check if an asset exits exists at that id in the auction map, and then store the asset if it does. Like the withdraw function, we again verify whether the SENDER invoking the function is the the asset manager.

The next thing we check for is whether the auction has ended, if (asset.end > NOW). We then check if the refund amount is 0 because we cannot transfer 0, if (asset.bid != mutez 0). If the winning bid is not 0, we transfer the winning bid amount back the the winning bidder, transfer(asset.winner, asset.bid).

Because the manager of an asset is refunding the winner, we will cancel out any claim that exists on the asset, asset.claim.resolved = bool true;. And lastly we update the asset by pushing it back to the auction map, storage.auction.push(input.id, asset);.

Putting It All Together

struct Claim(
bool resolved,
string buyer,
string seller
);
struct Asset(
string name,
string desc,
mutez bid,
address manager,
address winner,
Claim claim,
timestamp end,
timestamp resPeriod
);
storage map[int=>Asset] auction;
storage int total;
entry addAsset(Asset asset){
if (input.asset.end < NOW || input.asset.end > input.asset.resPeriod ) {
throw(string "Invalid end or resolution dates.");
}
let nat len = length(storage.auction);
if (len == nat 0) {
storage.auction.push(int 0, input.asset);
storage.total = int 1;
} else {
storage.auction.push(storage.total, input.asset);
storage.total.add(int 1);
}
}
entry addClaimBuyer(int id, string claim){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.winner) {
throw(string "You are not authorized to add to claim.");
}
if (asset.end > NOW) {
throw(string "You can't make a claim on an auction still live.");
}
if (asset.resPeriod > NOW) {
asset.claim.resolved = bool false;
asset.claim.buyer = input.claim;
storage.auction.push(input.id, asset);
}
}
entry addClaimSeller(int id, string claim){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to add to claim.");
}
if (asset.end > NOW) {
throw(string "You can't make a claim on an auction still live.");
}
asset.claim.seller = input.claim;
storage.auction.push(input.id, asset);
}
entry resolveClaim(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.winner) {
throw(string "You are not authorized to resolve this claim.");
}
if (asset.end > NOW) {
throw(string "You can't resolve a claim on an auction still live.");
}
if (asset.claim.resolved == bool true) {
throw(string "There is nothing to resolve.");
}
asset.claim.resolved = bool true;
storage.auction.push(input.id, asset);
}
entry withdraw(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to withdraw from this auction.");
}
if (asset.claim.resolved == bool false) {
throw(string "You must resolve the claim with the buyer to receive funds.")
}
if (asset.end < NOW || NOW < asset.resPeriod) {
throw(string "The auction has not ended or the resolution period has not past.")
}
if (asset.bid != mutez 0) {
transfer(SENDER, asset.bid);
} else {
throw(string "You cannot withdraw 0.")
}
}
entry refund(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
if (SENDER != asset.manager) {
throw(string "You are not authorized to send a refund for this auction.");
}

if (asset.end > NOW) {
throw(string "The auction has not ended.");
}
if (asset.bid != mutez 0) {
transfer(asset.winner, asset.bid);
} else {
throw(string "You cannot refund 0.")
}
asset.claim.resolved = bool true;
storage.auction.push(input.id, asset);
}
entry bid(int id){
if (in(storage.auction, input.id) == bool false) {
throw(string "No item exists by that Id for this auction.");
}
let Asset asset = storage.auction.get(input.id);
let address oldWinner = asset.winner;
if (SENDER == asset.manager) {
throw(string "You cant bid against your own item.");
}
if (asset.end < NOW) {
throw(string "The auction for the item requested has already ended.");
}
if (AMOUNT == mutez 0) {
throw(string "You cannot bid 0.");
}
if (asset.bid >= AMOUNT){
transfer(SENDER, AMOUNT);
throw(string "Sending funds back - bid too low.");
} else {
if (asset.bid != mutez 0) {
transfer(oldWinner, AMOUNT);
}
asset.winner = SENDER;
asset.bid = AMOUNT;
storage.auction.push(input.id, asset)
}
}

Conclusion

Congrats on designing a fairly robust smart contract auction with Fi! That’s exciting! Remember that when making smart contracts that are meant to hold XTZ, we need to think of all possible use cases for each entry to maintain confidence. Which is largely why we have so many check conditions in each entry before we get to the meat of the code!

What we learned:

  • How to properly utilize the transferfunction.
  • How to use the length() function.
  • The significance of the AMOUNT keyword in Fi.
  • How to create types by using Fi Structures.
  • The importance of logical checks in entry points.

Resources

--

--

BriceAldrich

Software Development, Cryptocurrency, Investing/Personal Finance