diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 8370e4a1d..37001abe2 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -86,20 +86,20 @@ const documentationNavigation = [ }, ], }, - { - title: 'Originating a contract', - href: '/tezos-basics/originate-your-first-smart-contract/smartpy', - children: [ - { - title: 'SmartPy', - href: '/tezos-basics/originate-your-first-smart-contract/smartpy', - }, - { - title: 'LIGO', - href: '/tezos-basics/originate-your-first-smart-contract/ligo', - }, - ], - }, + // { + // title: 'Originating a contract', + // href: '/tezos-basics/originate-your-first-smart-contract/smartpy', + // children: [ + // { + // title: 'SmartPy', + // href: '/tezos-basics/originate-your-first-smart-contract/smartpy', + // }, + // { + // title: 'LIGO', + // href: '/tezos-basics/originate-your-first-smart-contract/ligo', + // }, + // ], + // }, { title: 'Tezos Protocol & Shell', href: '/tezos-basics/tezos-protocol-and-shell', @@ -167,24 +167,24 @@ const documentationNavigation = [ { title: 'Dapp Development', links: [ - { - title: 'Build your first DApp', - href: '/dapp-development/build-your-first-dapp', - children: [ - { - title: 'Wallets and user tokens', - href: '/dapp-development/build-your-first-dapp/wallets-tokens', - }, - { - title: 'Swapping tokens', - href: '/dapp-development/build-your-first-dapp/swapping-tokens', - }, - { - title: 'Adding and removing liquidity', - href: '/dapp-development/build-your-first-dapp/adding-removing-liquidity', - }, - ], - }, + // { + // title: 'Build your first DApp', + // href: '/dapp-development/build-your-first-dapp', + // children: [ + // { + // title: 'Wallets and user tokens', + // href: '/dapp-development/build-your-first-dapp/wallets-tokens', + // }, + // { + // title: 'Swapping tokens', + // href: '/dapp-development/build-your-first-dapp/swapping-tokens', + // }, + // { + // title: 'Adding and removing liquidity', + // href: '/dapp-development/build-your-first-dapp/adding-removing-liquidity', + // }, + // ], + // }, { title: 'Taquito', href: '/dapp-development/taquito' }, { title: 'Indexers', href: '/dapp-development/indexers' }, { @@ -212,24 +212,42 @@ const documentationNavigation = [ { title: 'Synthetics', href: '/defi/synthetics' }, { title: 'Decentralized Autonomous Organization', href: '/defi/dao' }, { title: 'Lending and Flash Loans', href: '/defi/lending' }, - { title: 'Decentralized Fundraising', href: '/defi/decentralized-fundraising' }, + { + title: 'Decentralized Fundraising', + href: '/defi/decentralized-fundraising', + }, ], }, { title: 'NFTs', links: [ { title: 'Create an NFT', href: '/nft/create-an-nft' }, - { title: 'Mint NFT using Taquito and Pinata', href: '/nft/create-an-nft/nft-pinata' }, { - title: 'Build an NFT Marketplace', - href: '/nft/build-an-nft-marketplace', - children: [ - { title: 'NFT Marketplace - Part 1', href: '/nft/build-an-nft-marketplace' }, - { title: 'NFT Marketplace - Part 2', href: '/nft/build-an-nft-marketplace/part-2' }, - { title: 'NFT Marketplace - Part 3', href: '/nft/build-an-nft-marketplace/part-3' }, - { title: 'NFT Marketplace - Part 4', href: '/nft/build-an-nft-marketplace/part-4' }, - ], + title: 'Mint NFT using Taquito and Pinata', + href: '/nft/create-an-nft/nft-pinata', }, + // { + // title: 'Build an NFT Marketplace', + // href: '/nft/build-an-nft-marketplace', + // children: [ + // { + // title: 'NFT Marketplace - Part 1', + // href: '/nft/build-an-nft-marketplace', + // }, + // { + // title: 'NFT Marketplace - Part 2', + // href: '/nft/build-an-nft-marketplace/part-2', + // }, + // { + // title: 'NFT Marketplace - Part 3', + // href: '/nft/build-an-nft-marketplace/part-3', + // }, + // { + // title: 'NFT Marketplace - Part 4', + // href: '/nft/build-an-nft-marketplace/part-4', + // }, + // ], + // }, ], }, { @@ -297,41 +315,63 @@ const documentationNavigation = [ const tutorialNavigation = [ { - title: 'Tezos Basics', + title: 'Tutorials', links: [ { - title: 'Tezos Blockchain Tutorials', - href: 'tutorials/tezos-blockchain-overview', + title: 'Originating a contract', + href: '/tutorials/originate-your-first-smart-contract/smartpy', children: [ { - title: 'Whitepaper', - href: 'tutorials/tezos-blockchain-overview/whitepaper', + title: 'SmartPy', + href: '/tutorials/originate-your-first-smart-contract/smartpy', }, { - title: 'Position Paper', - href: 'tutorials/tezos-blockchain-overview/positionpaper', + title: 'LIGO', + href: '/tutorials/originate-your-first-smart-contract/ligo', }, + ], + }, + { + title: 'Deploy your own smart rollup', + href: '/tutorials/smart-rollups', + }, + { + title: 'Build an NFT Marketplace', + href: '/tutorials/build-an-nft-marketplace', + children: [ { - title: 'Nomenclature', - href: 'tutorials/tezos-blockchain-overview/nomenclature', + title: 'NFT Marketplace - Part 1', + href: '/tutorials/build-an-nft-marketplace', }, { - title: 'Governance', - href: 'tutorials/tezos-blockchain-overview/governance/intro', - children: [ - { - title: 'Governance Overview', - href: 'tutorials/tezos-blockchain-overview/governance/governance-overview', - }, - { - title: 'Tezos Improvement Process', - href: 'tutorials/tezos-blockchain-overview/governance/improvement-process-tzip', - }, - ], + title: 'NFT Marketplace - Part 2', + href: '/tutorials/build-an-nft-marketplace/part-2', }, { - title: 'Functional Programming', - href: 'tutorials/tezos-blockchain-overview/governance/functional-programming', + title: 'NFT Marketplace - Part 3', + href: '/tutorials/build-an-nft-marketplace/part-3', + }, + { + title: 'NFT Marketplace - Part 4', + href: '/tutorials/build-an-nft-marketplace/part-4', + }, + ], + }, + { + title: 'Build your first app', + href: '/tutorials/build-your-first-app', + children: [ + { + title: 'Wallets and user tokens', + href: '/tutorials/build-your-first-app/wallets-tokens', + }, + { + title: 'Swapping tokens', + href: '/tutorials/build-your-first-app/swapping-tokens', + }, + { + title: 'Adding and removing liquidity', + href: '/tutorials/build-your-first-app/adding-removing-liquidity', }, ], }, @@ -858,17 +898,18 @@ function Header({ navigation }) { Documentation - Office Hours + Tutorials - {/* - Tutorials - */} + Office Hours + + {/* {children} - {!isHomePage && ( + {!isHomePage && !router.pathname.endsWith('tutorials') && ( // Don't show the previous and next links on the homepage
{previousPage && ( diff --git a/src/components/QuickLinks.jsx b/src/components/QuickLinks.jsx index 1f8b0ea68..19784a3b4 100644 --- a/src/components/QuickLinks.jsx +++ b/src/components/QuickLinks.jsx @@ -33,7 +33,7 @@ export function QuickLinks({ children }) { } export function QuickLink({ title, description, href, icon, comingSoon }) { - const pathsToHideDescription = ['/tutorials', '/tooling', '/resources'] + const pathsToHideDescription = ['/tooling', '/resources'] // if (comingSoon === "true") { // console.log('comingSoon', comingSoon); diff --git a/src/pages/index.md b/src/pages/index.md index 713ae5a6c..abc65304d 100644 --- a/src/pages/index.md +++ b/src/pages/index.md @@ -10,7 +10,7 @@ Welcome to the Tezos Documentation Portal. We're currently in _beta_. Please sha {% lg-link title="Tezos Blockchain Overview" icon="overview" href="/tezos-blockchain-overview" description="Understanding what makes Tezos different and what you need to start building" /%} -{% lg-link title="Quickstart guide" icon="quickstart" href="/dapp-development/build-your-first-dapp" description="Learn how to write a smart contract and get it deployed quickly" /%} +{% lg-link title="Smart Rollups" icon="quickstart" href="/tutorials/smart-rollups/" description="Get started by deploying your own smart rollup with our onboarding tutorial" /%} {% /lg-links %} @@ -18,7 +18,7 @@ Welcome to the Tezos Documentation Portal. We're currently in _beta_. Please sha {% quick-link title="Get and Install Tezos" icon="installation" href="/tezos-basics/get-started-with-octez" description="Step-by-step guide to install and use the Tezos client Octez" /%} -{% quick-link title="Originate your First Smart Contract" icon="deploy" href="/tezos-basics/originate-your-first-smart-contract/smartpy" description="How to originate your first smart contract" /%} +{% quick-link title="Originate your First Smart Contract" icon="deploy" href="/tutorials/originate-your-first-smart-contract/smartpy" description="How to originate your first smart contract" /%} {% quick-link title="Tezos Protocol & Shell" icon="protocol" href="/tezos-basics/tezos-protocol-and-shell" description="Understanding the Tezos Protocol & Shell" /%} @@ -26,17 +26,17 @@ Welcome to the Tezos Documentation Portal. We're currently in _beta_. Please sha {% quick-link title="Smart Contract Languages" icon="contract" href="/smart-contracts/smart-contract-languages" description="Overview of Smart Contract languages" /%} -{% quick-link title="Wrapped Assets" icon="token" href="/defi/wrapped-assets" description="Learn about ctez and wrapped tokens. Two building blocks for DeFi on Tezos. " /%} +{% quick-link title="Decentralised Exchanges" icon="token" href="/defi/dex" description="Learn about decentralised exchanges, one of the core applications in DeFi" /%} {% quick-link title="Create an NFT" icon="nft" href="/nft/create-an-nft" description="Create your own NFT" /%} -{% quick-link title="Build an NFT Marketplace" icon="marketplace" href="/nft/build-an-nft-marketplace" description="Build a full marketplace for NFTs" /%} +{% quick-link title="Build an NFT Marketplace" icon="marketplace" href="/tutorials/build-an-nft-marketplace" description="Build a full marketplace for NFTs" /%} {% quick-link title="Tezos SDK for Unity" icon="unity" href="/gaming/tezos-sdk-for-unity" description="Tezos SDK for Unity" /%} {% quick-link title="Build a Game on Tezos" icon="game" href="" description="Building a Game on Tezos" comingSoon=true /%} -{% quick-link title="Build your first DApp" icon="dapp" href="/dapp-development/build-your-first-dapp" description="Build your first DApp" /%} +{% quick-link title="Build your first app" icon="dapp" href="/tutorials/build-your-first-app" description="Build your first app on Tezos" /%} {% quick-link title="Taquito" icon="taquito" href="/dapp-development/taquito" description="How is Taquito instrumental for dapp development" /%} diff --git a/src/pages/nft/build-an-nft-marketplace/index.md b/src/pages/nft/build-an-nft-marketplace/index.md index 7c8b30aa9..935afedf9 100644 --- a/src/pages/nft/build-an-nft-marketplace/index.md +++ b/src/pages/nft/build-an-nft-marketplace/index.md @@ -916,6 +916,6 @@ Now you can see all NFTs ![wine collection](/developers/docs/images/winecollection.png) -##µ Conclusion +## Conclusion You are able to create an NFT collection marketplace from the `ligo/fa` library. \ No newline at end of file diff --git a/src/pages/tutorials/build-an-nft-marketplace/index.md b/src/pages/tutorials/build-an-nft-marketplace/index.md new file mode 100644 index 000000000..783821ca9 --- /dev/null +++ b/src/pages/tutorials/build-an-nft-marketplace/index.md @@ -0,0 +1,923 @@ +--- +id: build-an-nft-marketplace +title: Build an NFT Marketplace +--- + +## Introduction + +Business objects managed by a blockchain are called `assets`. On Tezos you will find the term `Financial Asset or FA` with different versions 1, 2, or 2.1. + +Here are different categorizations of assets. + +![](http://jingculturecommerce.com/wp-content/uploads/2021/03/nft-assets-1024x614.jpg) + +## Wine marketplace + +We are going to build a Wine marketplace extending the `@ligo/fa` package from the [Ligo repository](https://packages.ligolang.org/). The goal is to showcase how to extend an existing smart contract and build a frontend on top of it. + +The Wine marketplace is adding these features on top of a generic NFT contract : + +- mint new wine bottles +- update wine bottle metadata details +- buy wine bottles +- sell wine bottles + +You can play with the [final demo](https://demo.winefactory.marigold.dev/). + +![nftfactory.png](/images/nftfactory.png) + +{% callout type="note" %} +Here we present Part 1 of 4 of a training course by [Marigold](https://www.marigold.dev/). You can find all 4 parts on github. +- [NFT 1](https://github.com/marigold-dev/training-nft-1): use FA2 NFT template to understand the basics +- [NFT 2](https://github.com/marigold-dev/training-nft-2): finish FA2 NFT marketplace to introduce sales +- [NFT 3](https://github.com/marigold-dev/training-nft-3): use FA2 single asset template to build another kind of marketplace +- [NFT 4](https://github.com/marigold-dev/training-nft-4): use FA2 multi asset template to build last complex kind of marketplace +{% /callout %} + + +| Token template | # of token_type | # of item per token_type | +| -------------- | --------------- | ------------------------ | +| NFT | 0..n | 1 | +| single asset | 0..1 | 1..n | +| multi asset | 0..n | 1..n | + +{% callout type="note" %} +Because we are in web3, buy or sell features are a real payment system using on-chain XTZ tokens as money. This differs from traditional web2 applications where you have to integrate a payment system and so, pay extra fees +{% /callout %} + +## Glossary + +## What is IPFS? + +The InterPlanetary File System is a protocol and peer-to-peer network for storing and sharing data in a distributed file system. IPFS uses content-addressing to uniquely identify each file in a global namespace connecting all computing devices. In this tutorial, we will be using [Pinata](https://www.pinata.cloud/) (free developer plan) to store the metadata for NFTs. An alternative would be to install a local IPFS node or an API gateway backend with a usage quota. + +## Smart Contracts + +We will use two contracts for the marketplace. + +### The token contract + +On Tezos, FA2 is the standard for Non-Fungible Token contracts. We will be using the [template provided by Ligo](https://packages.ligolang.org/package/@ligo/fa) to build out the Token Contract. The template contains the basic entrypoints for building a Fungible or Non-fungible token including: + +- Transfer +- Balance_of +- Update_operators + +### Marketplace unique contract + +On a second time, we will import the token contract into the marketplace unique contract. The latter will bring missing features as: + +- Mint +- Buy +- Sell + +## Prerequisites + +#### Required + +- [npm](https://nodejs.org/en/download/): front-end is a TypeScript React client app +- [taqueria >= v0.28.5-rc](https://github.com/ecadlabs/taqueria) : Tezos Dapp project tooling +- [Docker](https://docs.docker.com/engine/install/): needed for `taqueria` +- [jq](https://stedolan.github.io/jq/download/): extract `taqueria` JSON data + +#### Recommended + +- [`VS Code`](https://code.visualstudio.com/download): as code editor +- [`yarn`](https://classic.yarnpkg.com/lang/en/docs/install/#windows-stable): to build and run the front-end (see this article for more details about [differences between `npm` and `yarn`](https://www.geeksforgeeks.org/difference-between-npm-and-yarn/)) +- [ligo VS Code extension](https://marketplace.visualstudio.com/items?itemName=ligolang-publish.ligo-vscode): for smart contract highlighting, completion, etc. +- [Temple wallet](https://templewallet.com/): an easy to use Tezos wallet in your browser (or any other one with ghostnet support) + +#### Optional +- [taqueria VS Code extension](https://marketplace.visualstudio.com/items?itemName=ecadlabs.taqueria-vscode): visualize your project and execute tasks + + +## Smart contract + +We will use `taqueria` to shape the project structure, then create the NFT marketplace smart contract thanks to the `ligo/fa` library. + +{% callout type="note" %} +You will require to copy some code from this git repository later, so you can clone it with: + + ```bash + git clone https://github.com/marigold-dev/training-nft-1.git + ``` +{% /callout %} + +### Taq'ify your project + +```bash +taq init training +cd training +taq install @taqueria/plugin-ligo@next +``` + +{% callout type="warning" %} +Important hack: create a dummy esy.json file with `{}` content on it. I will be used by the ligo package installer to not override the default package.json file of taqueria +{% /callout %} + +```bash +echo "{}" > esy.json +``` + +**Your project is ready!** + +### FA2 contract + +We will rely on the Ligo FA library. To understand in detail how assets work on Tezos, please read below notes: + +- [FA2 standard](https://gitlab.com/tezos/tzip/-/blob/master/proposals/tzip-12/tzip-12.md) + +- Additional contract metadata can be added to ease displaying token pictures, etc., this is described in the [TZIP-21 standard](https://gitlab.com/tezos/tzip/-/blob/master/proposals/tzip-21/tzip-21.md) + +- [Generic Contract metadata reference](https://gitlab.com/tezos/tzip/-/blob/master/proposals/tzip-16/tzip-16.md) + +Install the `ligo/fa` library locally: + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.63.2 taq ligo --command "install @ligo/fa" +``` + +### NFT marketplace contract + +Create the NFT marketplace contract with `taqueria` + +```bash +taq create contract nft.jsligo +``` + +Remove the default code and paste this code instead + +```ligolang +#import "@ligo/fa/lib/fa2/nft/NFT.jsligo" "NFT" + +/* ERROR MAP FOR UI DISPLAY or TESTS + const errorMap : map = Map.literal(list([ + ["0", "Enter a positive and not null amount"], + ["1", "Operation not allowed, you need to be administrator"], + ["2", "You cannot sell more than your current balance"], + ["3", "Cannot find the offer you entered for buying"], + ["4", "You entered a quantity to buy than is more than the offer quantity"], + ["5", "Not enough funds, you need to pay at least quantity * offer price to get the tokens"], + ["6", "Cannot find the contract relative to implicit address"], + ])); +*/ + +type storage = + { + administrators: set
, + ledger: NFT.Ledger.t, + metadata: NFT.Metadata.t, + token_metadata: NFT.TokenMetadata.t, + operators: NFT.Operators.t, + token_ids : set + }; + +type ret = [list, storage]; + +type parameter = + | ["Mint", nat,bytes,bytes,bytes,bytes] //token_id, name , description ,symbol , ipfsUrl + | ["AddAdministrator" , address] + | ["Transfer", NFT.transfer] + | ["Balance_of", NFT.balance_of] + | ["Update_operators", NFT.update_operators]; + + +const main = ([p, s]: [parameter,storage]): ret => + match(p, { + Mint: (p: [nat,bytes,bytes,bytes,bytes]) => [list([]),s], + AddAdministrator : (p : address) => {if(Set.mem(Tezos.get_sender(), s.administrators)){ return [list([]),{...s,administrators:Set.add(p, s.administrators)}]} else {return failwith("1");}} , + Transfer: (p: NFT.transfer) => [list([]),s], + Balance_of: (p: NFT.balance_of) => [list([]),s], + Update_operators: (p: NFT.update_operator) => [list([]),s], + }); +``` + +Explanations: + +- the first line `#import "@ligo/fa/lib/fa2/nft/NFT.jsligo" "NFT"` imports the Ligo FA library that we are going to extend. We will add new entrypoints the the base code. +- `storage` definition is an extension of the imported library storage, we point to the original types keeping the same naming + - `NFT.Ledger.t` : keep/trace ownership of tokens + - `NFT.Metadata.t` : tzip-16 compliance + - `NFT.TokenMetadata.t` : tzip-12 compliance + - `NFT.Operators.t` : permissions part of FA2 standard + - `NFT.Storage.token_id>` : cache for keys of token_id bigmap +- `storage` has more fields to support a set of `administrators` +- `parameter` definition is an extension of the imported library entrypoints + - `NFT.transfer` : to transfer NFTs + - `NFT.balance_of` : to check token balance for a specific user (on this template it will return always 1) + - `NFT.update_operators` : to allow other users to manage our NFT +- `parameter` has more entrypoints to allow to create NFTs `Mint` +- `parameter` has an entrypoint `AddAdministrator` to add new administrators. Administrators will be allowed to mint NFTs + +Compile the contract + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.60.0 taq compile nft.jsligo +``` + +{% callout type="note" %} +To be sure that Taqueria will use a correct version of Ligo containing the Ligo package installer w/ Docker fix, we set the env var `TAQ_LIGO_IMAGE` +{% /callout %} + +The contract compiles, now let's write `Transfer,Balance_of,Update_operators` entrypoints. We will do a passthrough call to the underlying library. On `main` function, **replace the default cases code with this one** + +```ligolang + Transfer: (p: NFT.transfer) => { + const ret2 : [list, NFT.storage] = NFT.transfer(p,{ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,token_ids : s.token_ids}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,token_ids:ret2[1].token_ids}]; + }, + Balance_of: (p: NFT.balance_of) => { + const ret2 : [list, NFT.storage] = NFT.balance_of(p,{ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,token_ids : s.token_ids}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,token_ids:ret2[1].token_ids}]; + }, + Update_operators: (p: NFT.update_operator) => { + const ret2 : [list, NFT.storage] = NFT.update_ops(p,{ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,token_ids : s.token_ids}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,token_ids:ret2[1].token_ids}]; + } +``` + +Explanations: + +- every NFT.xxx() called function is taking the storage type of the NFT library, so we send a partial object from our storage definition to match the type definition +- the return type contains also the storage type of the library, so we need to reconstruct the storage by copying the modified fields + +{% callout type="note" %} +The LIGO team is working on merging type definitions, so you then can do `type union` or `merge 2 objects` like in Typescript +{% /callout %} + +Let's add the `Mint` function now. Add the new function, and update the main function + +```ligolang +const mint = (token_id : nat, name :bytes, description:bytes ,symbol :bytes, ipfsUrl:bytes, s: storage) : ret => { + + if(! Set.mem(Tezos.get_sender(), s.administrators)) return failwith("1"); + + const token_info: map = + Map.literal(list([ + ["name", name], + ["description",description], + ["interfaces", (bytes `["TZIP-12"]`)], + ["thumbnailUri", ipfsUrl], + ["symbol",symbol], + ["decimals", (bytes `0`)] + ])) as map; + + + const metadata : bytes = bytes + `{ + "name":"FA2 NFT Marketplace", + "description":"Example of FA2 implementation", + "version":"0.0.1", + "license":{"name":"MIT"}, + "authors":["Marigold"], + "homepage":"https://marigold.dev", + "source":{ + "tools":["Ligo"], + "location":"https://github.com/ligolang/contract-catalogue/tree/main/lib/fa2"}, + "interfaces":["TZIP-012"], + "errors": [], + "views": [] + }` ; + + return [list([]) as list, + {...s, + ledger: Big_map.add(token_id,Tezos.get_sender(),s.ledger) as NFT.Ledger.t, + metadata : Big_map.literal(list([["", bytes `tezos-storage:data`],["data", metadata]])), + token_metadata: Big_map.add(token_id, {token_id: token_id,token_info:token_info},s.token_metadata), + operators: Big_map.empty as NFT.Operators.t, + token_ids : Set.add(token_id,s.token_ids) + }]}; + +const main = ([p, s]: [parameter,storage]): ret => + match(p, { + Mint: (p: [nat,bytes,bytes,bytes,bytes]) => mint(p[0],p[1],p[2],p[3],p[4],s), + AddAdministrator : (p : address) => {if(Set.mem(Tezos.get_sender(), s.administrators)){ return [list([]),{...s,administrators:Set.add(p, s.administrators)}]} else {return failwith("1");}} , + Transfer: (p: NFT.transfer) => [list([]),s], + Balance_of: (p: NFT.balance_of) => [list([]),s], + Update_operators: (p: NFT.update_operator) => [list([]),s], + }); +``` + +Explanations: + +- `mint` function will allow you to create a unique NFT. You have to declare the name, description, symbol, and ipfsUrl for the picture to display +- to simplify, we don't manage the increment of the token_id here it will be done by the front end later. We encourage you to manage this counter on-chain to avoid overriding an existing NFT. There is no rule to allocate a specific number to the token_id but people increment it from 0. Also, there is no rule if you have a burn function to reallocate the token_id to a removed index and just continue the sequence from the greatest index. +- most of the fields are optional except `decimals` that is set to `0`. A unique NFT does not have decimals, it is a unit +- by default, the `quantity` for an NFT is `1`, that is why every bottle is unique and we don't need to set a total supply on each NFT. +- if you want to know the `size of the NFT collection`, look at `token_ids` size. This is used as a `cache` key index of the `token_metadata` big_map. By definition, a big map in Tezos can be accessed through a key, but you need to know the key, there is no function to return the keyset. This is why we keep a trace of all token_id in this set, so we can loop and read/update information on NFTs + +We have finished the smart contract implementation for this first training, let's prepare the deployment to ghostnet. + +Edit the storage file `nft.storageList.jsligo` as it. (:warning: you can change the `administrator` address to your own address or keep `alice`) + +```ligolang +#include "nft.jsligo" +const default_storage = + {administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" + as address])) + as set
, + ledger: Big_map.empty as NFT.Ledger.t, + metadata: Big_map.empty as NFT.Metadata.t, + token_metadata: Big_map.empty as NFT.TokenMetadata.t, + operators: Big_map.empty as NFT.Operators.t, + token_ids: Set.empty as set + }; +``` + +Compile again and deploy to ghostnet + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.60.0 taq compile nft.jsligo +taq install @taqueria/plugin-taquito@next +taq deploy nft.tz -e "testing" +``` + +{% callout type="note" %} +If this is the first time you're using `taqueria`, you may want to run through [this training](https://github.com/marigold-dev/training-dapp-1#ghostnet-testnet-wallet). +{% /callout %} + +> For advanced users, just go to `.taq/config.local.testing.json` and change the default account by alice one's (publicKey,publicKeyHash,privateKey) and then redeploy: +> +> ```json +> { +> "networkName": "ghostnet", +> "accounts": { +> "taqOperatorAccount": { +> "publicKey": "edpkvGfYw3LyB1UcCahKQk4rF2tvbMUk8GFiTuMjL75uGXrpvKXhjn", +> "publicKeyHash": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb", +> "privateKey": "edsk3QoqBuvdamxouPhin7swCvkQNgq4jP5KZPbwWNnwdZpSpJiEbq" +> } +> } +> } +> ``` + +Deploy again + +```bash +taq deploy nft.tz -e "testing" +``` + +```logs +┌──────────┬──────────────────────────────────────┬───────┬──────────────────┬────────────────────────────────┐ +│ Contract │ Address │ Alias │ Balance In Mutez │ Destination │ +├──────────┼──────────────────────────────────────┼───────┼──────────────────┼────────────────────────────────┤ +│ nft.tz │ KT1PLo2zWETRkmqUFEiGqQNVUPorWHVHgHMi │ nft │ 0 │ https://ghostnet.ecadinfra.com │ +└──────────┴──────────────────────────────────────┴───────┴──────────────────┴────────────────────────────────┘ +``` + +** We have finished the backend! ** + +## NFT Marketplace frontend + +## Get the react boilerplate + +To save time, we have a [boilerplate ready for the UI](https://github.com/marigold-dev/training-nft-1/tree/main/reactboilerplateapp) + +Copy this code into your folder (:warning: assuming you have cloned this repo and your current path is `$REPO/training`) + +```bash +cp -r ../reactboilerplateapp/ ./app +``` + +> Note : if you want to understand how it has been made from scratch look at [this training](https://github.com/marigold-dev/training-dapp-1#construction_worker-dapp) + +It is easier on frontend side to use typed objects. Taqueria provides a plugin to generate Typescript classes from your Michelson code. + +Install the plugin, then generate a representation of your smart contract objects that writes these files to your frontend app source code. + +Finally, run the server + +```bash +taq install @taqueria/plugin-contract-types@next +taq generate types ./app/src +cd app +yarn install +yarn run start +``` + +> Note : On `Mac` :green_apple:, `sed` does not work as Unix, change the start script on package.json to +> ` "start": "if test -f .env; then sed -i '' \"s/\\(REACT_APP_CONTRACT_ADDRESS *= *\\).*/\\1$(jq -r 'last(.tasks[]).output[0].address' ../.taq/testing-state.json)/\" .env ; else jq -r '\"REACT_APP_CONTRACT_ADDRESS=\" + last(.tasks[]).output[0].address' ../.taq/testing-state.json > .env ; fi && react-app-rewired start",` + +The website is ready! You have: + +- automatic pull from `taqueria` last deployed contract address at each start +- login/logout +- the general layout / navigation + +If you try to connect you are redirected to `/` path that is also the wine catalog. + +There are no bottle collections yet, so we need to create the mint page. + +## Mint Page + +Edit default Mint Page on `./src/MintPage.tsx` + +### Add a form to create the NFT + +In `MintPage.tsx`, replace the `HTML` template with this one : + +```html + + + {storage ? ( + + ) : ( + "" + )} + + + + + +
+ + Mint a new collection + + + + + + + {pictureUrl ? ( + + ) : ( + "" + )} + + + + +
+
+
+ + + Mint your wine collection + + {nftContratTokenMetadataMap.size != 0 ? ( + "//TODO" + ) : ( + + Sorry, there is not NFT yet, you need to mint bottles first + + )} +
+``` + +Add `formik` form to your Component function inside the same `MintPage.tsx` file: + +```typescript +const validationSchema = yup.object({ + name: yup.string().required("Name is required"), + description: yup.string().required("Description is required"), + symbol: yup.string().required("Symbol is required"), +}); + +const formik = useFormik({ + initialValues: { + name: "", + description: "", + token_id: 0, + symbol: "WINE", + } as TZIP21TokenMetadata, + validationSchema: validationSchema, + onSubmit: (values) => { + mint(values); + }, +}); +``` + +Now, add `pictureUrl` and `setFile` declaration to display the token image after pinning it to IPFS, and to get the upload file on the form: + +```typescript +const [pictureUrl, setPictureUrl] = useState(""); +const [file, setFile] = useState(null); +``` + +Add drawer variables to manage the side popup of the form: + +```typescript +//open mint drawer if admin +const [formOpen, setFormOpen] = useState(false); + +useEffect(() => { + if (storage && storage.administrators.indexOf(userAddress! as address) < 0) + setFormOpen(false); + else setFormOpen(true); +}, [userAddress]); + +const toggleDrawer = + (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { + if ( + event.type === "keydown" && + ((event as React.KeyboardEvent).key === "Tab" || + (event as React.KeyboardEvent).key === "Shift") + ) { + return; + } + setFormOpen(open); + }; +``` + +Finally, fix the missing imports: + +```typescript +import { AddCircleOutlined, Close } from "@mui/icons-material"; +import OpenWithIcon from "@mui/icons-material/OpenWith"; +import { + Box, + Button, + Stack, + SwipeableDrawer, + TextField, + Toolbar, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { useFormik } from "formik"; +import React, { useEffect, useState } from "react"; +import * as yup from "yup"; +import { TZIP21TokenMetadata, UserContext, UserContextType } from "./App"; +import { address } from "./type-aliases"; +``` + +### Add mint missing function + +Add the `mint` function and related imports : + +```typescript +import { useSnackbar } from "notistack"; +import { BigNumber } from "bignumber.js"; +import { address, bytes, nat } from "./type-aliases"; +import { char2Bytes } from "@taquito/utils"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +``` + +```typescript +const { enqueueSnackbar } = useSnackbar(); + +const mint = async (newTokenDefinition: TZIP21TokenMetadata) => { + try { + //IPFS + if (file) { + const formData = new FormData(); + formData.append("file", file); + + const requestHeaders: HeadersInit = new Headers(); + requestHeaders.set( + "pinata_api_key", + `${process.env.REACT_APP_PINATA_API_KEY}` + ); + requestHeaders.set( + "pinata_secret_api_key", + `${process.env.REACT_APP_PINATA_API_SECRET}` + ); + + const resFile = await fetch( + "https://api.pinata.cloud/pinning/pinFileToIPFS", + { + method: "post", + body: formData, + headers: requestHeaders, + } + ); + + const responseJson = await resFile.json(); + console.log("responseJson", responseJson); + + const thumbnailUri = `ipfs://${responseJson.IpfsHash}`; + setPictureUrl( + `https://gateway.pinata.cloud/ipfs/${responseJson.IpfsHash}` + ); + + const op = await nftContrat!.methods + .mint( + new BigNumber(newTokenDefinition.token_id) as nat, + char2Bytes(newTokenDefinition.name!) as bytes, + char2Bytes(newTokenDefinition.description!) as bytes, + char2Bytes(newTokenDefinition.symbol!) as bytes, + char2Bytes(thumbnailUri) as bytes + ) + .send(); + + //close directly the form + setFormOpen(false); + enqueueSnackbar( + "Wine collection is minting ... it will be ready on next block, wait for the confirmation message before minting another collection", + { variant: "info" } + ); + + await op.confirmation(2); + + enqueueSnackbar("Wine collection minted", { variant: "success" }); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = new TransactionInvalidBeaconError( + error + ); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } +}; +``` + +![mint form](/developers/docs/images/mintForm.png) + +Explanations: + +- on Mint button click, we upload a file and then we call the `pinata API` to push the file to `IPFS`. It returns the hash +- hash is used in two different ways + - https pinata gateway link (or any other ipfs http viewer) + - ipfs link for the backend thumbnail url +- TZIP standard requires storing data in `bytes`. As there is no Michelson function to convert string to bytes (using Micheline data PACK will not work as it alters the final bytes), we do the conversion using `char2Bytes` on the frontend side + +> Note : Finally, if you remember on the backend , we said that token_id increment management was done in the ui, so you can write this code. It is not a good security practice as it supposes that the counter is managed on frontend side, but it is ok for demo purpose. + +Add this code, every time you have a new token minted, you increment the counter for the next one + +```typescript +useEffect(() => { + (async () => { + if (storage && storage.token_ids.length > 0) { + formik.setFieldValue("token_id", storage?.token_ids.length); + } + })(); +}, [storage?.token_ids]); +``` + +### Display all minted bottles + +Replace the `"//TODO"` keyword with this template + +```html + + + {Array.from(nftContratTokenMetadataMap!.entries()).map( + ([token_id, token]) => ( + + + + + + + + {"ID : " + token_id} + {"Symbol : " + token.symbol} + + {"Description : " + token.description} + + + + + ) + )} + + + Next + + + } + backButton={ + + } + /> + +``` + +Add missing imports and parameters + +```typescript +import SwipeableViews from "react-swipeable-views"; +import OpenWithIcon from "@mui/icons-material/OpenWith"; +import { + Box, + Button, + CardHeader, + CardMedia, + MobileStepper, + Stack, + SwipeableDrawer, + TextField, + Toolbar, + useMediaQuery, +} from "@mui/material"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import { + AddCircleOutlined, + Close, + KeyboardArrowLeft, + KeyboardArrowRight, +} from "@mui/icons-material"; +``` + +```typescript +const [activeStep, setActiveStep] = React.useState(0); + +const handleNext = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1); +}; + +const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); +}; + +const handleStepChange = (step: number) => { + setActiveStep(step); +}; +``` + +## Let's play + +1. Connect with your wallet and choose `alice` account _(or the administrator you set on the smart contract earlier)_. You are redirected to the Administration /mint page as there is no NFT minted yet. + +2. Create your first wine bottle, for example: + +- `name`: Saint Emilion - Franc la Rose +- `symbol`: SEMIL +- `description`: Grand cru 2007 + +3. Click on `Upload an image` and select a bottle picture on your computer + +4. Click on the Mint button + +![minting](/developers/docs/images/minting.png) + +Your picture will be pushed to IPFS and displayed. + +Then, Temple Wallet _(or whatever other wallet you choose)_ will ask you to sign the operation. Confirm it, and less than 1 minute after the confirmation notification, the page will be automatically refreshed to display your wine collection with your first NFT! + +Now you can see all NFTs + +![wine collection](/developers/docs/images/winecollection.png) + +## Conclusion + +You are able to create an NFT collection marketplace from the `ligo/fa` library. + +To continue, let's go to [Part 2](/developers/docs/tutorials/build-an-nft-marketplace/part-2). \ No newline at end of file diff --git a/src/pages/tutorials/build-an-nft-marketplace/part-2.md b/src/pages/tutorials/build-an-nft-marketplace/part-2.md new file mode 100644 index 000000000..4093dfcfc --- /dev/null +++ b/src/pages/tutorials/build-an-nft-marketplace/part-2.md @@ -0,0 +1,1075 @@ +--- +id: nft-marketplace-part-2 +title: NFT Marketplace Part 2 +--- + +This time we will add the ability to buy and sell an NFT! + +Keep your code from the previous lesson or get the solution [here](https://github.com/marigold-dev/training-nft-1/tree/main/solution) + +> If you clone/fork a repo, rebuild locally + +```bash +npm i +cd ./app +yarn install +cd .. +``` + +## Smart contract + +Add the following code sections on your `nft.jsligo` smart contract + +Add offer type + +```ligolang +type offer = { + owner : address, + price : nat +}; +``` + +Add `offers` field to storage + +```ligolang +type storage = + { + administrators: set
, + offers: map, //user sells an offer + ledger: NFT.Ledger.t, + metadata: NFT.Metadata.t, + token_metadata: NFT.TokenMetadata.t, + operators: NFT.Operators.t, + token_ids : set + }; +``` + +Add 2 variants `Buy` and `Sell` to the parameter + +```ligolang +type parameter = + | ["Mint", nat,bytes,bytes,bytes,bytes] //token_id, name , description ,symbol , ipfsUrl + | ["Buy", nat, address] //buy token_id at a seller offer price + | ["Sell", nat, nat] //sell token_id at a price + | ["AddAdministrator" , address] + | ["Transfer", NFT.transfer] + | ["Balance_of", NFT.balance_of] + | ["Update_operators", NFT.update_operators]; +``` + +Add 2 entrypoints `Buy` and `Sell` inside the `main` function + +```ligolang +const main = ([p, s]: [parameter,storage]): ret => + match(p, { + Mint: (p: [nat,bytes,bytes,bytes,bytes]) => mint(p[0],p[1],p[2],p[3],p[4],s), + Buy: (p : [nat,address]) => [list([]),s], + Sell: (p : [nat,nat]) => [list([]),s], + AddAdministrator : (p : address) => {if(Set.mem(Tezos.get_sender(), s.administrators)){ return [list([]),{...s,administrators:Set.add(p, s.administrators)}]} else {return failwith("1");}} , + Transfer: (p: NFT.transfer) => { + const ret2: [list, NFT.storage] = + NFT.transfer( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Balance_of: (p: NFT.balance_of) => { + const ret2: [list, NFT.storage] = + NFT.balance_of( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Update_operators: (p: NFT.update_operators) => { + const ret2: [list, NFT.storage] = + NFT.update_ops( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + } + } + ); +``` + +Explanation: + +- an `offer` is an NFT _(owned by someone)_ with a price +- `storage` has a new field to store `offers`: a `map` of offers +- `parameter` has two new entrypoints `buy` and `sell` +- `main` function exposes these two new entrypoints + +Update also the initial storage on file `nft.storages.jsligo` to initialize `offers` + +```ligolang +#include "nft.jsligo" +const default_storage = + {administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" + as address])) + as set
, + offers: Map.empty as map, + ledger: Big_map.empty as NFT.Ledger.t, + metadata: Big_map.empty as NFT.Metadata.t, + token_metadata: Big_map.empty as NFT.TokenMetadata.t, + operators: Big_map.empty as NFT.Operators.t, + token_ids: Set.empty as set + }; +``` + +Finally, compile the contract + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo +``` + +### Sell at an offer price + +Define the `sell` function as below: + +```ligolang +const sell = (token_id : nat,price : nat, s : storage) : ret => { + + //check balance of seller + const sellerBalance = NFT.Storage.get_balance({ledger:s.ledger,metadata:s.metadata,operators:s.operators,token_metadata:s.token_metadata,token_ids : s.token_ids},Tezos.get_source(),token_id); + if(sellerBalance != (1 as nat)) return failwith("2"); + + //need to allow the contract itself to be an operator on behalf of the seller + const newOperators = NFT.Operators.add_operator(s.operators,Tezos.get_source(),Tezos.get_self_address(),token_id); + + //DECISION CHOICE: if offer already exists, we just override it + return [list([]) as list,{...s,offers:Map.add(token_id,{owner : Tezos.get_source(), price : price},s.offers),operators:newOperators}]; +}; +``` + +Then call it in the `main` function to do the right business operations + +```ligolang +const main = ([p, s]: [parameter, storage]): ret => + match( + p, + { + Mint: (p: [nat, bytes, bytes, bytes, bytes]) => + mint(p[0], p[1], p[2], p[3], p[4], s), + Buy: (p: [nat, address]) => [list([]), s], + Sell: (p : [nat,nat]) => sell(p[0],p[1], s), + AddAdministrator: (p: address) => { + if (Set.mem(Tezos.get_sender(), s.administrators)) { + return [ + list([]), + { ...s, administrators: Set.add(p, s.administrators) } + ] + } else { + return failwith("1") + } + }, + Transfer: (p: NFT.transfer) => { + const ret2: [list, NFT.storage] = + NFT.transfer( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Balance_of: (p: NFT.balance_of) => { + const ret2: [list, NFT.storage] = + NFT.balance_of( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Update_operators: (p: NFT.update_operators) => { + const ret2: [list, NFT.storage] = + NFT.update_ops( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + } + } + ); +``` + +Explanation: + +- User must have enough tokens _(wine bottles)_ to place an offer +- the seller will set the NFT marketplace smart contract as an operator. When the buyer sends his money to buy the NFT, the smart contract will change the NFT ownership _(it is not interactive with the seller, the martketplace will do it on behalf of the seller based on the offer data)_ +- we update the `storage` to publish the offer +- finally, do the correct business by calling `sell` function inside the `sell` case on `main` + +### Buy a bottle on the marketplace + +Now that we have offers available on the marketplace, let's buy bottles! + +Edit the smart contract to add the `buy` feature + +```ligolang +const buy = (token_id : nat, seller : address, s : storage) : ret => { + + //search for the offer + return match( Map.find_opt(token_id,s.offers) , { + None : () => failwith("3"), + Some : (offer : offer) => { + + //check if amount have been paid enough + if(Tezos.get_amount() < offer.price * (1 as mutez)) return failwith("5"); + + // prepare transfer of XTZ to seller + const op = Tezos.transaction(unit,offer.price * (1 as mutez),Tezos.get_contract_with_error(seller,"6")); + + //transfer tokens from seller to buyer + const ledger = NFT.Ledger.transfer_token_from_user_to_user(s.ledger,token_id,seller,Tezos.get_source()); + + //remove offer + return [list([op]) as list, {...s, offers : Map.update(token_id,None(),s.offers), ledger : ledger}]; + } + }); +}; +``` + +Call `buy` function on `main` + +```ligolang +const main = ([p, s]: [parameter, storage]): ret => + match( + p, + { + Mint: (p: [nat, bytes, bytes, bytes, bytes]) => + mint(p[0], p[1], p[2], p[3], p[4], s), + Buy: (p: [nat, address]) => buy(p[0], p[1], s), + Sell: (p: [nat, nat]) => sell(p[0], p[1], s), + AddAdministrator: (p: address) => { + if (Set.mem(Tezos.get_sender(), s.administrators)) { + return [ + list([]), + { ...s, administrators: Set.add(p, s.administrators) } + ] + } else { + return failwith("1") + } + }, + Transfer: (p: NFT.transfer) => { + const ret2: [list, NFT.storage] = + NFT.transfer( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Balance_of: (p: NFT.balance_of) => { + const ret2: [list, NFT.storage] = + NFT.balance_of( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + }, + Update_operators: (p: NFT.update_operators) => { + const ret2: [list, NFT.storage] = + NFT.update_ops( + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + token_ids: s.token_ids + } + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + token_ids: ret2[1].token_ids + } + ] + } + } + ); +``` + +Explanation: + +- search for the offer based on the `token_id` or return an error if it does not exist +- check that the amount sent by the buyer is greater than the offer price. If it is ok, transfer the offer price to the seller and transfer the NFT to the buyer +- remove the offer as it has been executed +- finally, do the correct business by calling `sell` function inside the `sell` case on `main` + +### Compile and deploy + +We finished the smart contract implementation of this second training, let's deploy to ghostnet. + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo +taq deploy nft.tz -e "testing" +``` + +```logs +┌──────────┬──────────────────────────────────────┬───────┬──────────────────┬────────────────────────────────┐ +│ Contract │ Address │ Alias │ Balance In Mutez │ Destination │ +├──────────┼──────────────────────────────────────┼───────┼──────────────────┼────────────────────────────────┤ +│ nft.tz │ KT1J9QpWT8awyYiFJSpEWqZtVYWKVrbm1idY │ nft │ 0 │ https://ghostnet.ecadinfra.com │ +└──────────┴──────────────────────────────────────┴───────┴──────────────────┴────────────────────────────────┘ +``` + +** We have implemented and deployed the smart contract (backend)!** + +## NFT Marketplace front + +Generate Typescript classes and go to the frontend to run the server + +```bash +taq generate types ./app/src +cd ./app +yarn install +yarn run start +``` + +## Sale page + +Edit Sale Page on `./src/OffersPage.tsx` + +Add this code inside the file : + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import SellIcon from "@mui/icons-material/Sell"; + +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + InputAdornment, + Pagination, + TextField, + Tooltip, + Typography, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useEffect, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +const validationSchema = yup.object({ + price: yup + .number() + .required("Price is required") + .positive("ERROR: The number must be greater than 0!"), +}); + +type Offer = { + owner: address; + price: nat; +}; + +export default function OffersPage() { + const [selectedTokenId, setSelectedTokenId] = React.useState(0); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + let [offersTokenIDMap, setOffersTokenIDMap] = React.useState>( + new Map() + ); + let [ownerTokenIds, setOwnerTokenIds] = React.useState>(new Set()); + + const { + nftContrat, + nftContratTokenMetadataMap, + userAddress, + storage, + refreshUserContextOnPageReload, + Tezos, + setUserAddress, + setUserBalance, + wallet, + } = React.useContext(UserContext) as UserContextType; + + const { enqueueSnackbar } = useSnackbar(); + + const formik = useFormik({ + initialValues: { + price: 0, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedTokenId); + sell(selectedTokenId, values.price); + }, + }); + + const initPage = async () => { + if (storage) { + console.log("context is not empty, init page now"); + ownerTokenIds = new Set(); + offersTokenIDMap = new Map(); + + await Promise.all( + storage.token_ids.map(async (token_id) => { + let owner = await storage.ledger.get(token_id); + if (owner === userAddress) { + ownerTokenIds.add(token_id); + + const ownerOffers = await storage.offers.get(token_id); + if (ownerOffers) offersTokenIDMap.set(token_id, ownerOffers); + + console.log( + "found for " + + owner + + " on token_id " + + token_id + + " with balance " + + 1 + ); + } else { + console.log("skip to next token id"); + } + }) + ); + setOwnerTokenIds(new Set(ownerTokenIds)); //force refresh + setOffersTokenIDMap(new Map(offersTokenIDMap)); //force refresh + } else { + console.log("context is empty, wait for parent and retry ..."); + } + }; + + useEffect(() => { + (async () => { + console.log("after a storage changed"); + await initPage(); + })(); + }, [storage]); + + useEffect(() => { + (async () => { + console.log("on Page init"); + await initPage(); + })(); + }, []); + + const sell = async (token_id: number, price: number) => { + try { + const op = await nftContrat?.methods + .sell( + BigNumber(token_id) as nat, + BigNumber(price * 1000000) as nat //to mutez + ) + .send(); + + await op?.confirmation(2); + + enqueueSnackbar( + "Wine collection (token_id=" + + token_id + + ") offer for " + + 1 + + " units at price of " + + price + + " XTZ", + { variant: "success" } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + + return ( + + + Sell my bottles + + {ownerTokenIds && ownerTokenIds.size != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil( + Array.from(ownerTokenIds.entries()).length / itemPerPage + )} + showFirstButton + showLastButton + /> + + + {Array.from(ownerTokenIds.entries()) + .filter((_, index) => + index >= currentPageIndex * itemPerPage - itemPerPage && + index < currentPageIndex * itemPerPage + ? true + : false + ) + .map(([token_id]) => ( + + + + {" "} + {"ID : " + token_id.toString()}{" "} + + + {"Description : " + + nftContratTokenMetadataMap.get( + token_id.toNumber() + )?.description} + + + } + > + + + } + title={ + nftContratTokenMetadataMap.get(token_id.toNumber())?.name + } + /> + + + + + + {offersTokenIDMap.get(token_id) + ? "Traded : " + + 1 + + " (price : " + + offersTokenIDMap + .get(token_id) + ?.price.dividedBy(1000000) + + " Tz)" + : ""} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedTokenId(token_id.toNumber()); + formik.handleSubmit(values); + }} + > + + + + + ), + }} + /> + +
+ )} +
+
+ ))}{" "} +
+
+ ) : ( + + Sorry, you don't own any bottles, buy or mint some first + + )} +
+ ); +} +``` + +Explanation: + +- the template will display all owned NFTs. Only NFTs belonging to the logged user are selected +- for each NFT, we have a form to make an offer at a price +- if you do an offer, it calls the `sell` function and the smart contract entrypoint `nftContrat?.methods.sell(BigNumber(token_id) as nat,BigNumber(price * 1000000) as nat).send()`. We multiply the XTZ price by 10^6 because the smart contract manipulates mutez. + +## Let's play : Sell + +1. Connect with your wallet and choose `alice` account (or one of the administrators you set on the smart contract earlier). You are redirected to the Administration /mint page as there is no NFT minted yet + +2. Enter these values on the form for example : + +- `name`: Saint Emilion - Franc la Rose +- `symbol`: SEMIL +- `description`: Grand cru 2007 + +3. Click on `Upload an image` and select a bottle picture on your computer + +4. Click on the Mint button + +Your picture will be pushed to IPFS and displayed, then your wallet ask you to sign the mint operation. + +- Confirm operation + +- Wait less than 1 minute until you get the confirmation notification, the page will automatically be refreshed. + +5. Now, go to the `Trading` menu and the `Sell bottles` submenu. + +6. Click on the submenu entry + +![sell.png](/developers/docs/images/sell.png) + +You are the owner of this bottle so you can create an offer to sell it. + +- Enter a price offer +- Click on `SELL` button +- Wait a bit for the confirmation, then after auto-refresh you have an offer for this NFT + +## Wine Catalogue page + +Edit the Wine Catalogue page on `./src/WineCataloguePage.tsx` + +Add the following code inside the file + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + Pagination, + Tooltip, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +type OfferEntry = [nat, Offer]; + +type Offer = { + owner: address; + price: nat; +}; + +const validationSchema = yup.object({}); + +export default function WineCataloguePage() { + const { + Tezos, + nftContratTokenMetadataMap, + setUserAddress, + setUserBalance, + wallet, + userAddress, + nftContrat, + refreshUserContextOnPageReload, + storage, + } = React.useContext(UserContext) as UserContextType; + const [selectedOfferEntry, setSelectedOfferEntry] = + React.useState(null); + + const formik = useFormik({ + initialValues: { + quantity: 1, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedOfferEntry); + buy(selectedOfferEntry!); + }, + }); + const { enqueueSnackbar } = useSnackbar(); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const buy = async (selectedOfferEntry: OfferEntry) => { + try { + const op = await nftContrat?.methods + .buy( + BigNumber(selectedOfferEntry[0]) as nat, + selectedOfferEntry[1].owner + ) + .send({ + amount: selectedOfferEntry[1].price.toNumber(), + mutez: true, + }); + + await op?.confirmation(2); + + enqueueSnackbar( + "Bought " + + 1 + + " unit of Wine collection (token_id:" + + selectedOfferEntry[0] + + ")", + { + variant: "success", + } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + return ( + + + Wine catalogue + + + {storage?.offers && storage?.offers.size != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil( + Array.from(storage?.offers.entries()).length / itemPerPage + )} + showFirstButton + showLastButton + /> + + {Array.from(storage?.offers.entries()) + + .filter((_, index) => + index >= currentPageIndex * itemPerPage - itemPerPage && + index < currentPageIndex * itemPerPage + ? true + : false + ) + .map(([token_id, offer]) => ( + + + + {" "} + {"ID : " + token_id.toString()}{" "} + + + {"Description : " + + nftContratTokenMetadataMap.get( + token_id.toNumber() + )?.description} + + + {"Seller : " + offer.owner}{" "} + + + } + > + + + } + title={ + nftContratTokenMetadataMap.get(token_id.toNumber())?.name + } + /> + + + + + + {" "} + {"Price : " + offer.price.dividedBy(1000000) + " XTZ"} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedOfferEntry([token_id, offer]); + formik.handleSubmit(values); + }} + > + +
+ )} +
+
+ ))} +
+
+ ) : ( + + Sorry, there is not NFT to buy yet, you need to mint or sell bottles + first + + )} +
+ ); +} +``` + +## Buy some wine! + +Now you can see on `Trading` menu the `Wine catalogue` submenu, click on it. + +![buy.png](/developers/docs/images/buy.png) + +As you are connected with the default administrator you can see your own unique offer on the market + +- Disconnect from your user and connect with another account that has enough tez to buy the bottle +- The buyer can see that Alice is selling a bottle +- Buy the bottle by clicking on the `BUY` button +- Once confirmed, the offer is removed from the market +- Click on `bottle offers` sub menu +- You are now the owner of this bottle, you can resell it at your own price, etc ... + +## Conclusion + +You created an NFT collection marketplace from the Ligo library, now you can buy and sell NFTs at your own price. + +In the next lesson, you will see another kind of NFT called `single asset`. Instead of creating *X* token types, you will be allowed to create only 1 token_id 0, on the other side, you can mint a quantity *n* of this token. + +To continue, let's go to [Part 3](/developers/docs/tutorials/build-an-nft-marketplace/part-3). \ No newline at end of file diff --git a/src/pages/tutorials/build-an-nft-marketplace/part-3.md b/src/pages/tutorials/build-an-nft-marketplace/part-3.md new file mode 100644 index 000000000..34608f395 --- /dev/null +++ b/src/pages/tutorials/build-an-nft-marketplace/part-3.md @@ -0,0 +1,1357 @@ +--- +id: nft-marketplace-part-3 +title: NFT Marketplace Part 3 +--- + +This time we are going to use the single asset template. It is the opposite of the previous NFT template because: + +- you have a unique `token_id`, so only 1 wine collection +- you have a certain quantity of items in the same collection + +To sum up, you are producing wine bottles from the same collection with `n` quantity. + +Keep your code from previous training or get the solution [here](https://github.com/marigold-dev/training-nft-2/tree/main/solution) + +> If you clone/fork a repo, rebuild in local + +```bash +npm i +cd ./app +yarn install +cd .. +``` + +## Smart Contract + +Point to the new template changing the first import line of your `nft.jsligo` file to + +```ligolang +#import "@ligo/fa/lib/fa2/asset/single_asset.mligo" "SINGLEASSET" +``` + +It means you will change the namespace from `NFT` to `SINGLEASSET` everywhere (like this you are sure to use the correct library) + +Change the `offer` and `storage` definitions + +```ligolang +type offer = { + quantity : nat, + price : nat +}; + +type storage = + { + administrators: set
, + totalSupply: nat, + offers: map, //user sells an offer + ledger: SINGLEASSET.Ledger.t, + metadata: SINGLEASSET.Metadata.t, + token_metadata: SINGLEASSET.TokenMetadata.t, + operators: SINGLEASSET.Operators.t, + owners: set + }; +``` + +Explanation: + +- `offers` is now a `map`, because you don't have to store `token_id` as a key, now the key is the owner's address. Each owner can sell a part of the unique collection +- `offer` requires a quantity, each owner will sell a part of the unique collection +- `totalSupply` is set while minting in order to track the global quantity of minted items on the collection. It makes it unnecessary to recalculate each time the quantity from each owner's holdings (this value is constant) +- Because the ledger is made of `big_map` of key `owners`, we cache the keys to be able to loop on it +- Since we have a unique collection, we remove `token_ids`. `token_id` will be set to `0` + +We don't change the `parameter` type because the signature is the same, but you can edit the comments because it is not the same parameter anymore and also changes to the new namespace `SINGLEASSET` + +```ligolang +type parameter = + | ["Mint", nat,bytes,bytes,bytes,bytes] // quantity, name , description ,symbol , bytesipfsUrl + | ["Buy", nat, address] //buy quantity at a seller offer price + | ["Sell", nat, nat] //sell quantity at a price + | ["AddAdministrator" , address] + | ["Transfer", SINGLEASSET.transfer] + | ["Balance_of", SINGLEASSET.balance_of] + | ["Update_operators", SINGLEASSET.update_operators]; +``` + +Edit the `mint` function to add the `quantity` extra param, and finally change the `return` + +```ligolang +const mint = (quantity : nat, name : bytes, description : bytes ,symbol : bytes , ipfsUrl : bytes, s : storage) : ret => { + + if(quantity <= (0 as nat)) return failwith("0"); + + if(! Set.mem(Tezos.get_sender(), s.administrators)) return failwith("1"); + + const token_info: map = + Map.literal(list([ + ["name", name], + ["description",description], + ["interfaces", (bytes `["TZIP-12"]`)], + ["thumbnailUri", ipfsUrl], + ["symbol",symbol], + ["decimals", (bytes `0`)] + ])) as map; + + + const metadata : bytes = bytes + `{ + "name":"FA2 NFT Marketplace", + "description":"Example of FA2 implementation", + "version":"0.0.1", + "license":{"name":"MIT"}, + "authors":["Marigold"], + "homepage":"https://marigold.dev", + "source":{ + "tools":["Ligo"], + "location":"https://github.com/ligolang/contract-catalogue/tree/main/lib/fa2"}, + "interfaces":["TZIP-012"], + "errors": [], + "views": [] + }` ; + + return [list([]) as list, + {...s, + totalSupply: quantity, + ledger: Big_map.literal(list([[Tezos.get_sender(),quantity as nat]])) as SINGLEASSET.Ledger.t, + metadata : Big_map.literal(list([["", bytes `tezos-storage:data`],["data", metadata]])), + token_metadata: Big_map.add(0 as nat, {token_id: 0 as nat,token_info:token_info},s.token_metadata), + operators: Big_map.empty as SINGLEASSET.Operators.t, + owners: Set.add(Tezos.get_sender(),s.owners)}]; + }; +``` + +Edit the `sell` function to replace `token_id` by `quantity`, we add/override an offer for the user + +```ligolang +const sell = (quantity: nat, price: nat, s: storage) : ret => { + + //check balance of seller + const sellerBalance = SINGLEASSET.Storage.get_amount_for_owner({ledger:s.ledger,metadata:s.metadata,operators:s.operators,token_metadata:s.token_metadata,owners:s.owners})(Tezos.get_source()); + if(quantity > sellerBalance) return failwith("2"); + + //need to allow the contract itself to be an operator on behalf of the seller + const newOperators = SINGLEASSET.Operators.add_operator(s.operators)(Tezos.get_source())(Tezos.get_self_address()); + + //DECISION CHOICE: if offer already exists, we just override it + return [list([]) as list,{...s,offers:Map.add(Tezos.get_source(),{quantity : quantity, price : price},s.offers),operators:newOperators}]; +}; +``` + +Also edit the `buy` function to replace `token_id` by `quantity`, check quantities, check final price is enough and update the current offer + +```ligolang +const buy = (quantity: nat, seller: address, s: storage) : ret => { + + //search for the offer + return match( Map.find_opt(seller,s.offers) , { + None : () => failwith("3"), + Some : (offer : offer) => { + //check if quantity is enough + if(quantity > offer.quantity) return failwith("4"); + //check if amount have been paid enough + if(Tezos.get_amount() < (offer.price * quantity) * (1 as mutez)) return failwith("5"); + + // prepare transfer of XTZ to seller + const op = Tezos.transaction(unit,(offer.price * quantity) * (1 as mutez),Tezos.get_contract_with_error(seller,"6")); + + //transfer tokens from seller to buyer + let ledger = SINGLEASSET.Ledger.decrease_token_amount_for_user(s.ledger)(seller)(quantity); + ledger = SINGLEASSET.Ledger.increase_token_amount_for_user(ledger)(Tezos.get_source())(quantity); + + //update new offer + const newOffer = {...offer,quantity : abs(offer.quantity - quantity)}; + + return [list([op]) as list, {...s, offers : Map.update(seller,Some(newOffer),s.offers), ledger : ledger, owners : Set.add(Tezos.get_source(),s.owners)}]; + } + }); +}; +``` + +Finally, update the namespaces and replace `token_ids` by owners on the `main` function + +```ligolang +const main = ([p, s]: [parameter,storage]): ret => + match(p, { + Mint: (p: [nat,bytes,bytes,bytes,bytes]) => mint(p[0],p[1],p[2],p[3],p[4],s), + Buy: (p : [nat,address]) => buy(p[0],p[1],s), + Sell: (p : [nat,nat]) => sell(p[0],p[1], s), + AddAdministrator : (p : address) => {if(Set.mem(Tezos.get_sender(), s.administrators)){ return [list([]),{...s,administrators:Set.add(p, s.administrators)}]} else {return failwith("1");}} , + Transfer: (p: SINGLEASSET.transfer) => { + const ret2 : [list, SINGLEASSET.storage] = SINGLEASSET.transfer(p)({ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,owners:s.owners}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,owners:ret2[1].owners}]; + }, + Balance_of: (p: SINGLEASSET.balance_of) => { + const ret2 : [list, SINGLEASSET.storage] = SINGLEASSET.balance_of(p)({ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,owners:s.owners}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,owners:ret2[1].owners}]; + }, + Update_operators: (p: SINGLEASSET.update_operators) => { + const ret2 : [list, SINGLEASSET.storage] = SINGLEASSET.update_ops(p)({ledger:s.ledger,metadata:s.metadata,token_metadata:s.token_metadata,operators:s.operators,owners:s.owners}); + return [ret2[0],{...s,ledger:ret2[1].ledger,metadata:ret2[1].metadata,token_metadata:ret2[1].token_metadata,operators:ret2[1].operators,owners:ret2[1].owners}]; + } + }); +``` + +Edit the storage file `nft.storageList.jsligo` as it. (:warning: you can change the `administrator` address to your own address or keep `alice`) + +```ligolang +#include "nft.jsligo" +const default_storage = +{ + administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" as address])) as set
, + totalSupply: 0 as nat, + offers: Map.empty as map, + ledger: Big_map.empty as SINGLEASSET.Ledger.t, + metadata: Big_map.empty as SINGLEASSET.Metadata.t, + token_metadata: Big_map.empty as SINGLEASSET.TokenMetadata.t, + operators: Big_map.empty as SINGLEASSET.Operators.t, + owners: Set.empty as set, + } +; +``` + +Compile again and deploy to ghostnet. + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo +taq deploy nft.tz -e "testing" +``` + +```logs +┌──────────┬──────────────────────────────────────┬───────┬──────────────────┬────────────────────────────────┐ +│ Contract │ Address │ Alias │ Balance In Mutez │ Destination │ +├──────────┼──────────────────────────────────────┼───────┼──────────────────┼────────────────────────────────┤ +│ nft.tz │ KT1SYqk9tAhgExhLawfvwc3ZCfGNzYjwi38n │ nft │ 0 │ https://ghostnet.ecadinfra.com │ +└──────────┴──────────────────────────────────────┴───────┴──────────────────┴────────────────────────────────┘ +``` + +We finished the smart contract! _(backend)_ + +# NFT Marketplace front + +Generate Typescript classes and go to the frontend to run the server + +```bash +taq generate types ./app/src +cd ./app +yarn install +yarn run start +``` + +## Update in `App.tsx` + +We just need to fetch the token_id == 0. +Replace the function `refreshUserContextOnPageReload` by + +```typescript +const refreshUserContextOnPageReload = async () => { + console.log("refreshUserContext"); + //CONTRACT + try { + let c = await Tezos.contract.at(nftContractAddress, tzip12); + console.log("nftContractAddress", nftContractAddress); + + let nftContrat: NftWalletType = await Tezos.wallet.at( + nftContractAddress + ); + const storage = (await nftContrat.storage()) as Storage; + + try { + let tokenMetadata: TZIP21TokenMetadata = (await c + .tzip12() + .getTokenMetadata(0)) as TZIP21TokenMetadata; + nftContratTokenMetadataMap.set(0, tokenMetadata); + + setNftContratTokenMetadataMap(new Map(nftContratTokenMetadataMap)); //new Map to force refresh + } catch (error) { + console.log("error refreshing nftContratTokenMetadataMap: "); + } + + setNftContrat(nftContrat); + setStorage(storage); + } catch (error) { + console.log("error refreshing nft contract: ", error); + } + + //USER + const activeAccount = await wallet.client.getActiveAccount(); + if (activeAccount) { + setUserAddress(activeAccount.address); + const balance = await Tezos.tz.getBalance(activeAccount.address); + setUserBalance(balance.toNumber()); + } + + console.log("refreshUserContext ended."); +}; +``` + +### Update in `MintPage.tsx` + +We introduce the quantity and remove the `token_id` variable. Replace the full file with the following content: + +```typescript +import OpenWithIcon from "@mui/icons-material/OpenWith"; +import { + Button, + CardHeader, + CardMedia, + MobileStepper, + Stack, + SwipeableDrawer, + TextField, + Toolbar, + useMediaQuery, +} from "@mui/material"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { BigNumber } from "bignumber.js"; +import { useSnackbar } from "notistack"; +import React, { useEffect, useState } from "react"; +import { TZIP21TokenMetadata, UserContext, UserContextType } from "./App"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; + +import { + AddCircleOutlined, + Close, + KeyboardArrowLeft, + KeyboardArrowRight, +} from "@mui/icons-material"; +import { char2Bytes } from "@taquito/utils"; +import { useFormik } from "formik"; +import SwipeableViews from "react-swipeable-views"; +import * as yup from "yup"; +import { address, bytes, nat } from "./type-aliases"; +export default function MintPage() { + const { + userAddress, + storage, + nftContrat, + refreshUserContextOnPageReload, + nftContratTokenMetadataMap, + } = React.useContext(UserContext) as UserContextType; + const { enqueueSnackbar } = useSnackbar(); + const [pictureUrl, setPictureUrl] = useState(""); + const [file, setFile] = useState(null); + + const [activeStep, setActiveStep] = React.useState(0); + + const handleNext = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handleStepChange = (step: number) => { + setActiveStep(step); + }; + const validationSchema = yup.object({ + name: yup.string().required("Name is required"), + description: yup.string().required("Description is required"), + symbol: yup.string().required("Symbol is required"), + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), + }); + + const formik = useFormik({ + initialValues: { + name: "", + description: "", + token_id: 0, + symbol: "WINE", + quantity: 1, + } as TZIP21TokenMetadata & { quantity: number }, + validationSchema: validationSchema, + onSubmit: (values) => { + mint(values); + }, + }); + + //open mint drawer if admin + useEffect(() => { + if (storage && storage!.administrators.indexOf(userAddress! as address) < 0) + setFormOpen(false); + else setFormOpen(true); + }, [userAddress]); + + const mint = async ( + newTokenDefinition: TZIP21TokenMetadata & { quantity: number } + ) => { + try { + //IPFS + if (file) { + const formData = new FormData(); + formData.append("file", file); + + const requestHeaders: HeadersInit = new Headers(); + requestHeaders.set( + "pinata_api_key", + `${process.env.REACT_APP_PINATA_API_KEY}` + ); + requestHeaders.set( + "pinata_secret_api_key", + `${process.env.REACT_APP_PINATA_API_SECRET}` + ); + + const resFile = await fetch( + "https://api.pinata.cloud/pinning/pinFileToIPFS", + { + method: "post", + body: formData, + headers: requestHeaders, + } + ); + + const responseJson = await resFile.json(); + console.log("responseJson", responseJson); + + const thumbnailUri = `ipfs://${responseJson.IpfsHash}`; + setPictureUrl( + `https://gateway.pinata.cloud/ipfs/${responseJson.IpfsHash}` + ); + + const op = await nftContrat!.methods + .mint( + new BigNumber(newTokenDefinition.quantity) as nat, + char2Bytes(newTokenDefinition.name!) as bytes, + char2Bytes(newTokenDefinition.description!) as bytes, + char2Bytes(newTokenDefinition.symbol!) as bytes, + char2Bytes(thumbnailUri) as bytes + ) + .send(); + + //close directly the form + setFormOpen(false); + enqueueSnackbar( + "Wine collection is minting ... it will be ready on next block, wait for the confirmation message before minting another collection", + { variant: "info" } + ); + + await op.confirmation(2); + + enqueueSnackbar("Wine collection minted", { variant: "success" }); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + + const [formOpen, setFormOpen] = useState(false); + + const toggleDrawer = + (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { + if ( + event.type === "keydown" && + ((event as React.KeyboardEvent).key === "Tab" || + (event as React.KeyboardEvent).key === "Shift") + ) { + return; + } + setFormOpen(open); + }; + + const isTablet = useMediaQuery("(min-width:600px)"); + + return ( + + {storage ? ( + + ) : ( + "" + )} + + + + + +
+ + Mint a new collection + + + + + + + + + {pictureUrl ? ( + + ) : ( + "" + )} + + + + +
+
+
+ + Mint your wine collection + + {nftContratTokenMetadataMap.size != 0 ? ( + + + {Array.from(nftContratTokenMetadataMap!.entries()).map( + ([token_id, token]) => ( + + + + + + + + {"ID : " + token_id} + {"Symbol : " + token.symbol} + + {"Description : " + token.description} + + + + + ) + )} + + + Next + + + } + backButton={ + + } + /> + + ) : ( + + Sorry, there is not NFT yet, you need to mint bottles first + + )} +
+ ); +} +``` + +### Update in `OffersPage.tsx` + +We introduce the quantity and remove the `token_id` variable. Replace the full file with the following content: + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import SellIcon from "@mui/icons-material/Sell"; + +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + InputAdornment, + Pagination, + TextField, + Tooltip, + Typography, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useEffect, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +const validationSchema = yup.object({ + price: yup + .number() + .required("Price is required") + .positive("ERROR: The number must be greater than 0!"), + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), +}); + +type Offer = { + price: nat; + quantity: nat; +}; + +export default function OffersPage() { + const [selectedTokenId, setSelectedTokenId] = React.useState(0); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + let [ownerOffers, setOwnerOffers] = React.useState(null); + let [ownerBalance, setOwnerBalance] = React.useState(0); + + const { + nftContrat, + nftContratTokenMetadataMap, + userAddress, + storage, + refreshUserContextOnPageReload, + Tezos, + setUserAddress, + setUserBalance, + wallet, + } = React.useContext(UserContext) as UserContextType; + + const { enqueueSnackbar } = useSnackbar(); + + const formik = useFormik({ + initialValues: { + price: 0, + quantity: 1, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedTokenId); + sell(selectedTokenId, values.quantity, values.price); + }, + }); + + const initPage = async () => { + if (storage) { + console.log("context is not empty, init page now"); + + await Promise.all( + storage.owners.map(async (owner) => { + if (owner === userAddress) { + const ownerBalance = await storage.ledger.get( + userAddress as address + ); + setOwnerBalance(ownerBalance.toNumber()); + const ownerOffers = await storage.offers.get( + userAddress as address + ); + if (ownerOffers && ownerOffers.quantity != BigNumber(0)) + setOwnerOffers(ownerOffers!); + + console.log( + "found for " + + owner + + " on token_id " + + 0 + + " with balance " + + ownerBalance + ); + } else { + console.log("skip to next owner"); + } + }) + ); + } else { + console.log("context is empty, wait for parent and retry ..."); + } + }; + + useEffect(() => { + (async () => { + console.log("after a storage changed"); + await initPage(); + })(); + }, [storage]); + + useEffect(() => { + (async () => { + console.log("on Page init"); + await initPage(); + })(); + }, []); + + const sell = async (token_id: number, quantity: number, price: number) => { + try { + const op = await nftContrat?.methods + .sell( + BigNumber(quantity) as nat, + BigNumber(price * 1000000) as nat //to mutez + ) + .send(); + + await op?.confirmation(2); + + enqueueSnackbar( + "Wine collection (token_id=" + + token_id + + ") offer for " + + quantity + + " units at price of " + + price + + " XTZ", + { variant: "success" } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + return ( + + + Sell my bottles + + {ownerBalance != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil(1 / itemPerPage)} + showFirstButton + showLastButton + /> + + + + + {"ID : " + 0} + + {"Description : " + + nftContratTokenMetadataMap.get(0)?.description} + + + } + > + + + } + title={nftContratTokenMetadataMap.get(0)?.name} + /> + + + + + + {"Owned : " + ownerBalance} + + + {ownerOffers + ? "Traded : " + + ownerOffers?.quantity + + " (price : " + + ownerOffers?.price.dividedBy(1000000) + + " Tz/b)" + : ""} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedTokenId(0); + formik.handleSubmit(values); + }} + > + + + + + + ), + }} + /> + +
+ )} +
+
+
+
+ ) : ( + + Sorry, you don't own any bottles, buy or mint some first + + )} +
+ ); +} +``` + +### Update in `WineCataloguePage.tsx` + +We introduce the quantity and remove the `token_id` variable. Replace the full file with the following content: + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; +import { + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + InputAdornment, + Pagination, + TextField, + Tooltip, + Typography, + useMediaQuery, +} from "@mui/material"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +type OfferEntry = [address, Offer]; + +type Offer = { + price: nat; + quantity: nat; +}; + +const validationSchema = yup.object({ + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), +}); + +export default function WineCataloguePage() { + const { + Tezos, + nftContratTokenMetadataMap, + setUserAddress, + setUserBalance, + wallet, + userAddress, + nftContrat, + refreshUserContextOnPageReload, + storage, + } = React.useContext(UserContext) as UserContextType; + const [selectedOfferEntry, setSelectedOfferEntry] = + React.useState(null); + + const formik = useFormik({ + initialValues: { + quantity: 1, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedOfferEntry); + buy(values.quantity, selectedOfferEntry!); + }, + }); + const { enqueueSnackbar } = useSnackbar(); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const buy = async (quantity: number, selectedOfferEntry: OfferEntry) => { + try { + const op = await nftContrat?.methods + .buy(BigNumber(quantity) as nat, selectedOfferEntry[0]) + .send({ + amount: + selectedOfferEntry[1].quantity.toNumber() * + selectedOfferEntry[1].price.toNumber(), + mutez: true, + }); + + await op?.confirmation(2); + + enqueueSnackbar( + "Bought " + + quantity + + " unit of Wine collection (token_id:" + + selectedOfferEntry[0][1] + + ")", + { + variant: "success", + } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + + return ( + + + Wine catalogue + + + {storage?.offers && storage?.offers.size != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil( + Array.from(storage?.offers.entries()).filter(([key, offer]) => + offer.quantity.isGreaterThan(0) + ).length / itemPerPage + )} + showFirstButton + showLastButton + /> + + {Array.from(storage?.offers.entries()) + .filter(([_, offer]) => offer.quantity.isGreaterThan(0)) + .filter((owner, index) => + index >= currentPageIndex * itemPerPage - itemPerPage && + index < currentPageIndex * itemPerPage + ? true + : false + ) + .map(([owner, offer]) => ( + + + {"ID : " + 0} + + {"Description : " + + nftContratTokenMetadataMap.get(0)?.description} + + {"Seller : " + owner} + + } + > + + + } + title={nftContratTokenMetadataMap.get(0)?.name} + /> + + + + + + {"Price : " + + offer.price.dividedBy(1000000) + + " XTZ/bottle"} + + + {"Available units : " + offer.quantity} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedOfferEntry([owner, offer]); + formik.handleSubmit(values); + }} + > + + + + ), + }} + /> + + )} +
+
+ ))} +
+
+ ) : ( + + Sorry, there is not NFT to buy yet, you need to mint or sell bottles + first + + )} +
+ ); +} +``` + +### Let's play + +1. Connect with your wallet and choose `alice` account (or one of the administrators you set on the smart contract earlier). You are redirected to the Administration/mint page as there is no minted NFT yet +2. Create an asset, for example: + +- `name`: Saint Emilion - Franc la Rose +- `symbol`: SEMIL +- `description`: Grand cru 2007 +- `quantity`: 1000 + +3. Click on `Upload an image` and select a bottle picture on your computer +4. Click on the Mint button + +![minting.png](/developers/docs/images/minting_part3.png) + +Your picture will be pushed to IPFS and displayed, then your wallet will ask you to sign the `mint` operation. + +- Confirm operation +- Wait less than 1 minute to get the confirmation notification, the page will be automatically refreshed. + +![minted.png](/developers/docs/images/minted.png) + +Now you can see the `Trading` menu and the `Bottle offers` sub menu + +Click on the sub-menu entry + +You are the owner of this bottle so you can make an offer on it + +- Enter a quantity +- Enter a price offer +- Click on the `SELL` button +- Wait a bit for the confirmation, then once automatically refreshed you have an offer attached to your NFT + +![offer.png](/developers/docs/images/offer.png) + +For buying, + +- Disconnect from your user and connect with another account _(who has enough XTZ to buy at least 1 bottle)_ +- The buyer will see that alice is selling some bottles from the unique collection +- Buy some bottles while clicking on `BUY` button +- Wait for the confirmation, then the offer is updated on the market _(depending how many bottle you bought)_ +- Click on `bottle offers` sub menu +- You are now the owner of some bottles, you can resell a part of it at your own price, etc ... + +![buy.png](/developers/docs/images/buy_part3.png) + +## Conclusion + +You are now able to play with a unique NFT collection from the Ligo library. + +In the next lesson, you will use the last template `multi-asset` that will allow multiple NFT collections on the same contract + +To continue, let's go to [Part 4](/developers/docs/tutorials/build-an-nft-marketplace/part-4) \ No newline at end of file diff --git a/src/pages/tutorials/build-an-nft-marketplace/part-4.md b/src/pages/tutorials/build-an-nft-marketplace/part-4.md new file mode 100644 index 000000000..1849f2a0a --- /dev/null +++ b/src/pages/tutorials/build-an-nft-marketplace/part-4.md @@ -0,0 +1,1493 @@ +--- +id: nft-marketplace-part-4 +title: NFT Marketplace Part 4 +--- + +We finish by using multi asset template. + +- you have an unlimited number of NFT collections +- you have an unlimited quantity of items in each collection + +To resume, you are producing any quantity of wine bottles on `n` collections + +Keep your code from previous training or get the solution [here](https://github.com/marigold-dev/training-nft-3/tree/main/solution) + +> If you clone/fork a repo, rebuild in local + +```bash +npm i +cd ./app +yarn install +cd .. +``` + +## Smart Contract + +Point to the new template changing the first import line of `nft.jsligo` file to + +```ligolang +#import "@ligo/fa/lib/fa2/asset/multi_asset.jsligo" "MULTIASSET" +``` + +It means you will change the namespace from `SINGLEASSET` to `MULTIASSET` everywhere _(like this you are sure to use the correct library)_ + +You will re-introduce the `token_id` as there are several collections now. + +We can remove `totalSupply` and add two extra key sets `owner_token_ids` and `token_ids` + +Change the `storage` definition + +```ligolang +type offer = { + quantity : nat, + price : nat +}; + +type storage = + { + administrators: set
, + offers: map<[address,nat],offer>, //user sells an offer for a token_id + ledger: MULTIASSET.Ledger.t, + metadata: MULTIASSET.Metadata.t, + token_metadata: MULTIASSET.TokenMetadata.t, + operators: MULTIASSET.Operators.t, + owner_token_ids : set<[MULTIASSET.Storage.owner,MULTIASSET.Storage.token_id]>, + token_ids : set + }; +``` + +Update `parameter` type too + +```ligolang +type parameter = + | ["Mint", nat,nat,bytes,bytes,bytes,bytes] //token_id, quantity, name , description ,symbol , bytesipfsUrl + | ["AddAdministrator" , address] + | ["Buy", nat,nat, address] //buy token_id,quantity at a seller offer price + | ["Sell", nat,nat, nat] //sell token_id,quantity at a price + | ["Transfer", MULTIASSET.transfer] + | ["Balance_of", MULTIASSET.balance_of] + | ["Update_operators", MULTIASSET.update_operators]; +``` + +Update `mint` function + +```ligolang +const mint = (token_id : nat, quantity: nat, name : bytes, description : bytes,symbol : bytes, ipfsUrl: bytes, s: storage) : ret => { + + if(quantity <= (0 as nat)) return failwith("0"); + + if(! Set.mem(Tezos.get_sender(), s.administrators)) return failwith("1"); + + const token_info: map = + Map.literal(list([ + ["name", name], + ["description",description], + ["interfaces", (bytes `["TZIP-12"]`)], + ["thumbnailUri", ipfsUrl], + ["symbol",symbol], + ["decimals", (bytes `0`)] + ])) as map; + + + const metadata : bytes = bytes + `{ + "name":"FA2 NFT Marketplace", + "description":"Example of FA2 implementation", + "version":"0.0.1", + "license":{"name":"MIT"}, + "authors":["Marigold"], + "homepage":"https://marigold.dev", + "source":{ + "tools":["Ligo"], + "location":"https://github.com/ligolang/contract-catalogue/tree/main/lib/fa2"}, + "interfaces":["TZIP-012"], + "errors": [], + "views": [] + }` ; + + return [list([]) as list, + {...s, + ledger: Big_map.add([Tezos.get_sender(),token_id],quantity as nat,s.ledger) as MULTIASSET.Ledger.t, + metadata : Big_map.literal(list([["", bytes `tezos-storage:data`],["data", metadata]])), + token_metadata: Big_map.add(token_id, {token_id: token_id,token_info:token_info},s.token_metadata), + operators: Big_map.empty as MULTIASSET.Operators.t, + owner_token_ids : Set.add([Tezos.get_sender(),token_id],s.owner_token_ids), + token_ids: Set.add(token_id, s.token_ids)}]}; +``` + +You also need to update `sell` function + +```ligolang +const sell = (token_id : nat, quantity: nat, price: nat, s: storage) : ret => { + + //check balance of seller + const sellerBalance = MULTIASSET.Ledger.get_for_user(s.ledger,Tezos.get_source(),token_id); + if(quantity > sellerBalance) return failwith("2"); + + //need to allow the contract itself to be an operator on behalf of the seller + const newOperators = MULTIASSET.Operators.add_operator(s.operators,Tezos.get_source(),Tezos.get_self_address(),token_id); + + //DECISION CHOICE: if offer already exists, we just override it + return [list([]) as list,{...s,offers:Map.add([Tezos.get_source(),token_id],{quantity : quantity, price : price},s.offers),operators:newOperators}]; +}; +``` + +Same for the `buy` function + +```ligolang +const buy = (token_id : nat, quantity: nat, seller: address, s: storage) : ret => { + + //search for the offer + return match( Map.find_opt([seller,token_id],s.offers) , { + None : () => failwith("3"), + Some : (offer : offer) => { + + //check if amount have been paid enough + if(Tezos.get_amount() < offer.price * (1 as mutez)) return failwith("5"); + + // prepare transfer of XTZ to seller + const op = Tezos.transaction(unit,offer.price * (1 as mutez),Tezos.get_contract_with_error(seller,"6")); + + //transfer tokens from seller to buyer + let ledger = MULTIASSET.Ledger.decrease_token_amount_for_user(s.ledger,seller,token_id,quantity); + ledger = MULTIASSET.Ledger.increase_token_amount_for_user(ledger,Tezos.get_source(),token_id,quantity); + + //update new offer + const newOffer = {...offer,quantity : abs(offer.quantity - quantity)}; + + return [list([op]) as list, {...s, offers : Map.update([seller,token_id],Some(newOffer),s.offers), ledger : ledger, owner_token_ids : Set.add([Tezos.get_source(),token_id],s.owner_token_ids) }]; + } + }); +}; +``` + +and finally the `main` function + +```ligolang +const main = ([p, s]: [parameter, storage]): ret => + match( + p, + { + Mint: (p: [nat, nat, bytes, bytes, bytes, bytes]) => + mint(p[0], p[1], p[2], p[3], p[4], p[5], s), + AddAdministrator: (p: address) => { + if (Set.mem(Tezos.get_sender(), s.administrators)) { + return [ + list([]), + { ...s, administrators: Set.add(p, s.administrators) } + ] + } else { + return failwith("1") + } + }, + Buy: (p: [nat, nat, address]) => buy(p[0], p[1], p[2], s), + Sell: (p: [nat, nat, nat]) => sell(p[0], p[1], p[2], s), + Transfer: (p: MULTIASSET.transfer) => { + const ret2: [list, MULTIASSET.storage] = + MULTIASSET.transfer( + [ + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + owner_token_ids: s.owner_token_ids, + token_ids: s.token_ids + } + ] + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + owner_token_ids: ret2[1].owner_token_ids, + token_ids: ret2[1].token_ids + } + ] + }, + Balance_of: (p: MULTIASSET.balance_of) => { + const ret2: [list, MULTIASSET.storage] = + MULTIASSET.balance_of( + [ + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + owner_token_ids: s.owner_token_ids, + token_ids: s.token_ids + } + ] + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + owner_token_ids: ret2[1].owner_token_ids, + token_ids: ret2[1].token_ids + } + ] + }, + Update_operators: (p: MULTIASSET.update_operators) => { + const ret2: [list, MULTIASSET.storage] = + MULTIASSET.update_ops( + [ + p, + { + ledger: s.ledger, + metadata: s.metadata, + token_metadata: s.token_metadata, + operators: s.operators, + owner_token_ids: s.owner_token_ids, + token_ids: s.token_ids + } + ] + ); + return [ + ret2[0], + { + ...s, + ledger: ret2[1].ledger, + metadata: ret2[1].metadata, + token_metadata: ret2[1].token_metadata, + operators: ret2[1].operators, + owner_token_ids: ret2[1].owner_token_ids, + token_ids: ret2[1].token_ids + } + ] + } + } + ); +``` + +Change the initial storage to + +```ligolang +#include "nft.jsligo" +const default_storage = +{ + administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" as address])) as set
, + offers: Map.empty as map<[address,nat],offer>, + ledger: Big_map.empty as MULTIASSET.Ledger.t, + metadata: Big_map.empty as MULTIASSET.Metadata.t, + token_metadata: Big_map.empty as MULTIASSET.TokenMetadata.t, + operators: Big_map.empty as MULTIASSET.Operators.t, + owner_token_ids : Set.empty as set<[MULTIASSET.Storage.owner,MULTIASSET.Storage.token_id]>, + token_ids : Set.empty as set + } +; +``` + +Compile again and deploy to ghostnet + +```bash +TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo +taq deploy nft.tz -e "testing" +``` + +```logs +┌──────────┬──────────────────────────────────────┬───────┬──────────────────┬────────────────────────────────┐ +│ Contract │ Address │ Alias │ Balance In Mutez │ Destination │ +├──────────┼──────────────────────────────────────┼───────┼──────────────────┼────────────────────────────────┤ +│ nft.tz │ KT1QfMdyRq56xLBiofFTjLhkq5VCdj9PwC25 │ nft │ 0 │ https://ghostnet.ecadinfra.com │ +└──────────┴──────────────────────────────────────┴───────┴──────────────────┴────────────────────────────────┘ +``` + +** Hooray! We have finished the smart contract _(backend)_ ** + +## NFT Marketplace front + +Generate Typescript classes and go to the frontend to run the server + +```bash +taq generate types ./app/src +cd ./app +yarn install +yarn run start +``` + +## Update in `App.tsx` + +We forget about `token_id == 0` and fetch back all tokens. +Replace the function `refreshUserContextOnPageReload` with the following content + +```typescript +const refreshUserContextOnPageReload = async () => { + console.log("refreshUserContext"); + //CONTRACT + try { + let c = await Tezos.contract.at(nftContractAddress, tzip12); + console.log("nftContractAddress", nftContractAddress); + + let nftContrat: NftWalletType = await Tezos.wallet.at( + nftContractAddress + ); + const storage = (await nftContrat.storage()) as Storage; + await Promise.all( + storage.token_ids.map(async (token_id: nat) => { + let tokenMetadata: TZIP21TokenMetadata = (await c + .tzip12() + .getTokenMetadata(token_id.toNumber())) as TZIP21TokenMetadata; + nftContratTokenMetadataMap.set(token_id.toNumber(), tokenMetadata); + }) + ); + setNftContratTokenMetadataMap(new Map(nftContratTokenMetadataMap)); //new Map to force refresh + setNftContrat(nftContrat); + setStorage(storage); + } catch (error) { + console.log("error refreshing nft contract: ", error); + } + + //USER + const activeAccount = await wallet.client.getActiveAccount(); + if (activeAccount) { + setUserAddress(activeAccount.address); + const balance = await Tezos.tz.getBalance(activeAccount.address); + setUserBalance(balance.toNumber()); + } + + console.log("refreshUserContext ended."); +}; +``` + +## Update in `MintPage.tsx` + +Just update the `mint` call and add the missing quantity, and add back the `token_id` counter incrementer + +```typescript +import { + AddCircleOutlined, + Close, + KeyboardArrowLeft, + KeyboardArrowRight, +} from "@mui/icons-material"; +import OpenWithIcon from "@mui/icons-material/OpenWith"; +import { + Box, + Button, + CardHeader, + CardMedia, + MobileStepper, + Stack, + SwipeableDrawer, + TextField, + Toolbar, + useMediaQuery, +} from "@mui/material"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { char2Bytes } from "@taquito/utils"; +import { BigNumber } from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { useEffect, useState } from "react"; +import SwipeableViews from "react-swipeable-views"; +import * as yup from "yup"; +import { TZIP21TokenMetadata, UserContext, UserContextType } from "./App"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, bytes, nat } from "./type-aliases"; + +export default function MintPage() { + const { + userAddress, + nftContrat, + refreshUserContextOnPageReload, + nftContratTokenMetadataMap, + storage, + } = React.useContext(UserContext) as UserContextType; + const { enqueueSnackbar } = useSnackbar(); + const [pictureUrl, setPictureUrl] = useState(""); + const [file, setFile] = useState(null); + + const [activeStep, setActiveStep] = React.useState(0); + + const handleNext = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handleStepChange = (step: number) => { + setActiveStep(step); + }; + + const validationSchema = yup.object({ + name: yup.string().required("Name is required"), + description: yup.string().required("Description is required"), + symbol: yup.string().required("Symbol is required"), + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), + }); + + const formik = useFormik({ + initialValues: { + name: "", + description: "", + token_id: 0, + symbol: "WINE", + quantity: 1, + } as TZIP21TokenMetadata & { quantity: number }, + validationSchema: validationSchema, + onSubmit: (values) => { + mint(values); + }, + }); + + //open mint drawer if admin + useEffect(() => { + if (storage && storage!.administrators.indexOf(userAddress! as address) < 0) + setFormOpen(false); + else setFormOpen(true); + }, [userAddress]); + + useEffect(() => { + (async () => { + if (storage && storage.token_ids.length > 0) { + formik.setFieldValue("token_id", storage?.token_ids.length); + } + })(); + }, [storage?.token_ids]); + + const mint = async ( + newTokenDefinition: TZIP21TokenMetadata & { quantity: number } + ) => { + try { + //IPFS + if (file) { + const formData = new FormData(); + formData.append("file", file); + + const requestHeaders: HeadersInit = new Headers(); + requestHeaders.set( + "pinata_api_key", + `${process.env.REACT_APP_PINATA_API_KEY}` + ); + requestHeaders.set( + "pinata_secret_api_key", + `${process.env.REACT_APP_PINATA_API_SECRET}` + ); + + const resFile = await fetch( + "https://api.pinata.cloud/pinning/pinFileToIPFS", + { + method: "post", + body: formData, + headers: requestHeaders, + } + ); + + const responseJson = await resFile.json(); + console.log("responseJson", responseJson); + + const thumbnailUri = `ipfs://${responseJson.IpfsHash}`; + setPictureUrl( + `https://gateway.pinata.cloud/ipfs/${responseJson.IpfsHash}` + ); + + const op = await nftContrat!.methods + .mint( + new BigNumber(newTokenDefinition.token_id) as nat, + new BigNumber(newTokenDefinition.quantity) as nat, + char2Bytes(newTokenDefinition.name!) as bytes, + char2Bytes(newTokenDefinition.description!) as bytes, + char2Bytes(newTokenDefinition.symbol!) as bytes, + char2Bytes(thumbnailUri) as bytes + ) + .send(); + + //close directly the form + setFormOpen(false); + enqueueSnackbar( + "Wine collection is minting ... it will be ready on next block, wait for the confirmation message before minting another collection", + { variant: "info" } + ); + + await op.confirmation(2); + + enqueueSnackbar("Wine collection minted", { variant: "success" }); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + + const [formOpen, setFormOpen] = useState(false); + + const toggleDrawer = + (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { + if ( + event.type === "keydown" && + ((event as React.KeyboardEvent).key === "Tab" || + (event as React.KeyboardEvent).key === "Shift") + ) { + return; + } + setFormOpen(open); + }; + + const isTablet = useMediaQuery("(min-width:600px)"); + + return ( + + {storage ? ( + + ) : ( + "" + )} + + + + + +
+ + Mint a new collection + + + + + + + + + {pictureUrl ? ( + + ) : ( + "" + )} + + + + +
+
+
+ + Mint your wine collection + + {nftContratTokenMetadataMap.size != 0 ? ( + + + {Array.from(nftContratTokenMetadataMap!.entries()).map( + ([token_id, token]) => ( + + + + + + + + {"ID : " + token_id} + {"Symbol : " + token.symbol} + + {"Description : " + token.description} + + + + + ) + )} + + + Next + + + } + backButton={ + + } + /> + + ) : ( + + Sorry, there is not NFT yet, you need to mint bottles first + + )} +
+ ); +} +``` + +## Update in `OffersPage.tsx` + +Copy the content below, and paste it to `OffersPage.tsx` + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import SellIcon from "@mui/icons-material/Sell"; + +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + InputAdornment, + Pagination, + TextField, + Tooltip, + Typography, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useEffect, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +const validationSchema = yup.object({ + price: yup + .number() + .required("Price is required") + .positive("ERROR: The number must be greater than 0!"), + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), +}); + +type Offer = { + price: nat; + quantity: nat; +}; + +export default function OffersPage() { + const [selectedTokenId, setSelectedTokenId] = React.useState(0); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + let [offersTokenIDMap, setOffersTokenIDMap] = React.useState>( + new Map() + ); + let [ledgerTokenIDMap, setLedgerTokenIDMap] = React.useState>( + new Map() + ); + + const { + nftContrat, + nftContratTokenMetadataMap, + userAddress, + storage, + refreshUserContextOnPageReload, + Tezos, + setUserAddress, + setUserBalance, + wallet, + } = React.useContext(UserContext) as UserContextType; + + const { enqueueSnackbar } = useSnackbar(); + + const formik = useFormik({ + initialValues: { + price: 0, + quantity: 1, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedTokenId); + sell(selectedTokenId, values.quantity, values.price); + }, + }); + + const initPage = async () => { + if (storage) { + console.log("context is not empty, init page now"); + ledgerTokenIDMap = new Map(); + offersTokenIDMap = new Map(); + + await Promise.all( + storage.owner_token_ids.map(async (element) => { + if (element[0] === userAddress) { + const ownerBalance = await storage.ledger.get({ + 0: userAddress as address, + 1: element[1], + }); + if (ownerBalance != BigNumber(0)) + ledgerTokenIDMap.set(element[1], ownerBalance); + const ownerOffers = await storage.offers.get({ + 0: userAddress as address, + 1: element[1], + }); + if (ownerOffers && ownerOffers.quantity != BigNumber(0)) + offersTokenIDMap.set(element[1], ownerOffers); + + console.log( + "found for " + + element[0] + + " on token_id " + + element[1] + + " with balance " + + ownerBalance + ); + } else { + console.log("skip to next owner"); + } + }) + ); + setLedgerTokenIDMap(new Map(ledgerTokenIDMap)); //force refresh + setOffersTokenIDMap(new Map(offersTokenIDMap)); //force refresh + + console.log("ledgerTokenIDMap", ledgerTokenIDMap); + } else { + console.log("context is empty, wait for parent and retry ..."); + } + }; + + useEffect(() => { + (async () => { + console.log("after a storage changed"); + await initPage(); + })(); + }, [storage]); + + useEffect(() => { + (async () => { + console.log("on Page init"); + await initPage(); + })(); + }, []); + + const sell = async (token_id: number, quantity: number, price: number) => { + try { + const op = await nftContrat?.methods + .sell( + BigNumber(token_id) as nat, + BigNumber(quantity) as nat, + BigNumber(price * 1000000) as nat //to mutez + ) + .send(); + + await op?.confirmation(2); + + enqueueSnackbar( + "Wine collection (token_id=" + + token_id + + ") offer for " + + quantity + + " units at price of " + + price + + " XTZ", + { variant: "success" } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + + return ( + + + Sell my bottles + + {ledgerTokenIDMap && ledgerTokenIDMap.size != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil( + Array.from(ledgerTokenIDMap.entries()).length / itemPerPage + )} + showFirstButton + showLastButton + /> + + + {Array.from(ledgerTokenIDMap.entries()) + .filter((_, index) => + index >= currentPageIndex * itemPerPage - itemPerPage && + index < currentPageIndex * itemPerPage + ? true + : false + ) + .map(([token_id, balance]) => ( + + + + {" "} + {"ID : " + token_id.toString()}{" "} + + + {"Description : " + + nftContratTokenMetadataMap.get( + token_id.toNumber() + )?.description} + + + } + > + + + } + title={ + nftContratTokenMetadataMap.get(token_id.toNumber())?.name + } + /> + + + + + + {"Owned : " + balance.toNumber()} + + + {offersTokenIDMap.get(token_id) + ? "Traded : " + + offersTokenIDMap.get(token_id)?.quantity + + " (price : " + + offersTokenIDMap + .get(token_id) + ?.price.dividedBy(1000000) + + " Tz/b)" + : ""} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedTokenId(token_id.toNumber()); + formik.handleSubmit(values); + }} + > + + + + + + ), + }} + /> + +
+ )} +
+
+ ))}{" "} +
+
+ ) : ( + + Sorry, you don't own any bottles, buy or mint some first + + )} +
+ ); +} +``` + +## Update in `WineCataloguePage.tsx` + +Copy the content below, and paste it to `WineCataloguePage.tsx` + +```typescript +import { InfoOutlined } from "@mui/icons-material"; +import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + CardMedia, + ImageList, + InputAdornment, + Pagination, + TextField, + Tooltip, + useMediaQuery, +} from "@mui/material"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +import BigNumber from "bignumber.js"; +import { useFormik } from "formik"; +import { useSnackbar } from "notistack"; +import React, { Fragment, useState } from "react"; +import * as yup from "yup"; +import { UserContext, UserContextType } from "./App"; +import ConnectButton from "./ConnectWallet"; +import { TransactionInvalidBeaconError } from "./TransactionInvalidBeaconError"; +import { address, nat } from "./type-aliases"; + +const itemPerPage: number = 6; + +type OfferEntry = [{ 0: address; 1: nat }, Offer]; + +type Offer = { + price: nat; + quantity: nat; +}; + +const validationSchema = yup.object({ + quantity: yup + .number() + .required("Quantity is required") + .positive("ERROR: The number must be greater than 0!"), +}); + +export default function WineCataloguePage() { + const { + Tezos, + nftContratTokenMetadataMap, + setUserAddress, + setUserBalance, + wallet, + userAddress, + nftContrat, + refreshUserContextOnPageReload, + storage, + } = React.useContext(UserContext) as UserContextType; + const [selectedOfferEntry, setSelectedOfferEntry] = + React.useState(null); + + const formik = useFormik({ + initialValues: { + quantity: 1, + }, + validationSchema: validationSchema, + onSubmit: (values) => { + console.log("onSubmit: (values)", values, selectedOfferEntry); + buy(values.quantity, selectedOfferEntry!); + }, + }); + const { enqueueSnackbar } = useSnackbar(); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const buy = async (quantity: number, selectedOfferEntry: OfferEntry) => { + try { + const op = await nftContrat?.methods + .buy( + selectedOfferEntry[0][1], + BigNumber(quantity) as nat, + selectedOfferEntry[0][0] + ) + .send({ + amount: quantity * selectedOfferEntry[1].price.toNumber(), + mutez: true, + }); + + await op?.confirmation(2); + + enqueueSnackbar( + "Bought " + + quantity + + " unit of Wine collection (token_id:" + + selectedOfferEntry[0][1] + + ")", + { + variant: "success", + } + ); + + refreshUserContextOnPageReload(); //force all app to refresh the context + } catch (error) { + console.table(`Error: ${JSON.stringify(error, null, 2)}`); + let tibe: TransactionInvalidBeaconError = + new TransactionInvalidBeaconError(error); + enqueueSnackbar(tibe.data_message, { + variant: "error", + autoHideDuration: 10000, + }); + } + }; + const isDesktop = useMediaQuery("(min-width:1100px)"); + const isTablet = useMediaQuery("(min-width:600px)"); + return ( + + + Wine catalogue + + + {storage?.offers && storage?.offers.size != 0 ? ( + + setCurrentPageIndex(value)} + count={Math.ceil( + Array.from(storage?.offers.entries()).filter(([key, offer]) => + offer.quantity.isGreaterThan(0) + ).length / itemPerPage + )} + showFirstButton + showLastButton + /> + + {Array.from(storage?.offers.entries()) + .filter(([key, offer]) => offer.quantity.isGreaterThan(0)) + .filter((_, index) => + index >= currentPageIndex * itemPerPage - itemPerPage && + index < currentPageIndex * itemPerPage + ? true + : false + ) + .map(([key, offer]) => ( + + + + {" "} + {"ID : " + key[1].toString()}{" "} + + + {"Description : " + + nftContratTokenMetadataMap.get( + key[1].toNumber() + )?.description} + + {"Seller : " + key[0]} + + } + > + + + } + title={ + nftContratTokenMetadataMap.get(key[1].toNumber())?.name + } + /> + + + + + + {" "} + {"Price : " + + offer.price.dividedBy(1000000) + + " XTZ/bottle"} + + + {"Available units : " + offer.quantity} + + + + + + {!userAddress ? ( + + + + ) : ( +
{ + setSelectedOfferEntry([key, offer]); + formik.handleSubmit(values); + }} + > + + + + ), + }} + /> + + )} +
+
+ ))} +
+
+ ) : ( + + Sorry, there is not NFT to buy yet, you need to mint or sell bottles + first + + )} +
+ ); +} +``` + +## Let's play + +1. Connect with your wallet and choose `alice` account _(or one of the administrators you set on the smart contract earlier)_. You are redirected to the Administration/mint page as there is no NFT minted yet +2. Create an asset, for example : + +- `name`: Saint Emilion - Franc la Rose +- `symbol`: SEMIL +- `description`: Grand cru 2007 +- `quantity`: 1000 + +3. Click on `Upload an image` and select a bottle picture on your computer +4. Click on the Mint button + +![minting_part4.png](/developers/docs/images/minting_part4.png) + +Your picture will be pushed to IPFS and will be displayed, then your wallet will ask you to sign the `mint` operation. + +- Confirm operation +- Wait less than 1 minute to get the confirmation notification, the page will be automatically refreshed + +![minted_part4.png](/developers/docs/images/minted_part4.png) + +Now you can see the `Trading` menu and the `Bottle offers` sub-menu + +Click on the sub-menu entry + +You are the owner of this bottle so you can create an offer on it + +- Enter a quantity +- Enter a price offer +- Click on `SELL` button +- Wait a bit for the confirmation, then once automatically refreshed you have an offer attached to your NFT! + +![sell_part4.png](/developers/docs/images/sell_part4.png) + +For buying, + +- Disconnect from your user and connect with another account _(who has enough XTZ to buy at least 1 bottle)_ +- The buyer will see that Alice is selling some bottles from the unique collection +- Buy some bottles while clicking on the `BUY` button +- Wait for the confirmation, then the offer is updated on the market _(depending on how many bottles you bought)_ +- Click on the `bottle offers` submenu +- You are now the owner of some bottles, you can resell a part of it at your own price, etc ... + +![buy_part4.png](/developers/docs/images/buy_part4.png) + +To add more collections, go to the Mint page and repeat the process. + +# Conclusion + +You are able to use any NFT template from the Ligo library. + +Congratulations! \ No newline at end of file diff --git a/src/pages/tutorials/build-your-first-app/adding-removing-liquidity.md b/src/pages/tutorials/build-your-first-app/adding-removing-liquidity.md new file mode 100644 index 000000000..46b5626f2 --- /dev/null +++ b/src/pages/tutorials/build-your-first-app/adding-removing-liquidity.md @@ -0,0 +1,486 @@ +--- +id: adding-removing-liquidity +title: Adding Liquidity +authors: Claude Barde +--- + +## Adding Liquidity + +This one is going to be a big one, but a little less involved than swapping tokens! + +The most complex part about adding liquidity to the Liquidity Baking contract is to get the amounts of tokens right! After that, it will be a walk in the park. + +First, let's understand what we are doing here: the LB DEX gives you the ability to provide a pair of tokens (only 2 choices here, XTZ and tzBTC) as liquidity to enable the swapping feature. In exchange, you get SIRS tokens to represent your investment. These tokens increase in value over time, so if you wait long enough, you can make a profit when you remove your liquidity, which will be explained in the next chapter. + +The interface here is going to look a lot like the interface for swapping, with some key differences: + +![AddLiquidity UI](/developers/docs/images/build-your-first-dapp/add-liquidity-ui.png "Add liquidity UI") + +Like before, we have 2 input fields, but this time, there is no middle button to switch between the 2 tokens and both inputs are editable. + +When inputting a number in one of the fields, the dapp must calculate the corresponding amount of the other token, as well as the expected amount in SIRS that will be received. + +Now, let's see how all of that is done! + +### Converting the input + +When the user is going to input a number in one of the fields, the input will dispatch a new event to the interface component with the name of the token involved and the amount that was input. This data will be read by the `saveInput` function: + +```typescript= +const saveInput = ev => { + const { token, val }: { token: token; val: number | null } = ev.detail; + ... +} +``` + +Then, we will introduce a condition based on the token because the calculations will be different to convert an amount of XTZ into tzBTC and vice-versa. Let's start with XTZ: + +```typescript= +if (token === "XTZ" && val && val > 0) { + inputXtz = val.toString(); + let tzbtcAmount = addLiquidityTokenIn({ + xtzIn: val * 10 ** 6, + xtzPool: $store.dexInfo.xtzPool, + tokenPool: $store.dexInfo.tokenPool + }); + if (tzbtcAmount) { + inputTzbtc = tzbtcAmount.dividedBy(10 ** 8).toPrecision(6); + } else { + inputTzbtc = ""; + } + ... +} +``` + +The condition also includes a check for the value, as there is no need to process it if the value is `null` or `0`. + +The value is cast to a string and stored in the `inputXtz` variable to be used later. The corresponding amount of tzBTC is calculated with the `addLiquidityTokenIn` function, another one of those useful functions to calculate different token amounts for the LB DEX, here it is for reference: + +```typescript= +const addLiquidityTokenIn = (p: { + xtzIn: BigNumber | number; + xtzPool: BigNumber | number; + tokenPool: BigNumber | number; +}): BigNumber | null => { + const { xtzIn, xtzPool, tokenPool } = p; + let xtzIn_ = new BigNumber(0); + let xtzPool_ = new BigNumber(0); + let tokenPool_ = new BigNumber(0); + try { + xtzIn_ = new BigNumber(xtzIn); + xtzPool_ = creditSubsidy(xtzPool); + tokenPool_ = new BigNumber(tokenPool); + } catch (err) { + return null; + } + if ( + xtzIn_.isGreaterThan(0) && + xtzPool_.isGreaterThan(0) && + tokenPool_.isGreaterThan(0) + ) { + return ceilingDiv(xtzIn_.times(tokenPool_), xtzPool_); + } else { + return null; + } +}; +``` + +We check the output of `addLiquidityTokenIn` and we update the `inputTzbtc` variable. + +If the user inputs an amount in tzBTC, the steps will be very similar to calculate the corresponding amount in XTZ: + +```typescript= +else if (token === "tzBTC" && val && val > 0) { + inputTzbtc = val.toString(); + let xtzAmount = tokenToXtzXtzOutput({ + tokenIn: val * 10 ** 8, + xtzPool: $store.dexInfo.xtzPool, + tokenPool: $store.dexInfo.tokenPool + }); + if (xtzAmount) { + inputXtz = xtzAmount.dividedBy(10 ** 6).toPrecision(8); + + ... + } else { + inputXtz = ""; + } +} +``` + +We also need to check that the provided value is correct, after what we use the `tokenToXtzXtzOutput` function to get the corresponding amount of XTZ to create a valid pair and provide liquidity: + +```typescript= +const tokenToXtzXtzOutput = (p: { + tokenIn: BigNumber | number; + xtzPool: BigNumber | number; + tokenPool: BigNumber | number; +}): BigNumber | null => { + const { tokenIn, xtzPool: _xtzPool, tokenPool } = p; + let xtzPool = creditSubsidy(_xtzPool); + let tokenIn_ = new BigNumber(0); + let xtzPool_ = new BigNumber(0); + let tokenPool_ = new BigNumber(0); + try { + tokenIn_ = new BigNumber(tokenIn); + xtzPool_ = new BigNumber(xtzPool); + tokenPool_ = new BigNumber(tokenPool); + } catch (err) { + return null; + } + if ( + tokenIn_.isGreaterThan(0) && + xtzPool_.isGreaterThan(0) && + tokenPool_.isGreaterThan(0) + ) { + let numerator = new BigNumber(tokenIn) + .times(new BigNumber(xtzPool)) + .times(new BigNumber(998001)); + let denominator = new BigNumber(tokenPool) + .times(new BigNumber(1000000)) + .plus(new BigNumber(tokenIn).times(new BigNumber(999000))); + return numerator.dividedBy(denominator); + } else { + return null; + } +}; +``` + +Once this is calculated, we store the result in the `inputXtz` variable for later use. + +### Calculating the expected amount of SIRS + +Now, we have to calculate the corresponding amount of SIRS that will be created if `inputXtz` and `inputTzbtc` are provided as parameters to add liquidity. The `addLiquidityLiquidityCreated` function does all the hard work for us: + +```typescript= +const addLiquidityLiquidityCreated = (p: { + xtzIn: BigNumber | number; + xtzPool: BigNumber | number; + totalLiquidity: BigNumber | number; +}): BigNumber | null => { + const { xtzIn, xtzPool, totalLiquidity } = p; + let xtzIn_ = new BigNumber(0); + let xtzPool_ = new BigNumber(0); + let totalLiquidity_ = new BigNumber(0); + try { + xtzIn_ = new BigNumber(xtzIn); + xtzPool_ = new BigNumber(xtzPool); + totalLiquidity_ = new BigNumber(totalLiquidity); + } catch (err) { + return null; + } + xtzPool_ = creditSubsidy(xtzPool_); + + if (xtzIn_.isGreaterThan(0) && xtzPool_.isGreaterThan(0)) { + if (totalLiquidity_.isEqualTo(0)) { + return new BigNumber(xtzIn) + .times(new BigNumber(totalLiquidity)) + .dividedBy(new BigNumber(xtzPool)); + } else if (totalLiquidity_.isGreaterThan(0)) { + return new BigNumber(xtzIn) + .times(new BigNumber(totalLiquidity)) + .dividedBy(new BigNumber(xtzPool)); + } + + return null; + } else { + return null; + } +}; +``` + +This function takes 3 parameters: + +1. the amount of XTZ you want to add as liquidity +2. the current state of the XTZ pool +3. the total amount of liquidity available in the contract (i.e. the SIRS tokens) + +It will output the amount of SIRS created after the transaction. This amount is stored in the `sirsOutput` variable to be displayed in the interface. + +### Sending tokens + +After we calculated all the values we need to add liquidity to the Liquidity Baking contract, it's time to forge the transaction! + +```typescript= +const addLiquidity = async () => { + try { + if (inputXtz && inputTzbtc && sirsOutput) { + addLiquidityStatus = TxStatus.Loading; + store.updateToast( + true, + "Adding liquidity, waiting for confirmation..." + ); + + const tzbtcForLiquidity = Math.floor( + +inputTzbtc * 10 ** tzBTC.decimals + ); + + const lbContract = await $store.Tezos.wallet.at(dexAddress); + const tzBtcContract = await $store.Tezos.wallet.at(tzbtcAddress); + ... + +} +``` + +First, we check that the 3 values we need, the amounts of XTZ, tzBTC, and SIRS are available. If it is the case, we update the UI by switching the `addLiquidityStatus` variable to `TxStatus.Loading` and displaying a simple toast with a message. + +After that, we convert the amount of tzBTC we got into its "real" value, i.e. the value without decimal points as stored in its contract. + +Then, we create the `ContractAbstraction` for the LB DEX and the `ContractAbstraction` for the tzBTC contract, as we will interact with both. + +> _Note: remember, every time your users want to use tzBTC with the LB DEX, the amount of tokens that will be used needs to be approved at the tzBTC contract level, which requires 3 different operations._ + +At this point, you may have guessed that we have to create a batched transaction, but let's do it in a different way from the previous chapter, so you can choose the way you prefer: + +```typescript= +const batch = $store.Tezos.wallet.batch([ + { + kind: OpKind.TRANSACTION, + ...tzBtcContract.methods.approve(dexAddress, 0).toTransferParams() + }, + { + kind: OpKind.TRANSACTION, + ...tzBtcContract.methods + .approve(dexAddress, tzbtcForLiquidity) + .toTransferParams() + }, + { + kind: OpKind.TRANSACTION, + ...lbContract.methodsObject + .addLiquidity({ + owner: $store.userAddress, + minLqtMinted: sirsOutput, + maxTokensDeposited: tzbtcForLiquidity, + deadline: calcDeadline() + }) + .toTransferParams(), + amount: +inputXtz + }, + { + kind: OpKind.TRANSACTION, + ...tzBtcContract.methods.approve(dexAddress, 0).toTransferParams() + } +]); + +const batchOp = await batch.send(); +await batchOp.confirmation(); +``` + +In the previous chapter, the batched transaction was created using the `withContractCall` method available on the `batch` method. Here, we will actually pass a parameter to the `batch()` method, an array containing multiple objects that each represent an operation. + +The first operation: + +```typescript= +{ +kind: OpKind.TRANSACTION, +...tzBtcContract.methods.approve(dexAddress, 0).toTransferParams() +} +``` + +is the transaction required to set the amount of approved tzBTC for the LB DEX to zero. + +The second operation: + +```typescript= +{ +kind: OpKind.TRANSACTION, +...tzBtcContract.methods + .approve(dexAddress, tzbtcForLiquidity) + .toTransferParams() +} +``` + +sets the amount of approved tzBTC for the LB DEX contract. + +The third operation: + +```typescript= +{ + kind: OpKind.TRANSACTION, + ...lbContract + .methodsObject + .addLiquidity({ + owner: $store.userAddress, + minLqtMinted: sirsOutput, + maxTokensDeposited: tzbtcForLiquidity, + deadline: calcDeadline() + }) + .toTransferParams(), + amount: +inputXtz +} +``` + +is the actual `addLiquidity` operation to provide the pair of tokens to the contract and receive SIRS tokens in exchange. The entrypoint expects 4 parameters (represented here as an object thanks to the `methodsObject` method): + +1. the address of the account that will receive the SIRS tokens +2. the minimum amount of SIRS tokens expected to be received +3. the amount of tzBTC deposited +4. the deadline + +> _Note: look how the attached amount of tez is passed to the operation as the last property of the operation object. It is important to put it after `.toTransferParams()` or it would be overwritten with the default amount of tez, which is zero._ + +The fourth operation: + +```typescript= +{ + kind: OpKind.TRANSACTION, + ...tzBtcContract.methods.approve(dexAddress, 0).toTransferParams() +} +``` + +resets the allowed amount of tzBTC to be used by the LB DEX to zero. + +Then, just like any other transaction forged through Taquito, you call `.send()` and `.confirmation()` on the operation object to wait for one confirmation. + +Once the transaction is confirmed, you clear the UI before fetching the new balances of XTZ, tzBTC, and SIRS. + +If the transaction failed, you update the UI and provide visual feedback to the users: + +```typescript= +addLiquidityStatus = TxStatus.Error; +store.updateToast(true, "An error has occurred"); +``` + +After all these steps, you can reset the interface to its previous state, maybe the user wants to add more liquidity! + +```typescript= +setTimeout(() => { + addLiquidityStatus = TxStatus.NoTransaction; + store.showToast(false); +}, 3000); +``` + +And that's it! Your users now have the ability to add liquidity to the Liquidity Baking DEX and invest their XTZ and tzBTC. + + + +Removing liquidity from the Liquidity Baking contract is arguably the easiest of all the tasks accomplished by our interface. The interface only needs one input to receive the amount of SIRS that the user wants to unwrap to get XTZ and tzBTC. + +![RemoveLiquidity UI](/developers/docs/images/build-your-first-dapp/remove-liquidity-ui.png "Remove liquidity UI") + +The dapp will then calculate the corresponding amount of XTZ and tzBTC expected to be received for the amount of SIRS in the input field. + +In the `lbUtils.ts` file, you will find the `removeLiquidityXtzTzbtcOut` function to calculate these amounts: + +```typescript= +const outputRes = removeLiquidityXtzTzbtcOut({ + liquidityBurned: val, + totalLiquidity: $store.dexInfo.lqtTotal.toNumber(), + xtzPool: $store.dexInfo.xtzPool.toNumber(), + tokenPool: $store.dexInfo.tokenPool.toNumber() + }); + if (outputRes) { + const { xtzOut, tzbtcOut } = outputRes; + xtzOutput = xtzOut + .decimalPlaces(0, 1) + .dividedBy(10 ** 6) + .decimalPlaces(6) + .toNumber(); + tzbtcOutput = tzbtcOut + .decimalPlaces(0, 1) + .dividedBy(10 ** 8) + .decimalPlaces(8) + .toNumber(); + } +``` + +This function takes an object as a parameter with 4 properties: + +- `liquidityBurned` -> the amount of SIRS to burn +- `totalLiquidity` -> the total amount of SIRS tokens in the contract +- `xtzPool` -> the total amount of XTZ tokens in the contract +- `tokenPool` -> the total amount of tzBTC tokens in the contract + +If the function has been able to calculate the amounts of XTZ and tzBTC, they are returned in an object, otherwise `null` is returned. After that, those amounts can be displayed in the interface. + +Now, let's see how to interact with the `removeLiquidity` entrypoint of the contract. First, we create a `removeLiquidity` function within our TypeScript code that will be triggered when the user clicks on the `Remove liquidity` button: + +```typescript= +const removeLiquidity = async () => { + try { + if (inputSirs) { + removeLiquidityStatus = TxStatus.Loading; + store.updateToast( + true, + "Removing liquidity, waiting for confirmation..." + ); + + const lbContract = await $store.Tezos.wallet.at(dexAddress); + + ... + +}; +``` + +The function starts by checking if there is an amount of SIRS that was input before the remove liquidity action was triggered. If that's the case, the `removeLiquidityStatus` is set to `loading` to update the UI and inform the user that the transaction is getting ready. A toast will also be displayed. + +Next, a `ContractAbstraction` is created for the LB DEX in order to interact with it from Taquito. + +Now, we can forge the actual transaction: + +```typescript= +const op = await lbContract.methodsObject + .removeLiquidity({ + to: $store.userAddress, + lqtBurned: inputSirs, + minXtzWithdrawn: Math.floor(xtzOutput * 10 ** XTZ.decimals), + minTokensWithdrawn: Math.floor(tzbtcOutput * 10 ** tzBTC.decimals), + deadline: calcDeadline() + }) + .send(); +await op.confirmation(); +``` + +The `removeLiquidity` entrypoint expects 5 parameters: + +1. `to` -> the account that will receive the XTZ and tzBTC +2. `lqtBurned` -> the amount of SIRS to burn +3. `minXtzWithdrawn` -> the minimum amount of XTZ expected to be received +4. `minTokensWithdrawn` -> the minimum amount of tzBTC expected to be received +5. `deadline` -> just as the other entrypoint, a deadline for the transaction must be provided + +After the transaction has been emitted, we call `.confirmation()` on the operation object returned by Taquito. + +If the transaction was successful, we update the UI and reset the token values to let the user know: + +```typescript= +removeLiquidityStatus = TxStatus.Success; +inputSirs = ""; +xtzOutput = 0; +tzbtcOutput = 0; + +// fetches user's XTZ, tzBTC and SIRS balances +const res = await fetchBalances($store.Tezos, $store.userAddress); +if (res) { + store.updateUserBalance("XTZ", res.xtzBalance); + store.updateUserBalance("tzBTC", res.tzbtcBalance); + store.updateUserBalance("SIRS", res.sirsBalance); +} else { + store.updateUserBalance("XTZ", null); + store.updateUserBalance("tzBTC", null); + store.updateUserBalance("SIRS", null); +} + +store.updateToast(true, "Liquidity successfully removed!"); +``` + +If the transaction failed, we also update the UI accordingly: + +```typescript= +removeLiquidityStatus = TxStatus.Error; +store.updateToast(true, "An error has occurred"); +``` + +And that's it, the users have now the possibility to remove SIRS tokens and get XTZ and tzBTC tokens in exchange! + + +You've made it until the end of this tutorial 🙂 + +This very simple dapp introduced a lot of different concepts that are fundamental to developing applications on Tezos, but also to understanding how Tezos works in general. + +Taquito is an amazing library to develop on Tezos, whether you want to prototype ideas quickly or want to create full-stack decentralized applications. It provides a main library with all you need to read from the Tezos blockchain, interact with smart contracts and use wallets, and several smaller packages for specific usage, for example, reading token metadata or batching operations. + +Whether you want to build a front-end app, a back-end, or even a desktop app, as long as you are using JavaScript/NodeJS, you will be able to use Taquito! + +This tutorial also introduced different tools you may need on your journey to developing dapps on Tezos, The Beacon SDK to interact with wallets, the TzKT API to get more data from the blockchain, etc. + +Although this tutorial uses Svelte as its framework of choice, the skills you learned are transferrable to other frameworks as they are based on a lot of the same concepts (the component lifecycles are very similar, etc.) It gives you everything you need to build amazing dapps on Tezos and I can't wait to see what you will build next! \ No newline at end of file diff --git a/src/pages/tutorials/build-your-first-app/index.md b/src/pages/tutorials/build-your-first-app/index.md new file mode 100644 index 000000000..71e6e31b3 --- /dev/null +++ b/src/pages/tutorials/build-your-first-app/index.md @@ -0,0 +1,553 @@ +--- +id: build-your-first-app +title: Build your first app on Tezos +authors: Claude Barde +--- + +In this tutorial, you will learn how to set up and create a decentralized web application on Tezos. We will build together an interface for the Liquidity Baking smart contract that will allow us to interact with this DEX and perform different operations, like swapping tokens or providing liquidity. At the same time, you will be introduced to core concepts of building a decentralized application in general, but also specifically on Tezos. + +As the dapp will be built with [TypeScript](https://www.typescriptlang.org/), a good knowledge of this programming language is required. We will use the [Svelte](https://svelte.dev/) framework to develop the application, no prior knowledge of it is required as it is pretty intuitive to use and I will explain how it works along the way. + +As 99% of the dapps in the ecosystem, this dapp will use [Taquito](https://tezostaquito.io/), a TypeScript library that will provide a much better developer experience to use the Tezos blockchain. + +## Overview of this tutorial +### Setting up the project +- Installing ViteJS + Svelte +- Installing Tezos packages +- Configuring ViteJS +- Checking that everything works + +### Setting up the dapp +- File structure +- Configuration +- The `TezosToolkit` instance + +### Setting up the wallet +- Setting up Beacon +- Design considerations (wallet, etc.) + +### Fetching user's balances +- XTZ balance +- tzBTC balance +- SIRIUS balance +- Displaying the balances + +### Swapping XTZ/tzBTC +- Requirements +- UI design +- Calculating minimum tokens out +- Transaction feedback + +### Adding liquidity +- Requirements +- UI design +- Calculating amounts of XTZ and tzBTC + +### Removing liquidity +- Requirements +- UI design + + + +## The Liquidity Baking contract + +There is a special contract on Tezos called the **Liquidity Baking** contract. This contract is a decentralized exchange (or DEX) that handles only 3 tokens: **XTZ** (the native token of Tezos), **tzBTC** (a wrapped token to use Bitcoin on Tezos), and **SIRS** (for _Sirius_, the token that represents an equal amount of liquidity in XTZ and tzBTC added to the contract). + +The particularity of this contract is that every time a new block is baked on Tezos, 2.5 XTZ are added to the contract. Users are expected to bring tzBTC in order to keep the DEX liquidity balanced and the price of SIRS stable. + +The contract is also fully public, which means that anybody with a Tezos wallet can interact with it to swap XTZ for tzBTC and vice-versa, provide liquidity or remove it, which is what we are going to do in this tutorial. + +## What are we going to build? + +In this tutorial, we will build a dapp interface that interacts with the LB contract to swap tokens, add liquidity and remove it. The dapp will handle different actions: + +- Displaying users' information like their XTZ, tzBTC, and SIRS balance and update them after each transaction +- Connecting and disconnecting the users' wallet +- Displaying wallet information like its connection status and the network it's connected to +- Displaying different interfaces to swap tokens, add and remove liquidity +- Allowing users to swap XTZ for tzBTC and tzBTC for XTZ +- Allowing users to add liquidity by providing XTZ and tzBTC and getting SIRS in exchange +- Allowing users to remove liquidity, i.e. to redeem SIRS tokens and get XTZ and tzBTC tokens in exchange. + +## What tools are we going to use? + +As the decentralized application is ultimately a web app, we will use the following tools to build it: + +- **Svelte** for the JavaScript framework +- **TypeScript** to make our JavaScript code safer and more expressive +- **Sass** as a CSS preprocessor +- **Vite** to bundle the application (pronounced like _veet_) +- **Taquito** to interact with the Tezos blockchain +- **Beacon** and the wrapper library provided by Taquito to use a Tezos wallet + +## Useful links + +- Svelte => https://svelte.dev/ +- TypeScript => https://www.typescriptlang.org/ +- Sass => https://sass-lang.com/ +- Vite => https://vitejs.dev/ +- Taquito => https://tezostaquito.io/ +- Beacon => https://docs.walletbeacon.io/ +- GitHub repo with the dapp => https://github.com/claudebarde/tezos-dev-portal-tutorial + + +As we are building a web app with the Svelte framework, the steps to set up the project will be very similar to the ones you would follow to set up any other web app. + +In this tutorial, we will make a Svelte SPA, so we won't need SvelteKit, which will also make our life easier. + +The first thing to do is to install Svelte with TypeScript and Vite: + +``` +npm create vite@latest lb-dex -- --template svelte-ts +cd lb-dex +npm install +``` + +Next, we will install all the dependencies we need for the dapp: + +``` +npm install --save-dev sass +npm install @taquito/taquito @taquito/beacon-wallet +``` + +Sass is a development-only dependency, `@taquito/taquito` is the NPM package for the Taquito library and `@taquito/beacon-wallet` is the NPM package that contains Beacon with some little configuration to make it easier to plug into Taquito. + +There are a couple of other libraries we need to install: + +``` +npm install --save-dev buffer events vite-compatible-readable-stream +``` + +These libraries are required to be able to run Beacon in a Svelte app. We will see down below how to use them. + +Once everything has been installed, we have to set up the right configuration. + +In your `app` folder, you will see the `vite.config.js` file, it's the file that contains the configuration that Vite needs to run and bundle your app. Make the following changes: + +```javascript= +import { defineConfig, mergeConfig } from "vite"; +import path from "path"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default ({ command }) => { + const isBuild = command === "build"; + + return defineConfig({ + plugins: [svelte()], + define: { + global: {} + }, + build: { + target: "esnext", + commonjsOptions: { + transformMixedEsModules: true + } + }, + server: { + port: 4000 + }, + resolve: { + alias: { + "@airgap/beacon-sdk": path.resolve( + path.resolve(), + `./node_modules/@airgap/beacon-sdk/dist/${ + isBuild ? "esm" : "cjs" + }/index.js` + ), + // polyfills + "readable-stream": "vite-compatible-readable-stream", + stream: "vite-compatible-readable-stream" + } + } + }); +}; +``` + +Here are a few changes we made to the template configuration given by Vite: +- We set `global` to `{}` and we will later provide the `global` object in our HTML file +- We provide a path to the Beacon SDK +- We provide polyfills for `readable-stream` and `stream` + +Once these changes have been done, there is a last step to finish setting up the project: we have to update the HTML file where the JavaScript code will be injected. + +Here is what you should have: + +```html= + + + + + + + + + Liquidity Baking DEX + + + + + +``` + +In the first `script` tag, we set the `global` variable to `globalThis`. Then, in the second `script` tag with a `module` type, we import `Buffer` from the `buffer` library and add it to the `window` global object. + +> *Note: this configuration is required to run the Beacon SDK with a Vite app. Taquito works completely out of the box and doesn't require any settings.* + +Once we updated the configuration in the `vite.config.js` file and in the `index.html` file, our project is successfully set up! You can run `npm run dev` in your terminal at the root of the project to check that everything works properly, the dapp should be running on `http://localhost:4000` + +Now, let's start writing some code and setting up the dapp! + +As we are building a web app with the Svelte framework, the steps to set up the project will be very similar to the ones you would follow to set up any other web app. + +In this tutorial, we will make a Svelte SPA, so we won't need SvelteKit, which will also make our life easier. + +The first thing to do is to install Svelte with TypeScript and Vite: + +``` +npm create vite@latest lb-dex -- --template svelte-ts +cd lb-dex +npm install +``` + +Next, we will install all the dependencies we need for the dapp: + +``` +npm install --save-dev sass +npm install @taquito/taquito @taquito/beacon-wallet +``` + +Sass is a development-only dependency, `@taquito/taquito` is the NPM package for the Taquito library and `@taquito/beacon-wallet` is the NPM package that contains Beacon with some little configuration to make it easier to plug into Taquito. + +There are a couple of other libraries we need to install: + +``` +npm install --save-dev buffer events vite-compatible-readable-stream +``` + +These libraries are required to be able to run Beacon in a Svelte app. We will see down below how to use them. + +Once everything has been installed, we have to set up the right configuration. + +In your `app` folder, you will see the `vite.config.js` file, it's the file that contains the configuration that Vite needs to run and bundle your app. Make the following changes: + +```javascript= +import { defineConfig, mergeConfig } from "vite"; +import path from "path"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default ({ command }) => { + const isBuild = command === "build"; + + return defineConfig({ + plugins: [svelte()], + define: { + global: {} + }, + build: { + target: "esnext", + commonjsOptions: { + transformMixedEsModules: true + } + }, + server: { + port: 4000 + }, + resolve: { + alias: { + "@airgap/beacon-sdk": path.resolve( + path.resolve(), + `./node_modules/@airgap/beacon-sdk/dist/${ + isBuild ? "esm" : "cjs" + }/index.js` + ), + // polyfills + "readable-stream": "vite-compatible-readable-stream", + stream: "vite-compatible-readable-stream" + } + } + }); +}; +``` + +Here are a few changes we made to the template configuration given by Vite: +- We set `global` to `{}` and we will later provide the `global` object in our HTML file +- We provide a path to the Beacon SDK +- We provide polyfills for `readable-stream` and `stream` + +Once these changes have been done, there is a last step to finish setting up the project: we have to update the HTML file where the JavaScript code will be injected. + +Here is what you should have: + +```html= + + + + + + + + + Liquidity Baking DEX + + + + + +``` + +In the first `script` tag, we set the `global` variable to `globalThis`. Then, in the second `script` tag with a `module` type, we import `Buffer` from the `buffer` library and add it to the `window` global object. + +> *Note: this configuration is required to run the Beacon SDK with a Vite app. Taquito works completely out of the box and doesn't require any settings.* + +Once we updated the configuration in the `vite.config.js` file and in the `index.html` file, our project is successfully set up! You can run `npm run dev` in your terminal at the root of the project to check that everything works properly, the dapp should be running on `http://localhost:4000` + +Now, let's start writing some code and setting up the dapp! + +If you've made it so far and your app is running on `http://localhost:4000`, congratulations! + +Now, we have to set up the dapp in order to use Taquito and Beacon. + +### File structure + +The entrypoint of every Svelte app is a file called `App.svelte`, this is where you will import all your components to be bundled together into your final app. The file structure of our project looks like this: + +``` +- src + - assets + - svelte.png + - lib + - AddLiquidityView.svelte + - Interface.svelte + - RemoveLiquidity.svelte + - Sidebar.svelte + - SirsStats.svelte + - SwapView.svelte + - Toast.svelte + - UserInput.svelte + - UserStats.svelte + - Wallet.svelte + - styles + - index.scss + - settings.scss + - App.svelte + - config.ts + - lbUtils.ts + - main.ts + - store.ts + - types.ts + - utils.ts +- index.html +- svelte.config.js +- tsconfig.json +- vite.config.js +``` + +Let's see what each of these elements does: + +- **assets** -> contains the favicon (here, this is the default Svelte favicon, but you can choose another one) +- **lib** -> contains the different components that will make up our interface, here is what each does: + - `SwapView.svelte`: the interface to swap XTZ and tzBTC tokens + - `AddLiquidityView.svelte`: the interface to add liquidity to the LB DEX + - `RemoveLiquidity.svelte`: the interface to remove liquidity from the LB DEX + - `Interface.svelte`: the higher-order component to hold the different views to interact with the LB DEX + - `Sidebar.svelte`: the component to navigate between the different interfaces and to connect or disconnect the wallet + - `SirsStats.svelte`: the component to display the amount of XTZ, tzBTC, and SIRS present in the contract + - `Toast.svelte`: a simple component to display the progression of the transactions and other messages when interacting with the contract + - `UserInput.svelte`: a utility component to make it easier to interact and control input fields + - `UserStats.svelte`: the component to display the user's balance in XTZ, tzBTC, and SIRS + - `Wallet.svelte`: the component to manage wallet interactions +- **styles** -> contains the SASS files to style different elements of our interface +- **App.svelte** -> the entrypoint of the application +- **config.ts** -> different immutable values needed for the application and saved in a separate file for convenience +- **lbUtils.ts** -> different methods to calculate values needed to interact with the Liquidity Baking contract +- **main.ts** -> this is where the JavaScript for the app is bundled before being injected into the HTML file +- **store.ts** -> a file with a [Svelte store](https://svelte.dev/tutorial/writable-stores) to handle the dapp state +- **types.ts** -> custom TypeScript types +- **utils.ts** -> different utility methods + +The first thing to do is to import our styles into the `main.ts` file: + +```typescript= +import App from './App.svelte' +import "./styles/index.scss"; + +const app = new App({ + target: document.body +}) + +export default app +``` + +Svelte uses SASS by default, so there is no configuration to do for that. + +> _Note: I also like to target the `body` tag to inject the HTML produced by JavaScript instead of a `div` inside the `body`, but that's a personal choice and you are free to use a `div` instead_ + +Before continuing, this is what a Svelte file looks like: + +```html= + + + + +... your HTML code +``` + +Svelte components are fully contained, which means that the style that you apply inside a component doesn't leak into the other components of your app. The style that we want to share among different components will be written in the `index.scss` file. + +There is a `script` tag with a `lang` attribute set to `ts` for TypeScript, a `style` tag with a `lang` attribute set to `scss` for SASS, and the rest of the code in the file will be interpreted as HTML. + +### Configuring the dapp + +Now, let's set up different things in our `App.svelte` file. + +The HTML part is just going to put all the higher-order components together: + +```html= +
+ + {#if $store.Tezos && $store.dexInfo} + + + {:else} +
Loading
+ {/if} +
+``` + +The interface will change after different elements are available to the dapp, mostly, the data about the liquidity pools from the liquidity baking contract. + +The SASS part will import different settings and apply styling to the `main` tag: + +```scss= +@import "./styles/settings.scss"; + +main { + display: grid; + grid-template-columns: 250px 1fr; + gap: $padding; + padding: $padding; + height: calc(100% - (#{$padding} * 2)); +} + +@media screen and (max-height: 700px) { + main { + padding: 0px; + height: 100%; + } +} +``` + +Now, the TypeScript part. First, you import the libraries and components we need: + +```typescript= +import { onMount } from "svelte"; +import { TezosToolkit } from "@taquito/taquito"; +import store from "./store"; +import { rpcUrl, dexAddress } from "./config"; +import Sidebar from "./lib/Sidebar.svelte"; +import Interface from "./lib/Interface.svelte"; +import Toast from "./lib/Toast.svelte"; +import type { Storage } from "./types"; +import { fetchExchangeRates } from "./utils"; +``` + +- `onMount` is a method exported by Svelte that will run some code when the component mounts (more on that below) +- `TezosToolkit` is the class that gives you access to all the features of Taquito +- `store` is a Svelte feature to manage the state of the dapp +- From the `config.ts` file, we import `rpcUrl` (the URL of the Tezos RPC node) and `dexAddress`, the address of the Liquidity Baking contract +- `Storage` is a custom type that represents the signature type of the LB DEX storage +- `fetchExchangeRates` is a function to fetch the exchange rates of XTZ and tzBTC (more on that below) + +Next, we use `onMount` to set up the state of the dapp: + +```typescript= +onMount(async () => { + const Tezos = new TezosToolkit(rpcUrl); + store.updateTezos(Tezos); + const contract = await Tezos.wallet.at(dexAddress); + const storage: Storage | undefined = await contract.storage(); + + if (storage) { + store.updateDexInfo({ ...storage }); + } + + // fetches XTZ and tzBTC prices + const res = await fetchExchangeRates(); + if (res) { + store.updateExchangeRates([ + { token: "XTZ", exchangeRate: res.xtzPrice }, + { token: "tzBTC", exchangeRate: res.tzbtcPrice } + ]); + } else { + store.updateExchangeRates([ + { token: "XTZ", exchangeRate: null }, + { token: "tzBTC", exchangeRate: null } + ]); + } + }); +``` + +The first thing to do is to create an instance of the `TezosToolkit` by passing the URL of the RPC node we want to interact with. In general, you want to have a single instance of the `TezosToolkit` in order to keep the same configuration across all your app components, this is why we save it in the `store` with the `updateTezos` method. + +After that, we want to fetch the storage of the LB DEX to get the amounts of XTZ, tzBTC, and SIRS in the contract. We create a `ContractAbstraction`, an instance provided by Taquito with different properties and methods that are useful to work with Tezos smart contracts. +From the `ContractAbstraction`, we can call the `storage` method that returns a JavaScript object that represents the storage of the given contract. We then pass the storage to the `updateDexInfo` method present on the `store` to update this data and display them to the user. + +To finish, we need to fetch the exchange rates for XTZ and tzBTC to make the conversions required by this kind of app. The `utils.ts` file contains a function that will help us here: + +```typescript= +export const fetchExchangeRates = async (): Promise<{ + tzbtcPrice: number; + xtzPrice: number; +} | null> => { + const query = ` + query { + overview { xtzUsdQuote }, + token(id: "KT1PWx2mnDueood7fEmfbBDKx1D9BAnnXitn") { price } + } + `; + const res = await fetch(`https://analytics-api.quipuswap.com/graphql`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + query + }) + }); + if (res.status === 200) { + const resData = await res.json(); + let xtzPrice = resData?.data?.overview?.xtzUsdQuote; + let tzbtcPrice = resData?.data?.token?.price; + // validates the 2 values + if (xtzPrice && tzbtcPrice) { + xtzPrice = +xtzPrice; + tzbtcPrice = +tzbtcPrice; + if (!isNaN(xtzPrice) && !isNaN(tzbtcPrice)) { + // tzBTC price is given in XTZ by the API + tzbtcPrice = tzbtcPrice * xtzPrice; + return { tzbtcPrice, xtzPrice }; + } + } else { + return null; + } + } else { + return null; + } +}; +``` + +We use the [QuipuSwap GraphQL API](https://analytics-api.quipuswap.com/graphql) to fetch these exchange rates. After the exchange rates are received, we parse the response from the API and validate the price given for XTZ and tzBTC. These prices are then returned by the function and we can save them in the store. The exchange rates are used, for example, to calculate the total value in USD locked in the contract. \ No newline at end of file diff --git a/src/pages/tutorials/build-your-first-app/swapping-tokens.md b/src/pages/tutorials/build-your-first-app/swapping-tokens.md new file mode 100644 index 000000000..493069cea --- /dev/null +++ b/src/pages/tutorials/build-your-first-app/swapping-tokens.md @@ -0,0 +1,341 @@ +--- +id: swapping-tokens +title: Swapping XTZ and tzBTC +authors: Claude Barde +--- + +## Swapping XTZ and tzBTC + +Now, let's go down the rabbit hole and implement the most complex feature of the dapp: the swap of XTZ and tzBTC. + +### Designing the UI + +I say "the most complex" because the interface you are about to build includes a lot of moving parts and calculations that must be done at the moment of the user's input and confirmation. The Liquidity Baking contract is also a bit picky about the data you must send in order to swap tokens, so you will have to fine-tune our code to make sure that it goes like clockwork! + +Here is a screenshot of the UI you are aiming for: + +![Swap UI](/developers/docs/images/build-your-first-dapp/swap-ui.png "Swap UI") + +There are 2 text inputs, the one on the left is editable and will let the user input the amount of XTZ or tzBTC they want to exchange and the one on the right will be disabled and will display the corresponding amount they'll get in the other token. The button in the middle with the 2 arrows will allow the user to switch the input between XTZ and tzBTC. + +Going into the details of how the text inputs are implemented would go beyond the scope of this tutorial, but you can have a look at it in the `UserInput.svelte` file. + +### Handling user input + +In a nutshell, each input with its token icon and `max` field is the same component, the parent component tracks the position of each to update their UI accordingly. Internally, each input component keeps track of the user's input and the available balance to display error messages if the balance is too low. Each update in the input is dispatched to the parent component to adjust the general UI. + +Every time an update is sent to the parent component (`SwapView.svelte`), the data provided with the update is passed to the `saveInput` function: + +```typescript= +import { + xtzToTokenTokenOutput, + tokenToXtzXtzOutput, + calcSlippageValue +} from "../lbUtils"; + +const saveInput = ev => { + const { token, val, insufficientBalance: insufBlnc } = ev.detail; + insufficientBalance = insufBlnc; + + if (token === tokenFrom && val > 0) { + inputFrom = val.toString(); + inputTo = ""; + if (tokenFrom === "XTZ") { + // calculates tzBTC amount + let tzbtcAmount = xtzToTokenTokenOutput({ + xtzIn: val * 10 ** XTZ.decimals, + xtzPool: $store.dexInfo.xtzPool, + tokenPool: $store.dexInfo.tokenPool + }); + if (tzbtcAmount) { + inputTo = tzbtcAmount.dividedBy(10 ** tzBTC.decimals).toPrecision(6); + } + // calculates minimum output + minimumOutput = calcSlippageValue("tzBTC", +inputTo, +slippage); + } else if (tokenFrom === "tzBTC") { + // calculates XTZ amount + let xtzAmount = tokenToXtzXtzOutput({ + tokenIn: val * 10 ** tzBTC.decimals, + xtzPool: $store.dexInfo.xtzPool, + tokenPool: $store.dexInfo.tokenPool + }); + if (xtzAmount) { + inputTo = xtzAmount.dividedBy(10 ** XTZ.decimals).toPrecision(8); + } + // calculates minimum output + minimumOutput = calcSlippageValue("XTZ", +inputTo, +slippage); + } + } else { + inputFrom = ""; + inputTo = ""; + } + }; +``` + +Here, a lot of things happen: + +- the values necessary for the calculations of the token amounts are destructured from the `ev.detail` object +- the function verifies that the values are received from the token that is currently active (the one on the left) +- if that token is XTZ, the amount in tzBTC is calculated via the `xtzToTokenTokenOutput` function (more on that below) +- if that token is tzBTC, the amount in XTZ is calculated via the `tokenToXtzXtzOutput` function (more on that below) +- the minimum amount to be expected according to the slippage set by the user is calculated by the `calcSlippage` function + +> _Note: the "slippage" refers to the percentage that the user accepts to lose during the trade, a loss of tokens can happen according to the state of the liquidity pools. For example, if 100 tokens A can be swapped for 100 tokens B with a slippage of 1%, it means that you will receive between 99 and 100 tokens B._ + +### Exchanging XTZ for tzBTC and tzBTC for XTZ + +Now, let's have a look at the functions you introduced above, `xtzToTokenTokenOutput` and `tokenToXtzXtzOutput`. They were adapted [from the code in this repo](https://github.com/kukai-wallet/kukai-dex-calculations) and allow you to calculate how many tzBTC a user will get according to the XTZ amount they input and vice-versa. + +```typescript= +export const xtzToTokenTokenOutput = (p: { + xtzIn: BigNumber | number; + xtzPool: BigNumber | number; + tokenPool: BigNumber | number; +}): BigNumber | null => { + let { xtzIn, xtzPool: _xtzPool, tokenPool } = p; + let xtzPool = creditSubsidy(_xtzPool); + let xtzIn_ = new BigNumber(0); + let xtzPool_ = new BigNumber(0); + let tokenPool_ = new BigNumber(0); + try { + xtzIn_ = new BigNumber(xtzIn); + xtzPool_ = new BigNumber(xtzPool); + tokenPool_ = new BigNumber(tokenPool); + } catch (err) { + return null; + } + if ( + xtzIn_.isGreaterThan(0) && + xtzPool_.isGreaterThan(0) && + tokenPool_.isGreaterThan(0) + ) { + const numerator = xtzIn_.times(tokenPool_).times(new BigNumber(998001)); + const denominator = xtzPool_ + .times(new BigNumber(1000000)) + .plus(xtzIn_.times(new BigNumber(998001))); + return numerator.dividedBy(denominator); + } else { + return null; + } +}; +``` + +The `xtzToTokenTokenOutput` function requires 3 values to calculate an output in tzBtc from an input in XTZ: the said amount in XTZ (`xtzIn`), the state of the XTZ pool in the contract (`xtzPool`), and the state of the SIRS pool (`tokenPool`). Most of the modifications made to the original functions apply to the use of `BigNumber` in order to make it work more smoothly with Taquito. The function then returns the corresponding amount in tzBTC or `null` if an error occurs. + +The same goes for `tokenToXtzXtzOutput`: + +```typescript= +export const tokenToXtzXtzOutput = (p: { + tokenIn: BigNumber | number; + xtzPool: BigNumber | number; + tokenPool: BigNumber | number; +}): BigNumber | null => { + const { tokenIn, xtzPool: _xtzPool, tokenPool } = p; + let xtzPool = creditSubsidy(_xtzPool); + let tokenIn_ = new BigNumber(0); + let xtzPool_ = new BigNumber(0); + let tokenPool_ = new BigNumber(0); + try { + tokenIn_ = new BigNumber(tokenIn); + xtzPool_ = new BigNumber(xtzPool); + tokenPool_ = new BigNumber(tokenPool); + } catch (err) { + return null; + } + if ( + tokenIn_.isGreaterThan(0) && + xtzPool_.isGreaterThan(0) && + tokenPool_.isGreaterThan(0) + ) { + let numerator = new BigNumber(tokenIn) + .times(new BigNumber(xtzPool)) + .times(new BigNumber(998001)); + let denominator = new BigNumber(tokenPool) + .times(new BigNumber(1000000)) + .plus(new BigNumber(tokenIn).times(new BigNumber(999000))); + return numerator.dividedBy(denominator); + } else { + return null; + } +}; +``` + +After the corresponding amount of XTZ or tzBTC is calculated according to the inputs of the user, the UI unlocks and is ready for a swap. + +### Creating a swap transaction + +Swapping the tokens is pretty intensive as they are multiple moving parts that must play in unison. Let's describe step by step what happens after the user clicks on the _Swap_ button: + +```typescript= +const swap = async () => { + try { + if (isNaN(+inputFrom) || isNaN(+inputTo)) { + return; + } + +... + + } catch (error) { + console.log(error); + swapStatus = TxStatus.Error; + store.updateToast(true, "An error has occurred"); + } +}; +``` + +The `swap` function is triggered when the user clicks the _Swap_ button. The first thing to do is to check if there is a valid value for `inputFrom`, i.e. the token that the user wants to exchange (XTZ or tzBTC), and a valid value for `inputTo`, i.e. the token that the user will receive. There is no point in going further if those two values are not set properly. + +Next, you update the UI in order to show the user that the transaction is getting ready: + +```typescript= +enum TxStatus { + NoTransaction, + Loading, + Success, + Error +} + +swapStatus = TxStatus.Loading; +store.updateToast(true, "Waiting to confirm the swap..."); + +const lbContract = await $store.Tezos.wallet.at(dexAddress); +const deadline = calcDeadline(); +``` + +You create an [`enum`](https://www.typescriptlang.org/docs/handbook/enums.html) to represent the status of the transaction (available in the `type.ts` file) and you update the `swapStatus` variable responsible for updating the UI and blocking the inputs. The store is also updated with the `updateToast()` method to get a simple toast to show up in the interface. + +After that, you create the `ContractAbstraction` from Taquito in order to interact with the DEX and you also calculate the deadline. + +> _Note: the Liquidity Baking contract expects you to pass a deadline for the swap, the transaction will be rejected if the deadline is expired._ + +#### Swapping tzBTC for XTZ + +Now, you have 2 situations: the user selected either XTZ or tzBTC as the token to swap. Let's start with tzBTC as the preparation of the transaction is more complicated: + +```typescript= +if (tokenFrom === "tzBTC") { + const tzBtcContract = await $store.Tezos.wallet.at(tzbtcAddress); + const tokensSold = Math.floor(+inputFrom * 10 ** tzBTC.decimals); + let batch = $store.Tezos.wallet + .batch() + .withContractCall(tzBtcContract.methods.approve(dexAddress, 0)) + .withContractCall( + tzBtcContract.methods.approve(dexAddress, tokensSold) + ) + .withContractCall( + lbContract.methods.tokenToXtz( + $store.userAddress, + tokensSold, + minimumOutput, + deadline + ) + ) + .withContractCall(tzBtcContract.methods.approve(dexAddress, 0)); + const batchOp = await batch.send(); + await batchOp.confirmation(); + } +``` + +The major difference between swapping XTZ to tzBTC and swapping tzBTC to XTZ is that the latter requires 3 additional operations: one to set the current permission for the LB DEX (if any) to zero, one to register the LB DEX as an operator within the tzBTC contract with the amount of tokens that it is allowed to spend on behalf of the user and one to set this amount back to zero and avoid later uses of the given permission. + +> _Note 1: you can read more about the behaviors of the tzBTC contract and other FA1.2 contracts [here](https://gitlab.com/tezos/tzip/-/blob/master/proposals/tzip-7/tzip-7.md)_. + +> _Note 2: technically speaking, it is not necessary to set the permission back to zero at the end of the transaction (but setting it to zero at the beginning is required). It's just a common practice to prevent any unexpected pending permission._ + +First, you create the `ContractAbstraction` for the tzBTC contract as you are about to interact with it. Once done, you calculate the amount of tokens you should approve based on our previous calculations. + +> _Note: the `ContractAbstraction` is a very useful instance provided by Taquito that exposes different tools and properties to get details about a given contract or interact with it._ + +After that, you use the [Batch API](https://tezostaquito.io/docs/batch_api/) provided by Taquito. The Batch API allows grouping multiple operations in a single transaction to save on gas and on processing time. This is how it works: + +1. You call the `batch()` method present on the `wallet` or `contract` property of the instance of the `TezosToolkit` +2. This returns a batch instance with different methods that you can use to create transactions, in our example, `withContractCall()` is a method that will add a new contract call to the batch of operations +3. As a parameter for `withContractCall()`, you pass the contract call as if you would call it on its own, by using the name of the entrypoint on the `methods` property of the `ContractAbstraction` +4. In this case, you batch 1 operation to set the permission of the LB DEX within the tzBTC contract to zero, 1 operation to approve the amount required by the swap, 1 operation to confirm the swap within the LB DEX contract, and 1 operation to set the permission of the LB DEX back to zero +5. On the returned batch, you call the `.send()` method to forge the transaction, sign it and send it to the Tezos mempool, which returns an operation +6. You can `await` the confirmation of the transaction by calling `.confirmation()` on the operation returned in the step above. + +Notice the penultimate transaction: the `tokenToXtz` entrypoint of the LB contract requires 4 parameters: + +- The address of the account that will receive the XTZ +- The amount of tzBTC that will be sold for the swap +- The expected amount of XTZ that will be received +- A deadline after which the transaction expires + +After the transaction is sent by calling the `.send()` method, you call `.confirmation()` on the operation object to wait for one confirmation (which is the default if you don't pass a parameter to the method). + +#### Swapping XTZ to tzBTC + +This will be a much easier endeavor! Let's check the code first: + +```typescript= +const op = await lbContract.methods + .xtzToToken($store.userAddress, minimumOutput, deadline) + .send({ amount: +inputFrom }); +await op.confirmation(); +``` + +The `xtzToToken` entrypoint takes 3 parameters: + +- The address of the account that will receive the tzBTC tokens +- The expected amount of tzBTC to be received +- The deadline + +In addition to that, you have to attach the right amount of XTZ to the transaction. This can be achieved very easily with Taquito. + +Remember the `.send()` method that you call on the output of the entrypoint call? If you didn't know, you can pass parameters to this method, one of the most important ones is an amount of XTZ to send along with the transaction. Just pass an object with an `amount` property and a value of the amount of tez you want to attach, and that's it! + +Then, just like any other transaction, you get an operation object and call `.confirmation()` on it to wait for the operation to be included in a new block. + +### Updating the UI + +Whether the swap is successful or not, it is crucial to provide feedback to your users. + +If the swap succeeded, you will fetch the user's new balances and provide visual feedback: + +```typescript= +const res = await fetchBalances($store.Tezos, $store.userAddress); +if (res) { + store.updateUserBalance("XTZ", res.xtzBalance); + store.updateUserBalance("tzBTC", res.tzbtcBalance); + store.updateUserBalance("SIRS", res.sirsBalance); +} else { + store.updateUserBalance("XTZ", null); + store.updateUserBalance("tzBTC", null); + store.updateUserBalance("SIRS", null); +} + +// visual feedback +store.updateToast(true, "Swap successful!"); +``` + +> _Note: it would also be possible to avoid 2 HTTP requests and calculate the new balances from the amounts that were passed as parameters for the swap. However, the users may have received tokens since the last time the balances were fetched, and it will provide a better user experience if you get the accurate balances after the swap._ + +If the swap isn't successful, you will be redirected to the `catch` branch where you also have to provide visual feedback and update the UI: + +```typescript= +swapStatus = TxStatus.Error; +store.updateToast(true, "An error has occurred"); +``` + +Setting `swapStatus` to `TxStatus.Error` will remove the loading interface you set during the swap before you display a toast to indicate that the transaction failed. + +Finally (pun intended), you move to the `finally` branch to reset the UI after 3 seconds: + +```typescript= +finally { + setTimeout(() => { + swapStatus = TxStatus.NoTransaction; + store.showToast(false); + }, 3000); +} +``` + +### Design considerations + +As you can tell from the code involved, swapping tokens is a pretty complex action and there are a few things that you should keep in mind, regarding both the code you write and the UI you create: + +- Try to structure your code into different steps that don't mix, for example, step 1: updating the UI before forging the transaction, step 2: forging the transaction, step 3: emitting the transaction, step 4: updating the UI, etc. +- Never forget to provide visual feedback to your users! Baking a new operation can take up to 30 seconds when the network is not congested, and even longer if there is a lot of traffic. The users will wonder what is happening if you don't make them wait. A spinner or a loading animation is generally a good idea to indicate that the app is waiting for some sort of confirmation. +- Disable the UI while the transaction is in the mempool! You don't want the users to click on the _Swap_ button a second time (or third, or fourth!) while the blockchain is processing the transaction they already created. In addition to costing them more money, it can also confuse them and create unexpected behaviors in your UI. +- Reset the UI at the end. Nobody wants to click on the _Refresh_ button after an interaction with the blockchain because the UI seems to be stuck in its previous state. Make sure the interface is in the same (or similar) state as it was when the user first opened it. diff --git a/src/pages/tutorials/build-your-first-app/wallets-tokens.md b/src/pages/tutorials/build-your-first-app/wallets-tokens.md new file mode 100644 index 000000000..67a6718b6 --- /dev/null +++ b/src/pages/tutorials/build-your-first-app/wallets-tokens.md @@ -0,0 +1,233 @@ +--- +id: wallets-tokens +title: Wallets +authors: Claude Barde +--- + +## Wallets + +Now, let's talk about the wallet. + +The wallet is a key element of your dapp, without it, the users won't be able to interact with the Tezos blockchain, which defeats your purpose. There are multiple considerations to take into account when you are setting up the wallet that we will explain below. + +First, you want to isolate the wallet and its different interactions and values in the same component, called `Wallet.svelte` in our example. When using the Beacon SDK, it is crucial to keep a **single instance of Beacon** running in order to prevent bugs. + +When the Wallet component mounts, there are different things you want to do: + +```typescript= +onMount(async () => { + const wallet = new BeaconWallet({ + name: "Tezos dev portal dapp tutorial", + preferredNetwork: network + }); + store.updateWallet(wallet); + const activeAccount = await wallet.client.getActiveAccount(); + if (activeAccount) { + const userAddress = (await wallet.getPKH()) as TezosAccountAddress; + store.updateUserAddress(userAddress); + $store.Tezos.setWalletProvider(wallet); + await getWalletInfo(wallet); + // fetches user's XTZ, tzBTC and SIRS balances + const res = await fetchBalances($store.Tezos, userAddress); + if (res) { + store.updateUserBalance("XTZ", res.xtzBalance); + store.updateUserBalance("tzBTC", res.tzbtcBalance); + store.updateUserBalance("SIRS", res.sirsBalance); + } else { + store.updateUserBalance("XTZ", null); + store.updateUserBalance("tzBTC", null); + store.updateUserBalance("SIRS", null); + } + } + }); +``` + +You create the instance of the `BeaconWallet` by providing a name for the dapp (it can be whatever you want) that will be displayed in the wallet UI and the network you want to connect to (imported from the config file). The instance of the wallet is then saved in the store. + +Now, you want to check if the user connected a wallet before. Beacon will keep track of live connections in the local storage, this is how your users can navigate to your dapp and have their wallet connected automagically! + +The `BeaconWallet` instance provides a `client` property with different methods, the one you need here is `getActiveAccount()`, which will retrieve any live connection stored in the local storage. +If there is a live connection, you can fetch the user's address and save it into the store, update the store with the user's address before setting up the wallet as the signer with `$store.Tezos.setWalletProvider(wallet)`, get the information you need about the wallet (mainly, the name of the wallet) with the `getWalletInfo()` function and then, fetch the balances for the address that is connected with the `fetchBalances()` function described earlier. +Once the balances are fetched, they are saved into the store to be displayed in the interface. + +> \*Note: `TezosAccountAddress` is a custom type I like to use to validate Tezos addresses for implicit accounts: +> `type TezosAccountAddress = tz${"1" | "2" | "3"}${string}` +> TypeScript will raise a warning if you try to use a string that doesn't match this pattern. + +### Connecting the wallet + +Taquito and Beacon working in unison make it very easy to connect the wallet. A few lines of code using the APIs of these two essential libraries on Tezos are going to make miracles. + +Here is how to do it: + +```typescript= +const connectWallet = async () => { + if (!$store.wallet) { + const wallet = new BeaconWallet({ + name: "Tezos dev portal dapp tutorial", + preferredNetwork: network + }); + store.updateWallet(wallet); + } + + await $store.wallet.requestPermissions({ + network: { type: network, rpcUrl } + }); + const userAddress = (await $store.wallet.getPKH()) as TezosAccountAddress; + store.updateUserAddress(userAddress); + $store.Tezos.setWalletProvider($store.wallet); + // finds account info + await getWalletInfo($store.wallet); + // fetches user's XTZ, tzBTC and SIRS balances + const res = await fetchBalances($store.Tezos, userAddress); + if (res) { + store.updateUserBalance("XTZ", res.xtzBalance); + store.updateUserBalance("tzBTC", res.tzbtcBalance); + store.updateUserBalance("SIRS", res.sirsBalance); + } else { + store.updateUserBalance("XTZ", null); + store.updateUserBalance("tzBTC", null); + store.updateUserBalance("SIRS", null); + } + }; +``` + +The connection will be handled in a specific function called `connectWallet`. +If the store doesn't hold an instance of the `BeaconWallet` (if the dapp didn't detect any live connection on mount), you create that instance and save it in the store. + +Next, you ask the user to select a wallet with the `requestPermissions()` method present on the instance of the `BeaconWallet`. The parameter is an object where you indicate the network you want to connect to as well as the URL of the Tezos RPC node you will interact with. + +After the user selects a wallet to use with our dapp, you get their address with the `getPKH()` method on the `BeaconWallet` instance, you update the signer in the `TezosToolkit` instance by passing the wallet instance to `setWalletProvider()`, you get the information you need from the wallet and you fetch the user's balances. + +Now, the wallet is connected and the user is shown their different balances, as well as a connection status in the sidebar! + +> IMPORTANT: however you want to design your dapp, it is essential to keep one single instance of the `BeaconWallet` and it is highly recommended to do the same with the instance of the `TezosToolkit`. Creating multiple instances messes with the state of your app and with Taquito in general. + +### Disconnecting the wallet + +Disconnecting the wallet is as important as connecting it. There is nothing more frustrating than looking for how to disconnect your wallet for hours when it is not made explicit. Remember, a lot of users have multiple wallets (like Temple or Kukai) and even multiple addresses within the same wallet that they want to use to interact with your dapp. Make disconnecting the wallet easy for them. + +```typescript= +const disconnectWallet = async () => { + $store.wallet.client.clearActiveAccount(); + store.updateWallet(undefined); + store.updateUserAddress(undefined); + connectedNetwork = ""; + walletIcon = ""; + }; +``` + +There are different steps to disconnect the wallet and reset the state of the dapp: + +- `$store.wallet.client.clearActiveAccount()` -> kills the current connection to Beacon +- `store.updateWallet(undefined)` -> removes the wallet from the state in order to trigger a reload of the interface +- `store.updateUserAddress(undefined)` -> removes the current user's address from the state to update the UI +- `connectedNetwork = ""; walletIcon = ""` -> also needed to reset the state of the dapp and present an interface where no wallet is connected + +The call to `clearActiveAccount()` on the wallet instance is the only thing that you will do in whatever dapp you are building, it will remove all the data in the local storage and when your user revisits your dapp, they won't be automatically connected with their wallet. + +### Design considerations + +Writing code to interact with a wallet in a decentralized application is a very new paradigm and although you will be able to reuse a lot of concepts and good practices from your experience as a developer, there are also a few new things to keep in mind: + +1. Never prompt the users to connect their wallet after the dapp is mounted: getting a wallet pop-up on your screen just after the app is loaded is annoying, you have to remember that a lot of your users are non-technical and don't understand that connecting a wallet is harmless, so they may be wary about your dapp if you ask them to connect their wallet from the get-go. Instead, present some information about your dapp and a button to manually connect their wallet, if this is their first time. +2. The button to connect a wallet must stand out in your interface, whether you make it bigger, with a different color, or a different font, the users must not spend more than a couple of seconds to find it. +3. The button must be in a predictable position: most dapps on Tezos place their button to connect a wallet at the top-left or top-right of the UI. You are not _"creative"_ by placing the button in some other location, you will just end up confusing your users. +4. The text in the button should read **Connect** or something similar, avoid **Sync** or other words but "connect" as they can mean something different in the context of a decentralized application. +5. The status of the wallet must be displayed in the dapp. Whether it is connected or disconnected, the user should be able to tell. Additionally, you can add some information about the wallet they are using (like you do in this tutorial), the network they are connected to, or their balance. +6. You must enable/disable the interactions of your dapp that depend on the wallet. Users shouldn't be able to interact with a feature of your application that requires their wallet to be connected if this is not the case. + + +One of the most important features of the dapp which is also among the easiest ones to overlook is fetching the user's balances. Users can tell something is wrong if their balances don't show properly or don't update accordingly after an interaction with the contract, that's why it's crucial to take care of displaying and updating their balances. + +Because we are going to fetch balances in different components of our application, we will create a function in the `utils.ts` file and import it when necessary. + +In order to fetch the balances, we will use Taquito for the XTZ balance of the user and a very popular API on Tezos for tzBTC and SIRS balances, the [TzKT API](https://api.tzkt.io/). If you want to build more complex applications on Tezos, a good knowledge of the TzKT API is essential as it provides a lot of features that will make your apps richer in content and faster. + +Let's have a look at the function type: + +```typescript= +export const fetchBalances = async ( + Tezos: TezosToolkit, + userAddress: TezosAccountAddress +): Promise<{ + xtzBalance: number; + tzbtcBalance: number; + sirsBalance: number; +} | null> => { + try { + // the code will be here + } catch (error) { + console.error(error); + return null; + } +} +``` + +The `fetchBalances` function will take 2 parameters: an instance of the `TezosToolkit` to fetch the user's XTZ balance and the user's address to retrieve the balances that match the address. It will return an object with 3 properties: `xtzBalance`, `tzbtcBalance`, and `sirsBalance` or `null` if any error occurs. + +First, let's fetch the XTZ balance: + +```typescript= +const xtzBalance = await Tezos.tz.getBalance(userAddress); +if (!xtzBalance) throw "Unable to fetch XTZ balance"; +``` + +The instance of the `TezosToolkit` includes a property called `tz` that allows different Tezos-specific actions, one of them is about fetching the balance of an account by its address through the `getBalance()` method that takes the address of the account as a parameter. + +Next, we check for the existence of a balance and we reject the promise if it doesn't exist. If it does, the balance will be available as a [BigNumber](https://mikemcl.github.io/bignumber.js/). + +>*Note: as it is the case most of the time, Taquito returns numeric values from the blockchain as BigNumber, because some values could be very big numbers and JavaScript is notorious for being bad at handling large numbers* + +Once the XTZ balance has been fetched, we can continue and fetch the balances of tzBTC and SIRS: + +```typescript= +import { tzbtcAddress, sirsAddress } from "./config"; + +// previous code for the function + +const res = await fetch( + `https://api.tzkt.io/v1/tokens/balances?account=${userAddress}&token.contract.in=${tzbtcAddress},${sirsAddress}` + ); + if (res.status === 200) { + const data = await res.json(); + if (Array.isArray(data) && data.length === 2) { + const tzbtcBalance = +data[0].balance; + const sirsBalance = +data[1].balance; + if (!isNaN(tzbtcBalance) && !isNaN(sirsBalance)) { + return { + xtzBalance: xtzBalance.toNumber(), + tzbtcBalance, + sirsBalance + }; + } else { + return null; + } + } + } else { + throw "Unable to fetch tzBTC and SIRS balances"; + } +``` + +You can check [this link](https://api.tzkt.io/#operation/Tokens_GetTokenBalances) to get more details about how to fetch token balances with the TzKT API. It's a simple `fetch` with a URL that is built dynamically to include the user's address and the addresses of the contracts for tzBTC and SIRS. + +When the promise resolves with a `200` code, this means that the data has been received. We parse it into JSON with the `.json()` method on the response and we check that the data has the expected shape, i.e. an array with 2 elements in it. + +The first element is the tzBTC balance and the second one is the SIRS balance. We store them in their own variables that we cast to numbers before verifying that they were cast properly with `isNaN`. If everything goes well, the 3 balances are returned and if anything goes wrong along the way, the function returns `null`. + +After fetching the balances in any component of our application, we store this data in the store to update the state: + +```typescript= +const res = await fetchBalances($store.Tezos, userAddress); +if (res) { + store.updateUserBalance("XTZ", res.xtzBalance); + store.updateUserBalance("tzBTC", res.tzbtcBalance); + store.updateUserBalance("SIRS", res.sirsBalance); +} else { + store.updateUserBalance("XTZ", null); + store.updateUserBalance("tzBTC", null); + store.updateUserBalance("SIRS", null); +} +``` + +And that's it, you fetched the user's balances in XTZ, tzBTC, and SIRS! \ No newline at end of file diff --git a/src/pages/tutorials/index.md b/src/pages/tutorials/index.md index a27402eca..64425c072 100644 --- a/src/pages/tutorials/index.md +++ b/src/pages/tutorials/index.md @@ -8,8 +8,13 @@ Welcome to the Tezos Documentation Tutorials Portal. We're currently in _beta_. {% lg-links %} -{% lg-link title="Originating your first smart contract" icon="quickstart" href="/tezos-basics/originate-your-first-smart-contract/smartpy/" description="In 15 minutes, go from zero to hero and originate your first smart contract with SmartPy/LIGO" /%} +{% lg-link title="Originating your first smart contract" icon="deploy" href="/tutorials/originate-your-first-smart-contract/smartpy/" description="In 15 minutes, go from zero to hero and originate your first smart contract with SmartPy/LIGO" /%} + +{% lg-link title="Smart Rollups" icon="quickstart" href="/tutorials/smart-rollups" description="Get started by deploying your own smart rollup with our onboarding tutorial" /%} + +{% lg-link title="Build an NFT marketplace" icon="marketplace" href="/tutorials/build-an-nft-marketplace" description="Learn how to build your own NFT marketplace, with LIGO smart contracts and frontend, in 4 parts" /%} + +{% lg-link title="Build your First App on Tezos" icon="dapp" href="/tutorials/build-your-first-app/" description="Learn how to set up and create a decentralized web application on Tezos using TypeScript, Taquito and Svelte" /%} -{% lg-link title="Get started with Octez, the Tezos Client" icon="overview" href="/tezos-basics/get-started-with-octez/" description="Octez is the official client to interact with a Tezos node via RPC. Learn how to do the basics and more." /%} {% /lg-links %} diff --git a/src/pages/tutorials/originate-your-first-smart-contract/ligo/index.md b/src/pages/tutorials/originate-your-first-smart-contract/ligo/index.md new file mode 100644 index 000000000..aece733d1 --- /dev/null +++ b/src/pages/tutorials/originate-your-first-smart-contract/ligo/index.md @@ -0,0 +1,359 @@ +--- +id: first-smart-contract-ligo +title: Originate your First Smart Contract with LIGO +authors: John Joubert, Sasha Aldrick, Claude Barde +--- + +--- + +{% callout type="note" title="Want to use SmartPy?" %} +Click [here](/developers/docs/tutorials/originate-your-first-smart-contract/smartpy) to find out how to originate your first smart contract using SmartPy. +{% /callout %} + +## Prerequisites + +| Dependency | Installation instructions | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ligo | Follow the _Installation_ steps in this [guide](https://ligolang.org/docs/tutorials/getting-started/?lang=cameligo#install-ligo) | + +{% callout type="warning" title="Note" %} +Make sure you have **installed** the above CLI tools before getting started. +{% /callout %} + +Now that you have installed the [_octez-client_](https://opentezos.com/tezos-basics/cli-and-rpc/#how-to-install-the-octez-client) and [_Ligo_](https://ligolang.org/docs/tutorials/getting-started/?lang=cameligo#install-ligo), we'll go ahead and dive right in. + +Ligo is a high-level programming language created by Marigold to write smart contracts for the Tezos blockchain. + +It abstracts away the complexity of using Michelson (the smart contract language directly available on-chain) and provides different syntaxes that make it easier to write smart contracts on Tezos. + +The 2 syntaxes that are available at the moment are *JsLigo*, a syntax similar to TypeScript, and *CameLigo*, a syntax similar to OCaml. The following article will introduce CameLigo. + +## Create a project folder + +Now we can go ahead and create a folder somewhere on our local drive with the name of the project. Let's call it `example-smart-contract`. + +```bash +mkdir example-smart-contract +``` + +```bash +cd example-smart-contract +``` + +## Create a project file + +Inside the `example-smart-contract` folder, let's create a file called `increment.mligo` and save it. We'll need this file later. + +```bash +touch increment.mligo +``` + +## Confirm your setup +### Ligo + +You can run +```bash +./ligo version +``` +or +```bash +ligo version +``` +according to your setup to check if Ligo is properly installed. You should see something like: +``` sh +Protocol built-in: lima +0.60.0 +``` + +### Octez-client + +We can check that it's correctly installed by running the following command: + +``` sh +octez-client +``` + +And we should see something like this returned: + +``` sh +Usage: + octez-client [global options] command [command options] + octez-client --help (for global options) + octez-client [global options] command --help (for command options) + octez-client --version (for version information) + +To browse the documentation: + octez-client [global options] man (for a list of commands) + octez-client [global options] man -v 3 (for the full manual) + +Global options (must come before the command): + -d --base-dir : client data directory (absent: TEZOS_CLIENT_DIR env) + -c --config-file : configuration file + -t --timings: show RPC request times + --chain : chain on which to apply contextual commands (commands dependent on the context associated with the specified chain). Possible tags are 'main' and 'test'. + -b --block : block on which to apply contextual commands (commands dependent on the context associated with the specified block). Possible tags include 'head' and 'genesis' +/- an optional offset (e.g. "octez-client -b head-1 get timestamp"). Note that block queried must exist in node's storage. + -w --wait >: how many confirmation blocks are needed before an operation is considered included + -p --protocol : use commands of a specific protocol + -l --log-requests: log all requests to the node + --better-errors: Error reporting is more detailed. Can be used if a call to an RPC fails or if you don't know the input accepted by the RPC. It may happen that the RPC calls take more time however. + -A --addr : [DEPRECATED: use --endpoint instead] IP address of the node + -P --port : [DEPRECATED: use --endpoint instead] RPC port of the node + -S --tls: [DEPRECATED: use --endpoint instead] use TLS to connect to node. + -m --media-type : Sets the "media-type" value for the "accept" header for RPC requests to the node. The media accept header indicates to the node which format of data serialisation is supported. Use the value "json" for serialisation to the JSON format. + -E --endpoint : HTTP(S) endpoint of the node RPC interface; e.g. 'http://localhost:8732' + -s --sources : path to JSON file containing sources for --mode light. Example file content: {"min_agreement": 1.0, "uris": ["http://localhost:8732", "https://localhost:8733"]} + -R --remote-signer : URI of the remote signer + -f --password-filename : path to the password filename + -M --mode : how to interact with the node + +``` + +## Switch to a Testnet + +Before going further let's make sure we're working on a [Testnet](https://teztnets.xyz). + +View the available Testnets: + +``` sh +https://teztnets.xyz +``` + +The [Ghostnet](https://teztnets.xyz/ghostnet-about) might be a good choice for this guide (at the time of writing). + +Copy the _Public RPC endpoint_ which looks something like this: + +``` sh +https://rpc.ghostnet.teztnets.xyz +``` + +Make sure we use this endpoint by running: + +```bash +octez-client --endpoint https://rpc.ghostnet.teztnets.xyz config update +``` + +You should then see something like this returned: + +``` sh +Warning: + + This is NOT the Tezos Mainnet. + + Do NOT use your fundraiser keys on this network. +``` + +## Create a local wallet + +We're now going to create a local wallet to use throughout this guide. + +Run the following command to generate a local wallet with _octez-client_, making sure to replace `` with a name of your choosing: + +```bash +octez-client gen keys local_wallet +``` + +Let's get the address for this wallet because we'll need it later: + +```bash +octez-client show address local_wallet +``` + +Which will return something like this: + +``` sh +Warning: + + This is NOT the Tezos Mainnet. + + Do NOT use your fundraiser keys on this network. + +Hash: tz1dW9Mk...........H67L +Public Key: edp.............................bjbeDj +``` + +We'll want to copy the Hash that starts with `tz` to your clipboard: + +``` sh +tz1dW9Mk...........H67L +``` + +## Fund your test wallet + +Tezos provides a [faucet](https://faucet.ghostnet.teztnets.xyz) to allow you to use the Testnet for free (has no value and can't be used on the Mainnet). + +Let's go ahead and fund our wallet through the [Ghostnet Faucet](https://faucet.ghostnet.teztnets.xyz). Paste the hash you copied earlier into the input field for "Or fund any address" and select the amount you'd like to add to your wallet. + +![Fund your wallet using the Ghostnet Faucet](/developers/docs/images/wallet-funding.png) + +Wait a minute or two and you can then run the following command to check that your wallet has funds in it: + +``` sh + octez-client get balance for local_wallet +``` + +Which will return something like this: + +``` sh +100 ꜩ +``` + +## Use Ligo to create the contract + +For this introduction to Ligo, you will write a very simple contract that increments, decrements, or resets a number in its storage. + +A contract is made of 3 main parts: +- a parameter type to update the storage +- a storage type to describe how values are stored +- a piece of code that controls the update of the storage + +The purpose of a smart contract is to write code that will use the values passed as a parameter to manipulate and update the storage in the intended way. + +The contract will store an integer: + +``` sh +type storage = int +``` + +The parameter to update the contract storage is a *variant*, similar to a TypeScript enum: + +``` sh +type parameter = +| Increment of int +| Decrement of int +| Reset +``` + +You can use the different branches of the variant to simulate entrypoints for your contract. In this case, there is an **Increment** entrypoint, a **Decrement** entrypoint, and a **Reset** entrypoint. + +Next, you declare a function called `main` that will receive the parameter value and the storage when the contract is called. This function returns a tuple with a list of operations on the left and the new storage on the right: + +``` sh +let main (action, store : parameter * storage) : operation list * storage = +``` + +You can return an empty list of operations from the beginning, then use pattern matching to match the targetted entrypoint: +``` sh +([] : operation list), + (match action with + | Increment (n) -> add (store, n) + | Decrement (n) -> sub (store, n) + | Reset -> 0) +``` + +The **Increment** branch redirects to an `add` function that takes a tuple as a parameter made of the current storage and the value used to increment the storage. + +The **Decrement** branch redirects to a `sub` function that takes a tuple as a parameter made of the current storage and the value used to decrement the storage. + +The **Reset** branch only returns `0`, the new storage. + +The `add` function: + +```bash +let add (store, inc : storage * int) : storage = store + inc +``` +takes a tuple with the current storage on the left and the value to increment it on the right. These 2 values are added and returned as the new storage. + +The `sub` function: + +```bash +let sub (store, dec : storage * int) : storage = store - dec +``` +takes a tuple with the current storage on the left and the value to subtract from it on the right. The passed value is subtracted from the current storage and the new storage is returned. + +``` sh +type storage = int + +type parameter = +| Increment of int +| Decrement of int +| Reset + +// Increment entrypoint +let add (store, inc : storage * int) : storage = store + inc +// Decrement entrypoint +let sub (store, dec : storage * int) : storage = store - dec + +let main (action, store : parameter * storage) : operation list * storage = + ([] : operation list), // No operations + (match action with + | Increment (n) -> add (store, n) + | Decrement (n) -> sub (store, n) + | Reset -> 0) + +``` + +## Compile the smart contract to Michelson + +You can now compile the contract to Michelson directly from the terminal with the following command: + +```bash +ligo compile contract increment.mligo -o increment.tz +``` + +You can also test that the contract works by calling one of its entrypoints with this command: + +```bash +ligo run dry-run increment.mligo "Increment(32)" "10" +``` + +This should return `(LIST_EMPTY(), 42)` if everything is correct. + +## Originate to the Testnet + +Run the following command to originate the smart contract: +```bash +octez-client originate contract increment \ + transferring 0 from \ + running increment.tz \ + --init 10 --burn-cap 0.1 --force +``` + +This will originate the contract with an initial storage of `10`. + +You should get a confirmation that your smart contract has been originated: + +```bash +New contract KT1Nnk.................UFsJrq originated. +The operation has only been included 0 blocks ago. +We recommend to wait more. +``` + +Make sure you copy the contract address for the next step! + +## Confirm that all worked as expected + +To interact with the contract and confirm that all went as expected, you can use an Explorer such as: [TzKT](https://tzkt.io) or [Better Call Dev](https://better-call.dev/). + +Make sure you have switched to [Ghostnet](https://ghostnet.tzkt.io) before you start looking. + +Then paste the contract address (starting with KT1) `KT1Nnk.................UFsJrq` into the search field and hit `enter` to find it. + +Then navigate to the `Storage` tab to see your initial value of `10`. + +## Calling the entrypoints + +Now that we've successfully originated our smart contract, let's test out the three entrypoints that we created: `increment`, `decrement`, and `reset`. + +#### Increment + +To increment the current storage by a certain value, you can call the `increment` entrypoint: + +```bash +octez-client --wait none transfer 0 from local_wallet to increment --entrypoint 'increment' --arg '5' --burn-cap 0.1 +``` + +#### Decrement + +To decrement the current storage by a certain value, you can call the `decrement` entrypoint: + +```bash +octez-client --wait none transfer 0 from local_wallet to increment --entrypoint 'decrement' --arg '6' --burn-cap 0.1 +``` + +#### Reset + +Finally, to reset the current storage to zero, you can call the `reset` entrypoint: + +```bash +octez-client --wait none transfer 0 from local_wallet to increment --entrypoint 'reset' --arg 'Unit' --burn-cap 0.1 +``` \ No newline at end of file diff --git a/src/pages/tutorials/originate-your-first-smart-contract/smartpy/index.md b/src/pages/tutorials/originate-your-first-smart-contract/smartpy/index.md new file mode 100644 index 000000000..ef0de411b --- /dev/null +++ b/src/pages/tutorials/originate-your-first-smart-contract/smartpy/index.md @@ -0,0 +1,331 @@ +--- +id: first-smart-contract-smartpy +title: Originate your First Smart Contract with SmartPy +slug: /first-smart-contract +authors: John Joubert, Sasha Aldrick +--- + +{% callout type="note" title="Want to use LIGO?" %} +Click [here](/developers/docs/tutorials/originate-your-first-smart-contract/ligo) to find out how to originate your first smart contract using LIGO. +{% /callout %} + +## Prerequisites + +| Dependency | Installation instructions | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SmartPy | Follow the _Installation_ steps in this [guide](https://smartpy.dev/docs/manual/introduction/installation). SmartPy requires Docker to run. For MacOS and Linux, it is recommended to install [Docker Desktop](https://www.docker.com/products/docker-desktop/). | +| _octez-client_ CLI | Follow the _How to install the octez-client_ steps [here](/developers/docs/tezos-basics/get-started-with-octez/). | + +{% callout type="warning" title="Note" %} +Make sure you have **installed** the above CLI tools before getting started. +{% /callout %} + +Now that you have installed the [_octez-client_](https://opentezos.com/tezos-basics/cli-and-rpc/#how-to-install-the-octez-client) and [_Smartpy_](https://smartpy.io/docs/cli/#installation), we'll go ahead and dive right in. + +## Create a project folder + +Now we can go ahead and create a folder somewhere on our local drive with the name of the project. Let's call it `example-smart-contract`. + +```bash +mkdir example-smart-contract +``` + +```bash +cd example-smart-contract +``` + +## Create a project file + +Inside the `example-smart-contract` folder, let's create a file called `store_greeting.py` and save it. We'll need this file later. + +```bash +touch store_greeting.py +``` + +## Confirm your setup + +### Smartpy + +The preferred way of running SmartPy is via the `smartPy` wrapper. To obtain the SmartPy executable within your local project folder: + +```bash +wget smartpy.io/smartpy +chmod a+x smartpy +``` + +If you are missing `wget` on MacOS, you can use `brew install wget` or the package manager of your choice. + +This creates a local executable file named `smartpy` which we will use to to compile our contract. + +We can check that it's correctly installed by running the following command: + +```bash +./smartpy +``` + +And we should see something like this returned: + +``` +./smartpy +Usage: + ./smartpy test