Version: 0.19.0

The Taco Shop Smart Contract

Meet Pedro, our artisan taco chef, who has decided to open a Taco shop on the Tezos blockchain, using a smart contract. He sells two different kinds of tacos: el Clásico and the Especial del Chef.

To help Pedro open his dream taco shop, we will implement a smart contract that will manage supply, pricing & sales of his tacos to the consumers.


Made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY

Pricing#

Pedro's tacos are a rare delicacy, so their price goes up as the stock for the day begins to deplete.

Each taco kind, has its own max_price that it sells for, and a finite supply for the current sales lifecycle.

For the sake of simplicity, we will not implement the replenishing of the supply after it has run out.

Daily Offer#

kindidavailable_stockmax_price
Clásico1n50n50tez
Especial del Chef2n20n75tez

Calculating the Current Purchase Price#

The current purchase price is calculated with the following formula:

current_purchase_price = max_price / available_stock

El Clásico#

available_stockmax_pricecurrent_purchase_price
50n50tez1tez
20n50tez2.5tez
5n50tez10tez

Especial del chef#

available_stockmax_pricecurrent_purchase_price
20n75tez3.75tez
10n75tez7.5tez
5n75tez15tez

Installing LIGO#

In this tutorial, we will use LIGO's dockerized version, for the sake of simplicity. You can find the installation instructions here.

Implementing our First main Function#

From now on we will get a bit more technical. If you run into something we have not covered yet - please try checking out the LIGO cheat sheet for some extra tips & tricks.

To begin implementing our smart contract, we need a main function, that is the first function being executed. We will call it main and it will specify our contract's storage (int) and input parameter (int). Of course this is not the final storage/parameter of our contract, but it is something to get us started and test our LIGO installation as well.

let main = ([parameter, contractStorage] : [int, int]) : [list <operation>, int] => {
return [
(list([]) as list <operation>), contractStorage + parameter
]
};
Let us break down the contract above to make sure we understand each bit of the LIGO syntax:
  • function main - definition of the main function, which takes the parameter of the contract and the storage
  • (const parameter : int; const contractStorage : int) - parameters passed to the function: the first is called parameter because it denotes the parameter of a specific invocation of the contract, the second is the storage
  • (list (operation) * int) - return type of our function, in our case a tuple with a list of operations, and an int (new value for the storage after a succesful run of the contract)
  • ((nil : list (operation)), contractStorage + parameter) - essentially a return statement
  • (nil : list (operation)) - a nil value annotated as a list of operations, because that is required by our return type specified above
    • contractStorage + parameter - a new storage value for our contract, sum of previous storage and a transaction parameter

Running LIGO for the First Time#

To test that we have installed LIGO correctly, and that taco-shop.ligo is a valid contract, we will dry-run it.

Dry-running is a simulated execution of the smart contract, based on a mock storage value and a parameter. We will later see a better way to test contracts: The LIGO test framework

Our contract has a storage of int and accepts a parameter that is also an int.

