title: Training dapp n°3 tags: Training description: Training n°3 for decentralized application

📍 See Github version and full code here

Training dapp n°3

☝️ Poke game with permissions

Previously, you learned how to do inter-contract calls, use view and do unit testing. In this third session, you will enhance your skills on :

On the second version of the poke game, you were able poke any contract without constraint. We will introduce now a right to poke via tickets. Ticket are a kind of object that cannot be copied and can hold some trustable information.

new Poke sequence diagram

sequenceDiagram
  Admin->>SM : Init(User,1)
  Note right of SM : Mint 1 ticket for User
  Note left of User : Prepare to poke
  User->>SM : Poke
  Note right of SM : Check available tickets for User
  Note right of SM : Store trace and burn 1 ticket
  SM-->>User : success
  User->>SM : Poke
  Note right of SM : Check available tickets for User
  SM-->>User : error

📝 Prerequisites

There is nothing more than you needed on first session : https://github.com/marigold-dev/training-dapp-1#memo-prerequisites

Get your code from the session 2 or the solution here

🎫 Tickets

Tickets just came with Tezos Edo upgrade, they are great and often misundersood

Ticket structure :

Tickets features :

Example of usage :

Step 1 : 🌱 Mint

Minting is the action of creating ticket from void. In general, minting operations are done by administrators of smart contract or either by end user (while creating an NFT for example)

Edit the ./contracts/pokeGame.jsligo file and add a map of ticket ownership to the default storage type. This map will keep a list of consumable ticket for each authrozized user. It will be used as a burnable right to poke here

export type storage = {
  pokeTraces: map<address, pokeMessage>,
  feedback: string,
  ticketOwnership: map<address, ticket<string>> //ticket of claims
};

In order to fill this map, we are adding an new administration endpoint. Modify the parameter type, update the main function and add the new function init

A new entrypoint Init will add x tickets to a specific user

Note : to simplify, we don't add security around this entrypoint, but in Production we should do it

export type parameter =
  | ["Poke"]
  | ["PokeAndGetFeedback", address]
  | ["Init", address, nat];

Main function will add this new entrypoint too.

Tickets are very special objects that cannot be DUPLICATED. During compilation to Michelson, using a variable twice, copying a structure holding tickets are generating DUP command. To avoid our contract to fail at runtime, Ligo will parse statically our code during compilation time to detect any DUP on tickets.

To solve most of issues, we need to segregate ticket objects from the rest of the storage, or structures containing ticket objects in order to avoid compilation errors. To do this, just destructure any object until you get tickets isolated.

Here below, store object is destructured to isolate ticketOwnership object holding our tickets. You need then to modify the function arguments to pass each field of the storage separately

export const main = ([action, store]: [parameter, storage]): return_ => {
  //destructure the storage to avoid DUP
  let { pokeTraces, feedback, ticketOwnership } = store;
  return match(action, {
    Poke: () => poke([pokeTraces, feedback, ticketOwnership]),
    PokeAndGetFeedback: (other: address) =>
      pokeAndGetFeedback([other, pokeTraces, feedback, ticketOwnership]),
    Init: (initParam: [address, nat]) =>
      init([initParam[0], initParam[1], pokeTraces, feedback, ticketOwnership]),
  });
};

Add the new Init function (before main)

const init = ([a, ticketCount, pokeTraces, feedback, ticketOwnership]: [
  address,
  nat,
  map<address, pokeMessage>,
  string,
  map<address, ticket<string>>
]): return_ => {
  if (ticketCount == (0 as nat)) {
    return [
      list([]) as list<operation>,
      {
        feedback,
        pokeTraces,
        ticketOwnership,
      }
    ]
  } else {
    const t : ticket<string> = Tezos.create_ticket("can_poke", ticketCount);
    return [
      list([]) as list<operation>,
      {
        feedback,
        pokeTraces,
        ticketOwnership: Map.add(
          a,
          t,
          ticketOwnership
        ),
      }
    ]
  }
};

Init function looks at how many tickets to create from the current caller, then it is added to the current map

Let's modify poke functions now

