Create an NFT marketplace (for wine collection) on Tezos
Reuse the FA2.0 ligo library to create your own wine bottle NFTs and extend it to become a real marketplace on Tezos
Written by
Business objects managed by a blockchain are called assets
. On Tezos you will find the term Financial Asset or FA
with different version 1, 2 or 2.1.
Here below different categorization of assets.
We are going to build a Wine marketplace extending the @ligo/fa
package from the Ligo repository. 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 a generic NFT contract :
You can play with the final demo.
Plan of the training course :
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 |
Note : because we are in web3, buy or sell features are a real payment system using onchain XTZ token as money. This differs from traditional web2 applications where you have to integrate payment system and so, pay extra fees.
A non-fungible token is a unique and non-interchangeable unit of data stored on a digital ledger. NFTs can be used to represent easily-reproducible items such as photos, videos, audio, and other types of digital files as unique items, and use blockchain technology to establish a verified and public proof of ownership.
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 (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 usage quota.
We will use two contracts for the marketplace.
On Tezos, FA2 is the standard for Non-fungible Token contracts. We will be using the template provided by Ligo to build out the Token Contract. The template contains the basic entrypoints for building a Fungible or Non-fungible token including:
On a second time, we will import the token contract into the marketplace unique contract. The latter will bring missing features as:
VS Code
: as code editornpm
: front-end is a typescript React client appyarn
: to build and run the front-end (see this article for more details about differences between npm
and yarn
)taqueria
: Tezos Dapp project tooling (version >= 0.28.5-rc)taqueria
taqueria
JSON dataWe will use taqueria
to shape the project structure, then create the NFT marketplace smart contract thanks to the ligo/fa
library.
Note : you will require to copy some code from this git repository later, so you can clone it if you want doing :
git clone https://github.com/marigold-dev/training-nft-1.git
taq init training
cd training
taq install @taqueria/plugin-ligo@next
⚠️ HACK note : 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
echo "{}" > esy.json
Your project is ready!
We will rely on Ligo FA library. To understand in details how assets work on Tezos, please read below notes:
Additional contract metadata can be added to ease displaying token picture, etc. this is described on TZIP-21 standard
Install the ligo/fa
library locally:
TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq ligo --command "install @ligo/fa"
Create the nft marketplace contract with taqueria
taq create contract nft.jsligo
Remove the default code and paste this code instead
#import "@ligo/fa/lib/fa2/nft/NFT.jsligo" "NFT"
/* ERROR MAP FOR UI DISPLAY or TESTS
const errorMap : map<string,string> = 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<address>,
ledger: NFT.Ledger.t,
metadata: NFT.Metadata.t,
token_metadata: NFT.TokenMetadata.t,
operators: NFT.Operators.t,
token_ids : set<NFT.Storage.token_id>
};
type ret = [list<operation>, 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:
#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 same namingNFT.Ledger.t
: keep/trace ownership of tokensNFT.Metadata.t
: tzip-16 complianceNFT.TokenMetadata.t
: tzip-12 complianceNFT.Operators.t
: permissions part of FA2 standardNFT.Storage.token_id>
: cache for keys of token_id bigmapstorage
has more fields to support a set of administrators
parameter
definition is an extension of the imported library entrypointsNFT.transfer
: to transfer NFTsNFT.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 manager our NFTparameter
has more entrypoints to allow to create nfts Mint
parameter
has an entrypoint AddAdministrator
to add new administrators. Administrators will be allowed to mint NFTsCompile the contract
TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo
Note : to be sure that taqueria will use ligo v0.57 that contains the ligo package installer w/ Docker fix, we set the env var
TAQ_LIGO_IMAGE
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 by this one
Transfer: (p: NFT.transfer) => {
const ret2 : [list<operation>, 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<operation>, 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<operation>, 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:
Note : Ligo team is working on merging type definitions. You could be able to do
type union
ormerge 2 objects
like in Typescript
Let's add the Mint
function now. Add the new function, and update the main function
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<string, bytes> =
Map.literal(list([
["name", name],
["description",description],
["interfaces", (bytes `["TZIP-12"]`)],
["thumbnailUri", ipfsUrl],
["symbol",symbol],
["decimals", (bytes `0`)]
])) as map<string, bytes>;
const metadata : bytes = bytes
`{
"name":"FA2 NFT Marketplace",
"description":"Example of FA2 implementation",
"version":"0.0.1",
"license":{"name":"MIT"},
"authors":["Marigold<contact@marigold.dev>"],
"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<operation>,
{...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 displaydecimals
that is set to 0
. A unique nft does not have decimals, it is a unitquantity
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.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 access through a key, but you need to know the key, there is no function to return the keyset. This is why we keep trace of all token_id in this set, so we can loop and read/update information on nftsWe 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. (⚠️ you can change the administrator
address to your own address or keep alice
)
#include "nft.jsligo"
const default_storage =
{administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
as address]))
as set<address>,
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<NFT.Storage.token_id>
};
Compile again and deploy to ghostnet
TAQ_LIGO_IMAGE=ligolang/ligo:0.64.2 taq compile nft.jsligo
taq install @taqueria/plugin-taquito@next
taq deploy nft.tz -e "testing"
Note : if it is the first time you use
taqueria
, I recommend to look at this training first
For advanced users, just go to
.taq/config.json
and change the default account on path/network/ghostnet/accounts
to alice settings (publicKey,publicKeyHash,privateKey) and then redeploy:
"accounts": { "taqOperatorAccount": { "publicKey": "edpkvGfYw3LyB1UcCahKQk4rF2tvbMUk8GFiTuMjL75uGXrpvKXhjn", "publicKeyHash": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb", "privateKey": "edsk3QoqBuvdamxouPhin7swCvkQNgq4jP5KZPbwWNnwdZpSpJiEbq" } }
┌──────────┬──────────────────────────────────────┬───────┬──────────────────┬────────────────────────────────┐
│ Contract │ Address │ Alias │ Balance In Mutez │ Destination │
├──────────┼──────────────────────────────────────┼───────┼──────────────────┼────────────────────────────────┤
│ nft.tz │ KT1QTwcQWpf4MRM5sjoGC6ASn4Y5fJNsTqWd │ nft │ 0 │ https://ghostnet.ecadinfra.com │
└──────────┴──────────────────────────────────────┴───────┴──────────────────┴────────────────────────────────┘
🎉 Hooray! We have finished the backend 🎉
To win time, we have a boilerplate ready for the UI
Copy this code into your folder (⚠️ assuming you have cloned this repo, and your current path is $REPO/training
)
cp -r ../reactboilerplateapp/ ./app
Note : if you want to understand how it has been made from scratch look at this training
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
taq install @taqueria/plugin-contract-types@next
taq generate types ./app/src
cd app
yarn install
yarn run start
Note : On
Mac
🍏,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",
Website is ready! You have:
taqueria
last deployed contract address at each startIf you try to connect you are redirected to /
path that is also the wine catalog.
There is no bottle collections yet, so we need to create the mint page.
Edit default Mint Page on ./src/MintPage.tsx
In MintPage.tsx
, replace the HTML
template by this one :
<Paper>
{storage ? (
<Button
disabled={storage.administrators.indexOf(userAddress! as address) < 0}
sx={{
p: 1,
position: "absolute",
right: "0",
display: formOpen ? "none" : "block",
zIndex: 1,
}}
onClick={toggleDrawer(!formOpen)}
>
{" Mint Form " +
(storage!.administrators.indexOf(userAddress! as address) < 0
? " (You are not admin)"
: "")}
<OpenWithIcon />
</Button>
) : (
""
)}
<SwipeableDrawer
onClose={toggleDrawer(false)}
onOpen={toggleDrawer(true)}
anchor="right"
open={formOpen}
variant="temporary"
>
<Toolbar
sx={
isTablet
? { marginTop: "0", marginRight: "0" }
: { marginTop: "35px", marginRight: "125px" }
}
/>
<Box
sx={{
width: isTablet ? "40vw" : "60vw",
borderColor: "text.secondary",
borderStyle: "solid",
borderWidth: "1px",
height: "calc(100vh - 64px)",
}}
>
<Button
sx={{
position: "absolute",
right: "0",
display: !formOpen ? "none" : "block",
}}
onClick={toggleDrawer(!formOpen)}
>
<Close />
</Button>
<form onSubmit={formik.handleSubmit}>
<Stack spacing={2} margin={2} alignContent={"center"}>
<Typography variant="h5">Mint a new collection</Typography>
<TextField
id="standard-basic"
name="token_id"
label="token_id"
value={formik.values.token_id}
disabled
variant="filled"
/>
<TextField
id="standard-basic"
name="name"
label="name"
required
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="filled"
/>
<TextField
id="standard-basic"
name="symbol"
label="symbol"
required
value={formik.values.symbol}
onChange={formik.handleChange}
error={formik.touched.symbol && Boolean(formik.errors.symbol)}
helperText={formik.touched.symbol && formik.errors.symbol}
variant="filled"
/>
<TextField
id="standard-basic"
name="description"
label="description"
required
multiline
minRows={2}
value={formik.values.description}
onChange={formik.handleChange}
error={
formik.touched.description &&
Boolean(formik.errors.description)
}
helperText={
formik.touched.description && formik.errors.description
}
variant="filled"
/>
{pictureUrl ? (
<img height={100} width={100} src={pictureUrl} />
) : (
""
)}
<Button variant="contained" component="label" color="primary">
<AddCircleOutlined />
Upload an image
<input
type="file"
hidden
name="data"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const data = e.target.files ? e.target.files[0] : null;
if (data) {
setFile(data);
}
e.preventDefault();
}}
/>
</Button>
<Button variant="contained" type="submit">
Mint
</Button>
</Stack>
</form>
</Box>
</SwipeableDrawer>
<Typography variant="h5">Mint your wine collection</Typography>
{nftContratTokenMetadataMap.size != 0 ? (
"//TODO"
) : (
<Typography sx={{ py: "2em" }} variant="h4">
Sorry, there is not NFT yet, you need to mint bottles first
</Typography>
)}
</Paper>
Add formik
form to your Component function inside the same MintPage.tsx
file:
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:
const [pictureUrl, setPictureUrl] = useState<string>("");
const [file, setFile] = useState<File | null>(null);
Add drawer variables to manager the side-popup of the form:
//open mint drawer if admin
const [formOpen, setFormOpen] = useState<boolean>(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:
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 the mint
function and related imports :
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";
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",
`undefined`
);
requestHeaders.set(
"pinata_secret_api_key",
`undefined`
);
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,
});
}
};
Explanations:
pinata API
to push the file to IPFS
. It returns the hashbytes
. 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 sideNote : 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, everytime you have a new token minted, you increment the counter for the next one
useEffect(() => {
(async () => {
if (storage && storage.token_ids.length > 0) {
formik.setFieldValue("token_id", storage?.token_ids.length);
}
})();
}, [storage?.token_ids]);
Replace the "//TODO"
keyword by this template
<Box sx={{ width: "70vw" }}>
<SwipeableViews
axis="x"
index={activeStep}
onChangeIndex={handleStepChange}
enableMouseEvents
>
{Array.from(nftContratTokenMetadataMap!.entries()).map(
([token_id, token]) => (
<Card
sx={{
display: "block",
maxWidth: "80vw",
overflow: "hidden",
}}
key={token_id.toString()}
>
<CardHeader
titleTypographyProps={
isTablet ? { fontSize: "1.5em" } : { fontSize: "1em" }
}
title={token.name}
/>
<CardMedia
sx={
isTablet
? {
width: "auto",
marginLeft: "33%",
maxHeight: "50vh",
}
: { width: "100%", maxHeight: "40vh" }
}
component="img"
image={token.thumbnailUri?.replace(
"ipfs://",
"https://gateway.pinata.cloud/ipfs/"
)}
/>
<CardContent>
<Box>
<Typography>{"ID : " + token_id}</Typography>
<Typography>{"Symbol : " + token.symbol}</Typography>
<Typography>
{"Description : " + token.description}
</Typography>
</Box>
</CardContent>
</Card>
)
)}
</SwipeableViews>
<MobileStepper
variant="text"
steps={Array.from(nftContratTokenMetadataMap!.entries()).length}
position="static"
activeStep={activeStep}
nextButton={
<Button
size="small"
onClick={handleNext}
disabled={
activeStep ===
Array.from(nftContratTokenMetadataMap!.entries()).length - 1
}
>
Next
<KeyboardArrowRight />
</Button>
}
backButton={
<Button
size="small"
onClick={handleBack}
disabled={activeStep === 0}
>
<KeyboardArrowLeft />
Back
</Button>
}
/>
</Box>
Add missing imports and parameters
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";
const [activeStep, setActiveStep] = React.useState(0);
const handleNext = () => {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleStepChange = (step: number) => {
setActiveStep(step);
};
Connect with your wallet an 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.
Create your first wine bottle, for example:
name
: Saint Emilion - Franc la Rosesymbol
: SEMILdescription
: Grand cru 2007Click on Upload an image
and select a bottle picture on your computer
Click on Mint button
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
You are able to create an NFT collection marketplace from the ligo/fa
library.
On next training, you will add the Buy and Sell functions to your smart contract and update the frontend to allow these actions.
Original author: Benjamin Fuentes. You can get in touch with the author on GitHub or Twitter. You can get in touch with Marigold on Marigold website.
Stay up to date on all things Tezos