The dry-run command requires a few parameters:

  • contract (file path)
  • entrypoint (name of the main function in the contract)
  • parameter (parameter to execute our contract with)
  • storage (starting storage before our contract's code is executed)

It outputs what is returned from our main function: in our case a tuple containing an empty list (of operations to apply) and the new storage value, which, in our case, is the sum of the previous storage and the parameter we have used for the invocation.

ligo dry-run taco-shop.jsligo main 4 3
# OUTPUT:
# ( LIST_EMPTY() , 7 )

3 + 4 = 7 yay! Our CLI & contract work as expected, we can move onto fulfilling Pedro's on-chain dream.


Designing the Taco Shop's Contract Storage#

We know that Pedro's Taco Shop serves two kinds of tacos, so we will need to manage stock individually, per kind. Let us define a type, that will keep the stock & max_price per kind in a record with two fields. Additionally, we will want to combine our taco_supply type into a map, consisting of the entire offer of Pedro's shop.

Taco shop's storage

type taco_supply = { current_stock : nat , max_price : tez } ;
type taco_shop_storage = map <nat, taco_supply> ;

Next step is to update the main function to include taco_shop_storage in its storage. In the meanwhile, let us set the parameter to unit as well to clear things up.

taco-shop.ligo

type return_ = [list <operation>, taco_shop_storage];
let main = ([parameter, taco_shop_storage] : [unit, taco_shop_storage]) : return_ => {
return [(list([]) as list <operation>), taco_shop_storage]
};

Populating our Storage#

When deploying contract, it is crucial to provide a correct initial storage value. In our case the storage is type-checked as taco_shop_storage. Reflecting Pedro's daily offer, our storage's value will be defined as follows:

let init_storage : taco_shop_storage = Map.literal (list([
[1 as nat, { current_stock : 50 as nat, max_price : 50 as tez }],
[2 as nat, { current_stock : 20 as nat, max_price : 75 as tez }]
]));

The storage value is a map with two bindings (entries) distinguished by their keys 1n and 2n.

Out of curiosity, let's try to use LIGO compile-expression command compile this value down to michelson.

ligo compile-expression pascaligo --init-file gitlab-pages/docs/tutorials/get-started/pre_taco1.jsligo init_storage
# Output:
#
# { Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) }

Our initial storage record is compiled to a michelson map { Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) } holding the current_stock and max_prize in as a pair.


Providing another Access Function for Buying Tacos#

Now that we have our stock well defined in form of storage, we can move on to the actual sales. The main function will take a key id from our taco_shop_storage map and will be renamed buy_taco for more readability. This will allow us to calculate pricing, and if the sale is successful, we will be able to reduce our stock because we have sold a taco!

Selling the Tacos for Free#

Let is start by customizing our contract a bit, we will:

  • rename parameter to taco_kind_index
  • only in pascaligo syntax: change taco_shop_storage to a var instead of a const, because we will want to modify it
let buy_taco = ([taco_kind_index, taco_shop_storage] : [nat, taco_shop_storage]) : return_ => {
return [(list([]) as list <operation>), taco_shop_storage]
};

Decreasing current_stock when a Taco is Sold#

In order to decrease the stock in our contract's storage for a specific taco kind, a few things needs to happen:

  • retrieve the taco_kind from our storage, based on the taco_kind_index provided;
  • subtract the taco_kind.current_stock by 1n;
  • we can find the absolute value of the subtraction above by calling abs (otherwise we would be left with an int);
  • update the storage, and return it.
let buy_taco = ([taco_kind_index, taco_shop_storage] : [nat, taco_shop_storage]) : return_ => {
/* Retrieve the taco_kind from the contracts storage or fail */
let taco_kind : taco_supply =
match (Map.find_opt (taco_kind_index, taco_shop_storage), {
Some: (k:taco_supply) => k,
None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply)
}) ;
/* Update the storage decreasing the stock by 1n */
let taco_shop_storage = Map.update (
taco_kind_index,
(Some (({...taco_kind, current_stock : abs (taco_kind.current_stock - (1 as nat)) }))),
taco_shop_storage );
return [(list([]) as list <operation>), taco_shop_storage]
};

Making Sure We Get Paid for Our Tacos#

In order to make Pedro's taco shop profitable, he needs to stop giving away tacos for free. When a contract is invoked via a transaction, an amount of tezzies to be sent can be specified as well. This amount is accessible within LIGO as Tezos.amount.

To make sure we get paid, we will:

  • calculate a current_purchase_price based on the equation specified earlier
  • check if the sent amount matches the current_purchase_price:
    • if not, then our contract will fail (failwith)
    • otherwise, stock for the given taco_kind will be decreased and the payment accepted
let buy_taco = ([taco_kind_index, taco_shop_storage] : [nat, taco_shop_storage]) : return_ => {
/* Retrieve the taco_kind from the contracts storage or fail */
let taco_kind : taco_supply =
match (Map.find_opt (taco_kind_index, taco_shop_storage), {
Some: (k:taco_supply) => k,
None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply)
}) ;
let current_purchase_price : tez = taco_kind.max_price / taco_kind.current_stock ;
/* We won't sell tacos if the amount is not correct */
if (Tezos.amount != current_purchase_price) {
failwith ("Sorry, the taco you are trying to purchase has a different price")
} ;
/* Update the storage decreasing the stock by 1n */
let taco_shop_storage = Map.update (
taco_kind_index,
(Some (({...taco_kind, current_stock : abs (taco_kind.current_stock - (1 as nat)) }))),
taco_shop_storage );
return [(list([]) as list <operation>), taco_shop_storage]
};