const poke = ([pokeTraces, feedback, ticketOwnership]: [
  map<address, pokeMessage>,
  string,
  map<address, ticket<string>>
]): return_ => {
  //extract opt ticket from map
  const [t, tom]: [option<ticket<string>>, map<address, ticket<string>>] =
    Map.get_and_update(
      Tezos.get_source(),
      None() as option<ticket<string>>,
      ticketOwnership
    );

  return match(t, {
    None: () => failwith("User does not have tickets => not allowed"),
    Some: (_t: ticket<string>) => [
      list([]) as list<operation>,
      {
        //let t burn
        feedback,
        pokeTraces: Map.add(
          Tezos.get_source(),
          { receiver: Tezos.get_self_address(), feedback: "" },
          pokeTraces
        ),
        ticketOwnership: tom,
      }
    ]
  });
};

First, we need to extract an existing optional ticket from the map. If we try to do operation directly on the map, even trying to find or get this object in the structure, a DUP command can be generated. We use the secure get_and_update function from Map library to extract the item from the map and avoid any copy.

Note : more information about this function here

Second step, we can look at the optional ticket, if it exists, then we burn it (i.e we do not store it somewhere on the storage anymore) and add a trace of execution, otherwise we fail with an error message

Same for pokeAndGetFeedback function, do same checks and type modifications as below

// @no_mutation
const pokeAndGetFeedback = ([
  oracleAddress,
  pokeTraces,
  _feedback,
  ticketOwnership
]: [
  address,
  map<address, pokeMessage>,
  string,
  map<address, ticket<string>>
]): return_ => {
  //extract opt ticket from map
  const [t, tom]: [option<ticket<string>>, map<address, ticket<string>>] =
    Map.get_and_update(
      Tezos.get_source(),
      None() as option<ticket<string>>,
      ticketOwnership
    );

  //Read the feedback view
  let feedbackOpt: option<string> = Tezos.call_view(
    "feedback",
    unit,
    oracleAddress
  );

  return match(t, {
    None: () => failwith("User does not have tickets => not allowed"),
    Some: (_t: ticket<string>) =>
      match(feedbackOpt, {
        Some: (feedback: string) => {
          let feedbackMessage = { receiver: oracleAddress, feedback: feedback };
          return [
            list([]) as list<operation>,
            {
              feedback,
              pokeTraces: Map.add(
                Tezos.get_source(),
                feedbackMessage,
                pokeTraces
              ),
              ticketOwnership: tom,
            }
          ]
        },
        None: () =>
          failwith("Cannot find view feedback on given oracle address"),
      }),
  });
};

Update the storage initialization on pokeGame.storages.jsligo

#include "pokeGame.jsligo"
const default_storage = {
    pokeTraces : Map.empty as map<address, pokeMessage>,
    feedback : "kiss",
    ticketOwnership : Map.empty as map<address,ticket<string>>  //ticket of claims
};

Compile the contract to check any errors

Note : don't forget to check that Docker is running

yarn install

taq compile pokeGame.jsligo

Check on logs that everything is fine 👌

Try to display a DUP error now 👺

Add this line on poke function somewhere

const t2 = Map.find_opt(Tezos.get_source(), ticketOwnership);

Compile again

taq compile pokeGame.jsligo

This time you should see the DUP error generated by the find function

At line 87 characters 17 to 22,
type map address (ticket string) cannot be used here because it is not duplicable. Only duplicable types can be used with the DUP instruction and as view inputs and outputs.
At line 87 characters 17 to 22,
Ticket in unauthorized position (type error).

Ok so remove it !!! ❎

Step 2 : Test authorization poking

Update the unit tests files to see if we can still poke

Edit ./contracts/unit_pokeGame.jsligo

#import "./pokeGame.jsligo" "PokeGame"

export type main_fn = (parameter : PokeGame.parameter, storage : PokeGame.storage) => PokeGame.return_ ;

// reset state
const _ = Test.reset_state ( 2 as nat, list([]) as list <tez> );
const faucet = Test.nth_bootstrap_account(0);
const sender1 : address = Test.nth_bootstrap_account(1);
const _ = Test.log("Sender 1 has balance : ");
const _ = Test.log(Test.get_balance(sender1));

