Introduction to building a dApp
Learn how to build a simple dApp on Tezos using React and Archetype.
Written by
This section presents how to build a dApp (Decentralized Application) with Archetype and Completium CLI.
Compared to a standard application, a dApp uses decentralized (peer to peer) backends (that is not owned by any private or public entity):
The user is authenticated on the blockchain with a wallet, whose main role is to sign transactions to the smart contract. The signature principle is the one of asymmetric cryptography: the wallet uses the user's private key to sign, while the public key (or its hash in Tezos case) is used as public address (identity) on the blockchain.
Many elements of a dApp are centralized.
The user interface is usually a web page (or a native app) served by a centralized server (owned by an ISP, Github, Gitlab, ...).
Accessing to a peer to peer network (blockchain, IPFS, ...) is done by connecting to one machine, called the endpoint, owned by one entity. The integrity of the dApp then depends on that entity.
The user could potentially use its own endpoints.
Indexers provide key informations to the ecosystem; however, as a centralised point of information access, it is recommended to use them only when necessary, that is when the required information is not available in any contract's storage or in any current block.
This section presents how to create and setup a dApp's User Interface (UI) project using React and Beacon's dApp/wallet interaction.
The use of Nodejs as the javascript execution environment is a no brainer. It is best installed with nvm.
Any UI framework is suitable to create a web dApp interface (Angular, Vue, Svelte, ...). We present here how to create a dApp UI project with React.
It is strongly recommended to use Typescript language as it will greatly shorten and ease up the development cycle. Its typed aspect makes that many errors are detected at compilation time (ie. at the time of writing code) rather than later at execution. It just doesn't make sense to developp with untyped languages when you think of it ...
Project templates configured with typescript are available with most UI frameworks. The following command uses create-react-app template with typescript:
npx create-react-app my-dapp --template typescript
This creates the my-dapp project. More information may be found here
While it is always possible to interact directly with the Tezos endpoint's RPC API, it is more than recommended to use a dedicated library that will wrap all services in high-level development services and take care of all the low-level Tezos protocol details.
In the context of web dApps for Tezos, the main library is Taquito:
npm install @taquito/taquito
Taquito uses official cryptographic packages (aka libraries). Some of these packages relies on nodejs packages designed to run on the back-end side (server side, not in a browser). These packages are crypto, stream, assert, http, https, os. As a result, the default build process fails; it is then necessary to map these packages to their front-end counterparts in the build process:
As a comment, create-react-app uses webpack (version 5) to bundle all resources as static web assets. It is then necessary to use the react-app-rewired package as described in instructions above, to be able to provide a customized webpack configuration without ejecting the react app.
A dApp needs to interact with a wallet to sign operations (transfers, calls to a smart contract, ...). Many wallets are available on Tezos (Temple, Kukai, Umami, ...). It is common practice to interact with them all via Beaconthat implements the interaction standard TZIP-10between a wallet and a dApp, as it greatly reduces the integration effort with wallets.
npm install @taquito/beacon-wallet @airgap/beacon-sdk
The main drawback of Beacon is the lack of control over the UI elements (typically the wallet selection popup), which can be a no-go if you want a tight control of the dApp L&F. In that case, each wallet needs to be integrated separately.
A plug-and-play constate context for Beacon services connect and disconnect is available here.
When interacting with a contract (read and write), it is strongly recommended to use its generated typescript bindings, that is a typed high-level typescript interface. It greatly reduces the effort to call a contract, read its storage and the number of runtime errors, as the compilier and LSP guides you through the contract interface.
Typescript bindings may be obtained with the following Completium CLI command:
completium-cli generate binding-dapp-ts mycontract.arl > mycontract.ts
The generated binding interface relies on two packages:
npm install @completium/dapp-ts @completium/archetype-ts-types
With React applications, it is strongly recommended to setup contexts for application data (settings, UI states, ...) in read/write modes with a dedicated package like constate or redux. This is preventing from awkward spaghetti code of passing components states and data through large hierarchy of components.
The dApp example presented here is using constate for its lightweight aspect.
npm install constate
Taquito and Beacon must be singletons, hence there are wrapped as contexts (with constate) to make them available to UI components. The same stands for the contract bindings.
The main 4 blockchain-related contexts are provided as plug-and-play code:
Contexts
Schema below illustrates the module and package architecture of the dApp and their interactions:
├── README.md
├── config-overrides.js
├── package.json
├── tsconfig.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
└── src
├── App.css
├── App.test.tsx
├── App.tsx
├── index.css
├── index.tsx
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
├── bindings
│ └── ...
├── components
│ └── ...
├── routes
│ └── ...
└── contexts
├── Beacon.tsx
├── Taquito.tsx
├── Contract.tsx
├── Settings.tsx
└── ...
Note that 4 directories are created under src:
└── src
└── store
├── Settings.tsx
The Settings context provides components (and other contexts) with access to the main blockchain settings required by Taquito and Beacon:
"import { NetworkType } from "@airgap/beacon-sdk";
import constate from "constate";
import { useState } from 'react';
export const [
SettingsProvider,
useAppName,
useEndpoint,
useNetwork,
useContractAddress
] = constate(
() => {
const [settingState] = useState({
app_name : 'My DApp',
endpoint : 'https://ghostnet.ecadinfra.com',
network : NetworkType.GHOSTNET,
contract : 'KT1...',
});
return settings;
},
v => v.app_name,
v => v.endpoint,
v => v.network,
v => v.contract
);"
When a component needs a settings, it imports the hook and calls it to retrieve the value:
import { useAppName, useEndpoint, useNetwork } from '../store/Settings'
const network = useNetwork()
const endpoint = useEndpoint()
const name = useAppName()
See the Beacon.tsx constate hook for an example.
└── src
└── store
├── Taquito.tsx
The Taquito context provides Taquito's Tezos Toolkit to all components. It is created once at context creation.
When a component needs to use Taquito's Tezos Toolkit, it retrieves it with the useTezosToolkit hook:
import { useTezosToolkit } from '../store/Taquito'
const ttk = useTezosToolkit()
const balance = await ttk.tz.getBalance('tz1h4CiqWxNe4UxSpkwXy617RM6DaK6NU76P');
└── src
└── store
├── Beacon.tsx
The Beacon context provides the following services:
Beacon.tsx
import { BeaconWallet } from '@taquito/beacon-wallet';
import constate from 'constate';
import React from 'react';
import { useAppName, useEndpoint, useNetwork } from "./Settings";
import { useTezosToolkit } from "./Taquito";
export const [
BeaconProvider,
useWalletAddress,
useWalletName,
useConnect,
useDisconnect,
useIsConnected
] = constate(
MakeBeacon,
(v) => v.beaconState.user_address,
(v) => v.beaconState.wallet,
(v) => v.utils.connect,
(v) => v.utils.disconnect,
(v) => v.utils.is_connected
);
function MakeBeacon() {
const network = useNetwork()
const endpoint = useEndpoint()
const name = useAppName()
const ttk = useTezosToolkit()
const [beaconState, setState] = React.useState(() : {
beacon : undefined | BeaconWallet,
user_address : undefined | string,
wallet : undefined | string,
} => ({
beacon : new BeaconWallet({ name : name, preferredNetwork : network }),
user_address : undefined,
wallet : undefined
}));
React.useEffect(() => {
// to be executed on mount
ttk.setWalletProvider(beaconState.beacon)
const on_mount = async () => {
const account = await beaconState.beacon?.client.getActiveAccount();
const address = account?.address;
const peers = await beaconState.beacon?.client?.getPeers();
const wallet_name = peers !== undefined ? peers[0].name : undefined;
setState(s => { return { ...s, user_address : address, wallet : wallet_name }})
}
on_mount()
}, []);
const connect = async () => {
try {
let beacon : BeaconWallet | undefined = undefined
if (beaconState.beacon) {
beacon = beaconState.beacon
ttk.setWalletProvider(beacon);
await beacon.requestPermissions({
network : {
type : network,
rpcUrl : endpoint
}
})
const address = await beacon.getPKH();
const peers = await beacon.client.getPeers()
const wallet_name = peers[0].name
setState(s => { return { ...s, user_address : address, wallet : wallet_name }})
} else {
throw new Error("Beacon Not Initialized")
}
} catch(e) {
console.log(e)
}
}
const disconnect = async () => {
beaconState.beacon?.client.disconnect()
setState(s => { return { ...s,
user_address : undefined
}})
}
const is_connected = () => {
return beaconState.user_address !== undefined
}
return { beaconState, utils : { connect, disconnect, is_connected } };
}
For example, the Login component providing the button to login to a wallet would code:
import { useConnect } from '../store/Beacon'
const LoginButton = () => {
const connect = useConnect()
return <Button onClick={connect} />
}
└── src
└── store
├── Contract.tsx
This Contract context template provides the contract binding to components.
import { set_binder_tezos_toolkit } from '@completium/dapp-ts';
import constate from 'constate';
import { useState } from 'react';
import { FIXME as Contract } from '../bindings/FIXME'; // replace FIXME
import { useContractAddress } from './Settings';
import { useTezosToolkit } from './Taquito';
export const [
ContractProvider,
useContract
] = constate(
() => {
const tezos = useTezosToolkit()
const address = useContractAddress()
const [contract] = useState({
contract: new Contract(address),
});
set_binder_tezos_toolkit(tezos)
return contract;
},
(v) => v.contract
)
Comments:
import { Poll as Contract } from '../bindings/poll';
import { useContract } from '../store/contract'
const contract = useContract()
See example dApp's detailed contract integration explanations.
└── src
├── App.tsx
The App.tsx file contains the root component of the application App (it comes with the default react template project).
The role of this component is to wrap the main panel component <MainPanel /> with constate context providers. It should at least be wrapped with the Settings, Taquito, Beacon and Contract contexts, as illustrated below:
function App() {
return (
<div className="App">
<CssBaseline />
<SettingsProvider>
<TaquitoProvider>
<BeaconProvider>
<ContractProvider>
<MainPanel />
</ContractProvider>
</BeaconProvider>
</TaquitoProvider>
</SettingsProvider>
</div>
);
}
This section presents the Poll dApp example: users can create and answer polls. An account can only answer a poll once.
Poll definitions (question, image and possible answers) are stored on IPFS. The list of polls and user's answers are stored in a smart contract.
The dApp uses the MUI UI widget library.
Click the topbar LOGIN button to connect to a wallet; select a wallet you want to connect to (Temple, Kukai, Umami, ...).
Once logged in, the login button is replaced by a panel with basic information:
Steps to answer a poll:
Once the transaction is validated on the blockchain, the statistic of the poll (number of answers per possible answer) is displayed.
User is notified when another user either creates a new poll or answers a poll:
A poll is specified with a JSON file that defines the following fields:
or example, below is the "What is your favorite food?" poll definition:
{
"utterance" : "What is your favorite food?",
"img" : "https://1.bp.blogspot.com/-WOrNura8t1g/W6ufizUlixI/AAAAAAAAA38/X05agj6atWcxyyaRgOl07jMeaZnuF7JwgCLcBGAs/w1200-h630-p-k-no-nu/World-Food-Day.jpg",
"choices" : [ "Pizza", "Burger", "Sushi", "Tacos", "Croissant" ]
}
The poll JSON file must be then uploaded to IPFS. An IPFS browser extension may be used for that. Once uploaded, the data is identified by a unique hash value.
For example, the hash of the above poll definition is QmZ8GxAwPvVDEtGxyUmfbB1dtmrdDR6tmMv9HUATaippqU
Steps to add a poll:
Once submitted, the contract's owner (tz1h4CiqWxNe4UxSpkwXy617RM6DaK6NU76P) needs to validate the poll for it to appear in the main panel.
The poll contract stores polls' IPFS hash and users' answers. It guarantees that an account can only answer once to a poll. It also computes the number of times an answer has been selected.
When a user adds a poll (IPFS hash), it needs to be approved by a special account, called contract's owner. The owner can also remove any existing poll.
Poll contract project was created with the following Completium CLI command:
completium-cli create project poll-contract
Command npm i installs required packages:
package.json file is created with utility commands, including:
The following Completium CLI command is used to deploy the contract:
completium-cli deploy ./contracts/poll.arl --parameters '{ "owner": "tz1h4CiqWxNe4UxSpkwXy617RM6DaK6NU76P" }' --metadata-uri "ipfs://QmXbuUyyJXW1RRuL3k81Kpe2HULbYLj1sUUq44Nuxa5z8h"
where QmXbuUyyJXW1RRuL3k81Kpe2HULbYLj1sUUq44Nuxa5z8h is the IPFS hash of the contract metadata file:
{
"name": "Poll Dapp",
"description": "An example of Dapp built with Archetype & Completium",
"version": "2.0",
"license": { "name": "MIT" },
"authors": ["Completium team <contact@completium.com>"],
"homepage": "https://completium.github.io/poll-dapp/",
"interfaces": ["TZIP-016"]
}
Contract owner's address, passed as contract parameter. Only the owner can:
Code
archetype poll(owner : address)
with metadata ""
Number of polls added, used as poll key in poll asset collection (see approve entry point).
Collection of polls.
A poll is identified by a natural integer rather than by its IPFS hash. This is to minimize the required storage of the responder information, that stores which polls an user has answered.
The responses field stores the numbers of responses to poll's possible answers.
Code
Collection of polls' IPFS hashes proposed by users. When approved by owner, a poll asset is created.
Note that the asset collection is created as a big_map, to be able to handle an arbitrary large amount of poll proposition.
Code
Collection of responders' lists (set) of answered polls. This is to decide whether a responder has already answered a poll or not (see respond entrypoint).
It is specified as a big_mapto be able to handle an arbitrary large number of responders.
Entry to call to propose a new poll. The poll's IPFS hash is added to the collection of hashes to approve.
Code
Parameter
Poll's IPFS hash
CONTRACT_PAUSED
When contract has been paused by owner
("KEY_EXISTS", "poll_to_approve")
When poll's IPFS hash h has already been proposed
Entry to call to answer a poll. It fails if:
The number of times someone has responded to the poll's answer (choice_id) is incremented, and the poll id is registered in the set of polls caller has already responded to.
Code
entry respond(pk : nat, choice_id : nat) {
constant {
selection_count is poll[pk] ? (the.responses[choice_id] ? the : 0) : 0;
}
require {
r2 : is_not_paused();
r3 : poll.contains(pk) otherwise POLL_NOT_FOUND;
}
fail if {
f1 : responder[caller] ? the.polls.contains(pk) : false with CANNOT_RESPOND_TWICE
}
effect {
responder.add_update(caller, { polls += [pk] } );
poll.update(pk, {
responses += [(choice_id, selection_count + 1)]
});
emit<Response>({ caller; pk; choice_id })
}
}
Parameters
Poll's primary key
Poll's choice id selected by user
CONTRACT_PAUSED
When contract has been paused by owner
POLL_NOT_FOUND
When poll's primary key pk is not found in poll asset
CANNOT_RESPOND_TWICE
When poll's primary key pk is found in caller's set of already responded polls
Entry called by owner to approve a proposed poll:
Code
entry approve(h : bytes) {
called by owner
constant {
creator_ ?is poll_to_approve[h]?.poll_creator otherwise POLL_NOT_FOUND
}
effect {
poll.add({ poll_pk = polls_counter; ipfs_hash = h });
polls_counter += 1;
poll_to_approve.remove(h);
emit<Approve>({ creator_; h })
}
}
Parameter
Poll's IPFS hash
POLL_NOT_FOUND
When poll's IPFS hash h is not found in poll_to_approve asset
Entry called by owner to disapprove a proposed poll:
Code
Entry called by owner to remove a poll.
Code
Returns poll pk response statistics.
Code
view get_responses(pk : nat) : map<nat, nat> {
return poll[pk].responses
}
Returns true if sourcesource has already answered poll pk.
Code
view already_responded(pk : nat) : bool {
return (responder[source] ? the.polls.contains(pk) : false)
}
Emitted by add_poll with:
Emitted by respond with:
Code
Emitted by approve with:
This section presents in detail the interaction between the dApp's UI and the poll contract:
The following Completium CLI command generates the contract(s) bindings for a dApp:
completium-cli generate binding-dapp-ts ./poll-contract/contracts/poll.arl > ./src/bindings/poll.ts
The Typescript binding provides the method get_poll that returns the poll container as a list of pair of poll key and poll_value, whose type finally reduces to:
Array<[ Nat, {
ipfs_hash : Bytes,
responses : Array<[ Nat, Nat ]>,
creation : Date
}]>;
The binding is using types from @completium/archtype-ts-types package:
It is convienient to:
A dedicated type Poll is created to merge contract's polls data with IPFS stored poll definition:
export interface Poll {
id : number,
utterance : string,
img : string,
choices : Array<string>
creation : Date,
responses : Array<[ number, number]>
}
The load_data function from Polls.tsx:
const loadData = async () => {
const poll_data = await contract.get_poll()
const polls = await Promise.all(poll_data.map(async ([poll_id, poll_value]) => {
const url = ipfs + poll_value.ipfs_hash.hex_decode()
const res = await fetch(url)
const ui : UIPoll = await res.json()
return {
id : poll_id.to_number(),
utterance : ui.utterance,
img : ui.img,
choices : ui.choices,
creation : poll_value.creation,
responses : poll_value.responses.map(x => [ x[0].to_number(), x[1].to_number() ])
}
}))
setPolls(polls.sort((p1,p2) => p1.creation.getTime() - p2.creation.getTime()))
}
Comments:
RespondPoll UI component displays the SUBMIT button that calls contract's respond entry point.
The code that handles the click button event is:
const respond = async () => {
try {
if (choice !== undefined) {
await contract.respond(new Nat(selected), new Nat(choice), {})
}
} catch(e) {
console.log(e)
}
}
<Button onClick={respond}>submit</Button>
Comments:
AddPoll UI component displays the SUBMIT button that calls contract's add_poll entry point.
The code that handles the click button event is:
const add_poll = async () => {
try {
await contract.add_poll(Bytes.hex_encode(uri), {})
} catch (e) {
console.log(e)
}
}
<Button onClick={add_poll}>submit</Button>
When loaded, RespondPoll UI component checks whether wallet address has already responded to the poll by invoking already_responded view. If so, poll statistics are displayed:
const RespondPoll = () => {
useEffect(() => {
const responded = await contract.view_already_responded(
new Nat(poll.id),
{ as : new Address(wallet_address) }
)
if (responded) {
await loadResponses(poll.id)
}
}, [])
}
Comments:
The poll contracts emits events on poll addition/approval and when a response is submitted. When an event is emitted, the dApp notifies the user with a snack message and a notification appears in the events' panel.
Contract's binding offers methods to register event's handlers. An event handler is a function called when an event is emitted, and that takes this new event as argument. It takes a second optional argument that provides blockchain-related information:
Event handlers are registered in the useEffect hook of constate Events component:
useEffect(() => {
const startListener = async () => {
contract.register_Response(async (e : Response, d ?: EventData) => {
setAlertMsg(make_response_msg(e))
setAlerOpen(true)
await loadResponses(e.poll_id.to_big_number().toNumber())
if (d) addEvent(d)
})
contract.register_NewPoll((np : NewPoll, d ?: EventData) => {
setAlertMsg(make_new_poll_msg(np))
setAlerOpen(true)
if (d) addEvent(d)
})
contract.register_ApprovePoll((ap : ApprovePoll, d ?: EventData) => {
setAlertMsg(make_poll_confirmed_msg(ap))
setAlerOpen(true)
if (d) addEvent(d)
})
await run_listener({
endpoint: endpoint,
verbose: false,
horizon: 0
})
};
startListener()
}, [])
Comments:
Settings.tsx
Taquito.tsx
Beacon.tsx
Contract.tsx
Polls.tsx
Alerts.tsx
Events.tsx
.
└── App
└── DApp
├── Router
│ ├── AddPage
│ │ ├── AddForm
│ │ └── PollPreview
│ │ └── PollPanel
│ ├── PickPage
│ │ └── [PollCard]
│ └── PollPage
│ └── PollPanel
│ └── [ChoicePanel]
└── TopBar
├── EventNotifications
│ ├── [EventCard]
│ └── NotificationMenu
├── GitHubLink
├── LoginButton
│ └── WalletInfo
├── TezosIcon
└── ThemeSwitch
Interacting with the poll contract is done via the generated bindings.
The following Completium CLI command generates the contract bindings:
completium-cli generate ./poll_contract/contracts/poll.arl > ./src/binding/poll.ts
The following mockup code presents the generated Poll TypeScript API:
class Poll {
/*
Contract address is passed to the constructor
*/
constructor Poll(address?: string | undefined): Poll
// utils
get_address(): Address
get_balance(): Promise<Tez>
/*
Entries from Ownership template
*/
declare_ownership(candidate: Address, params: Partial<Parameters>): Promise<any>
claim_ownership(params: Partial<Parameters>): Promise<any>
/*
Entries from Pausable template
*/
pause(params: Partial<Parameters>): Promise<any>
unpause(params: Partial<Parameters>): Promise<any>
/*
Entries from Metadata template
*/
set_metadata(k: string, d: Option<Bytes>, params: Partial<Parameters>): Promise<any>
/*
Poll specific entries
*/
add_poll(h: Bytes, params: Partial<Parameters>): Promise<any>
approve(h: Bytes, params: Partial<Parameters>): Promise<any>
disapprove(h: Bytes, params: Partial<Parameters>): Promise<any>
remove(pk: Nat, params: Partial<Parameters>): Promise<any>
respond(pk: Nat, choice_id: Nat, params: Partial<Parameters>): Promise<any>
/*
Entries' CallParameter makers for batch operations
*/
get_declare_ownership_param(candidate: Address, params: Partial<Parameters>): Promise<CallParameter>
get_claim_ownership_param(params: Partial<Parameters>): Promise<CallParameter>
get_pause_param(params: Partial<Parameters>): Promise<CallParameter>
get_unpause_param(params: Partial<Parameters>): Promise<CallParameter>
get_set_metadata_param(k: string, d: Option<Bytes>, params: Partial<Parameters>): Promise<CallParameter>
get_add_poll_param(h: Bytes, params: Partial<Parameters>): Promise<CallParameter>
get_approve_param(h: Bytes, params: Partial<Parameters>): Promise<CallParameter>
get_disapprove_param(h: Bytes, params: Partial<Parameters>): Promise<CallParameter>
get_remove_param(pk: Nat, params: Partial<Parameters>): Promise<CallParameter>
get_respond_param(pk: Nat, choice_id: Nat, params: Partial<Parameters>): Promise<CallParameter>
/*
Views
*/
view_get_responses(pk: Nat, params: Partial<Parameters>): Promise<Array<[ Nat, Nat ]>>
view_already_responded(pk: Nat, params: Partial<Parameters>): Promise<boolean>
/*
Storage elements getters
*/
get_owner(): Promise<Address>
get_owner_candidate(): Promise<Option<Address>>
get_paused(): Promise<boolean>
get_polls_counter(): Promise<Nat>
get_poll(): Promise<poll_container>
get_poll_to_approve_value(key: poll_to_approve_key): Promise<poll_to_approve_value | undefined>
has_poll_to_approve_value(key: poll_to_approve_key): Promise<boolean>
get_responder_value(key: responder_key): Promise<responder_value | undefined>
has_responder_value(key: responder_key): Promise<boolean>
get_metadata_value(key: string): Promise<Bytes | undefined>
has_metadata_value(key: string): Promise<boolean>
register_Response(ep: el.EventProcessor<Response>): void
/*
Event register utils
*/
register_NewPoll(ep: el.EventProcessor<NewPoll>): void
register_ApprovePoll(ep: el.EventProcessor<ApprovePoll>): void
/*
Errors
*/
(property) Poll.errors: {
f1: Micheline;
r3: Micheline;
r2: Micheline;
INVALID_CALLER: Micheline;
POLL_NOT_FOUND: Micheline;
r1: Micheline;
md_r1: Micheline;
pausable_r2: Micheline;
pausable_r1: Micheline;
ownership_r1: Micheline;
CONTRACT_PAUSED: Micheline;
}
}
Table of Contents
Stay up to date on all things Tezos