Now let's test our function against a few inputs using the LIGO test framework.
For that, we will have another file in which will describe our test:

#include "gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-smart-contract.jsligo"
let assert_string_failure = ([res,expected] : [test_exec_result, string]) : unit => {
let expected = Test.compile_value (expected) ;
match (res, {
Fail: (x: test_exec_error) => (
match (x, {
Rejected: (x:[michelson_code,address]) => assert (Test.michelson_equal (x[0], expected)),
Other: (_:unit) => failwith ("contract failed for an unknown reason")
})),
Success: (_:unit) => failwith ("bad price check")
} );
} ;
let test = (_: unit): unit => {
/* originate the contract with a initial storage */
let init_storage = Map.literal (list([
[1 as nat, { current_stock : 50 as nat, max_price : 50 as tez }],
[2 as nat, { current_stock : 20 as nat, max_price : 75 as tez }] ])) ;
let [pedro_taco_shop_ta, _code, _size] = Test.originate (buy_taco, init_storage, 0 as tez) ;
let pedro_taco_shop_ctr = Test.to_contract (pedro_taco_shop_ta);
let pedro_taco_shop = Tezos.address (pedro_taco_shop_ctr);
/* test inputs */
let classico_kind = (1 as nat) ;
let unknown_kind = (3 as nat) ;
let eq_in_map = ([r, m, k] : [taco_supply, taco_shop_storage, nat]) : bool =>
match(Map.find_opt(k, m), {
None: () => false,
Some: (v : taco_supply) => v.current_stock == r.current_stock && v.max_price == r.max_price }) ;
/* Purchasing a Taco with 1tez and checking that the stock has been updated */
let ok_case : test_exec_result = Test.transfer_to_contract (pedro_taco_shop_ctr, classico_kind, 1 as tez) ;
let _u = match (ok_case, {
Success: (_:unit) => {
let storage = Test.get_storage (pedro_taco_shop_ta) ;
assert (eq_in_map({ current_stock : 49 as nat, max_price : 50 as tez }, storage, 1 as nat) &&
eq_in_map({ current_stock : 20 as nat, max_price : 75 as tez }, storage, 2 as nat)); },
Fail: (_: test_exec_error) => failwith ("ok test case failed")
}) ;
/* Purchasing an unregistred Taco */
let nok_unknown_kind = Test.transfer_to_contract (pedro_taco_shop_ctr, unknown_kind, 1 as tez) ;
let _u = assert_string_failure (nok_unknown_kind, "Unknown kind of taco") ;
/* Attempting to Purchase a Taco with 2tez */
let nok_wrong_price = Test.transfer_to_contract (pedro_taco_shop_ctr, classico_kind, 2 as tez) ;
let _u = assert_string_failure (nok_wrong_price, "Sorry, the taco you are trying to purchase has a different price") ;
return unit
}

Let's break it down a little bit:

  • we define assert_string_failure, a function reading a transfer result and testing against a failure. It also compares the failing data - here, a string - to what we expect it to be
  • test is actually performing the tests: Originates the taco-shop contract; purchasing a Taco with 1tez and checking that the stock has been updated ; attempting to purchase a Taco with 2tez and trying to purchase an unregistred Taco.

checkout the reference page for a more detailed description of the Test API

Now it is time to use the ligo command test:

ligo test gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.jsligo test
# Output:
#
# Test passed with ()

The test passed ! That's it - Pedro can now sell tacos on-chain, thanks to Tezos & LIGO.


💰 Bonus: Accepting Tips above the Taco Purchase Price#

If you would like to accept tips in your contract, simply change the following line, depending on your preference.

Without tips

if (Tezos.amount != current_purchase_price)

With tips

if (Tezos.amount >= current_purchase_price)