const _ = Test.set_baker(faucet);
const _ = Test.set_source(faucet);

//functions
export const _testPoke = (main : main_fn, s : address, ticketCount : nat, expectedResult : bool) : unit => {

    //contract origination
    const [taddr, _, _] = Test.originate(main, {
        pokeTraces : Map.empty as map<address, PokeGame.pokeMessage> ,
        feedback : "kiss" ,
        ticketOwnership : Map.empty as map<address,ticket<string>>},
        0 as tez);
    const contr = Test.to_contract(taddr);
    const contrAddress = Tezos.address(contr);
    const _ = Test.log("contract deployed with values : ");
    const _ = Test.log(contr);

    const statusInit = Test.transfer_to_contract(contr, Init([sender1,ticketCount]), 0 as tez);
    const _ = Test.log(statusInit);
    const _ = Test.log("*** Check initial ticket is here ***");
    const _ = Test.log(Test.get_storage(taddr));

    Test.set_source(s);

    const status = Test.transfer_to_contract(contr, Poke() as PokeGame.parameter, 0 as tez);
    Test.log(status);

    return match(status,{
        Fail : (tee : test_exec_error) => match(tee,{
                                Other: (msg : string) => assert_with_error(expectedResult==false,msg),
                                Balance_too_low : (_record : [ address ,  tez , tez ]) => assert_with_error(expectedResult==false,"ERROR Balance_too_low"),
                                Rejected: (s:[michelson_program , address])=>assert_with_error(expectedResult==false,Test.to_string(s[0]))}),
        Success : (_n : nat) => match(Map.find_opt (s, (Test.get_storage(taddr) as PokeGame.storage).pokeTraces), {
                                Some: (pokeMessage: PokeGame.pokeMessage) => { assert_with_error(pokeMessage.feedback == "","feedback "+pokeMessage.feedback+" is not equal to expected "+"(empty)"); assert_with_error(pokeMessage.receiver == contrAddress,"receiver is not equal");} ,
                                None: () => assert_with_error(expectedResult==false,"don't find traces")
       })
    });

  };


  //********** TESTS *************/

  const _ = Test.log("*** Run test to pass ***");
  const testSender1Poke = _testPoke([PokeGame.main,sender1, 1 as nat,true]);

  const _ = Test.log("*** Run test to fail ***");
  const testSender1PokeWithNoTicketsToFail = _testPoke([PokeGame.main,sender1, 0 as nat,false]) ;

Run the test, and look at the logs to track execution

taq test unit_pokeGame.jsligo

First test should be fine

┌──────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Contract             │ Test Results                                                                                                                                                   │
├──────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ unit_pokeGame.jsligo │ "Sender 1 has balance : "                                                                                                                                      │
│                      │ 3800000000000mutez                                                                                                                                             │
│                      │ "*** Run test to pass ***"                                                                                                                                     │
│                      │ "contract deployed with values : "                                                                                                                             │
│                      │ KT1JVH7KyY4RVWgLRdX43WJojBRzMABN8eJu(None)                                                                                                                     │
│                      │ Success (2674n)                                                                                                                                                │
│                      │ "*** Check initial ticket is here ***"                                                                                                                         │
│                      │ {feedback = "kiss" ; pokeTraces = [] ; ticketOwnership = [tz1TDZG4vFoA2xutZMYauUnS4HVucnAGQSpZ -> (KT1JVH7KyY4RVWgLRdX43WJojBRzMABN8eJu , ("can_poke" , 1n))]} │
│                      │ Success (1850n)                                                                                                                                                │
│                      │ "*** Run test to fail ***"                                                                                                                                     │
│                      │ "contract deployed with values : "                                                                                                                             │
│                      │ KT1UetUfHaJHa4skPVzyMzUahZtaVECt5MrF(None)                                                                                                                     │
│                      │ Success (2218n)                                                                                                                                                │
│                      │ "*** Check initial ticket is here ***"                                                                                                                         │
│                      │ {feedback = "kiss" ; pokeTraces = [] ; ticketOwnership = []}                                                                                                   │
│                      │ Fail (Rejected (("User does not have tickets => not allowed" , KT1UetUfHaJHa4skPVzyMzUahZtaVECt5MrF)))                                                         │
│                      │ Everything at the top-level was executed.                                                                                                                      │
│                      │ - testSender1Poke exited with value ().                                                                                                                        │
│                      │ - testSender1PokeWithNoTicketsToFail exited with value ().                                                                                                     │
│                      │                                                                                                                                                                │
│                      │ 🎉 All tests passed 🎉                                                                                                                                         │
└──────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Step 3 : Redeploy the smart contract

Let play with the CLI to compile and deploy

taq compile pokeGame.jsligo
taq generate types ./app/src
taq deploy pokeGame.tz -e testing
┌─────────────┬──────────────────────────────────────┬──────────┬──────────────────┬─────────────────────────────────────┐
│ Contract    │ Address                              │ Alias    │ Balance In Mutez │ Destination                         │
├─────────────┼──────────────────────────────────────┼──────────┼──────────────────┼─────────────────────────────────────┤
│ pokeGame.tz │ KT1TFV55RYD5hqHLYiHouJoWBL1tQfSvf54a │ pokeGame │ 0                │ https://ghostnet.tezos.marigold.dev │
└─────────────┴──────────────────────────────────────┴──────────┴──────────────────┴─────────────────────────────────────┘

Step 4 : Adapt the frontend code

Rerun the app, we will check that can cannot use the app anymore without tickets

cd app
yarn run start

Connect with any wallet that has enough Tez, and Poke your own contract

pokefail

My Kukai wallet is giving me back the error from the smart contract

kukaifail

Ok, so let's authorize some 🎇 minting on my user and try again to poke

We add a new button for minting on a specific contract, replace the full content of App.tsx as it :

import { NetworkType } from "@airgap/beacon-types";
import { Contract, ContractsService } from "@dipdup/tzkt-api";
import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";
import { BigNumber } from "bignumber.js";
import { useEffect, useState } from "react";
import "./App.css";
import ConnectButton from "./ConnectWallet";
import DisconnectButton from "./DisconnectWallet";
import { PokeGameWalletType, Storage } from "./pokeGame.types";
import { address, nat } from "./type-aliases";

function App() {
  const [Tezos, setTezos] = useState<TezosToolkit>(
    new TezosToolkit("https://ghostnet.tezos.marigold.dev")
  );
  const [wallet, setWallet] = useState<BeaconWallet>(
    new BeaconWallet({
      name: "Training",
      preferredNetwork: NetworkType.GHOSTNET,
    })
  );

  useEffect(() => {
    Tezos.setWalletProvider(wallet);
    (async () => {
      const activeAccount = await wallet.client.getActiveAccount();
      if (activeAccount) {
        setUserAddress(activeAccount.address);
        const balance = await Tezos.tz.getBalance(activeAccount.address);
        setUserBalance(balance.toNumber());
      }
    })();
  }, [wallet]);

  const [userAddress, setUserAddress] = useState<string>("");
  const [userBalance, setUserBalance] = useState<number>(0);

  const [contractToPoke, setContractToPoke] = useState<string>("");

  //tzkt
  const contractsService = new ContractsService({
    baseUrl: "https://api.ghostnet.tzkt.io",
    version: "",
    withCredentials: false,
  });
  const [contracts, setContracts] = useState<Array<Contract>>([]);
  const [contractStorages, setContractStorages] = useState<
    Map<string, Storage>
  >(new Map());

  const fetchContracts = () => {
    (async () => {
      const tzktcontracts: Array<Contract> = await contractsService.getSimilar({
        address: process.env["REACT_APP_CONTRACT_ADDRESS"]!,
        includeStorage: true,
        sort: { desc: "id" },
      });
      setContracts(tzktcontracts);
      const taquitoContracts: Array<PokeGameWalletType> = await Promise.all(
        tzktcontracts.map(
          async (tzktcontract) =>
            (await Tezos.wallet.at(tzktcontract.address!)) as PokeGameWalletType
        )
      );
      const map = new Map<string, Storage>();
      for (const c of taquitoContracts) {
        const s: Storage = await c.storage();
        map.set(c.address, s);
      }
      setContractStorages(map);
    })();
  };

  //poke
  const poke = async (
    e: React.MouseEvent<HTMLButtonElement>,
    contract: Contract
  ) => {
    e.preventDefault();
    let c: PokeGameWalletType = await Tezos.wallet.at("" + contract.address);
    try {
      console.log("contractToPoke", contractToPoke);
      c.storage();
      const op = await c.methods
        .pokeAndGetFeedback(contractToPoke as address)
        .send();
      await op.confirmation();
      alert("Tx done");
    } catch (error: any) {
      console.log(error);
      console.table(`Error: ${JSON.stringify(error, null, 2)}`);
    }
  };

  //mint
  const mint = async (
    e: React.MouseEvent<HTMLButtonElement>,
    contract: Contract
  ) => {
    e.preventDefault();
    let c: PokeGameWalletType = await Tezos.wallet.at("" + contract.address);
    try {
      console.log("contractToPoke", contractToPoke);
      const op = await c.methods
        .init(userAddress as address, new BigNumber(1) as nat)
        .send();
      await op.confirmation();
      alert("Tx done");
    } catch (error: any) {
      console.log(error);
      console.table(`Error: ${JSON.stringify(error, null, 2)}`);
    }
  };

  return (
    <div className="App">
      <header className="App-header">
        <ConnectButton
          Tezos={Tezos}
          setUserAddress={setUserAddress}
          setUserBalance={setUserBalance}
          wallet={wallet}
        />

        <DisconnectButton
          wallet={wallet}
          setUserAddress={setUserAddress}
          setUserBalance={setUserBalance}
        />

        <div>
          I am {userAddress} with {userBalance} mutez
        </div>

        <br />
        <div>
          <button onClick={fetchContracts}>Fetch contracts</button>
          <table>
            <thead>
              <tr>
                <th>address</th>
                <th>trace "contract - feedback - user"</th>
                <th>action</th>
              </tr>
            </thead>
            <tbody>
              {contracts.map((contract) => (
                <tr>
                  <td style={{ borderStyle: "dotted" }}>{contract.address}</td>
                  <td style={{ borderStyle: "dotted" }}>
                    {contractStorages.get(contract.address!) !== undefined &&
                    contractStorages.get(contract.address!)!.pokeTraces
                      ? Array.from(
                          contractStorages
                            .get(contract.address!)!
                            .pokeTraces.entries()
                        ).map(
                          (e) =>
                            e[1].receiver +
                            " " +
                            e[1].feedback +
                            " " +
                            e[0] +
                            ","
                        )
                      : ""}
                  </td>
                  <td style={{ borderStyle: "dotted" }}>
                    <input
                      type="text"
                      onChange={(e) => {
                        console.log("e", e.currentTarget.value);
                        setContractToPoke(e.currentTarget.value);
                      }}
                      placeholder="enter contract address here"
                    />
                    <button onClick={(e) => poke(e, contract)}>Poke</button>
                    <button onClick={(e) => mint(e, contract)}>
                      Mint 1 ticket
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </header>
    </div>
  );
}

export default App;

Note : You have maybe noticed, but we use the full typed generated taquito classes for the storage access, now. It will improve maintenance in case you contract storage has changed.

Refresh the page, now you have the Mint button

Mint a ticket on this contract

mint

Wait for the Tx popup confirmation and then try to poke again, it should succeed now

success

Wait for the Tx popup confirmation and try to poke again, you should be out of tickets and it should fail

kukaifail

🎊 Congratulation, you know how to use tickets now and avoid DUP errors

Takeaways :

  • you can go further and improve the code like consuming one 1 ticket quantity at a time and manage it the right way
  • you can also implement different type of AUTHZ, not only can poke claim
  • You can also try to base your ticket on some duration time like JSON token can do, not using the data field as a string but as bytes and store a timestamp on it.

🌴 Conclusion 🌞

Now, you are able to understand ticket. If you want to learn more about tickets, read this great article here

On next training, we will learn hot to upgrade deployed contracts

➡️ NEXT (HTML version)

➡️ NEXT (Github version)