Training n°2 for NFT marketplace
This time we will add the ability to buy and sell NFT!
Keep your code from previous training or get the solution here
If you clone/fork a repo, rebuild locally
npm i
cd ./app
yarn install
cd ..
Add these code sections on your nft.jsligo
smart contract
type offer = {
owner : address,
price : nat
};
offers
field to storagetype storage =
{
administrators: set<address>,
offers: map<nat,offer>, //user sells an offer
ledger: NFT.Ledger.t,
metadata: NFT.Metadata.t,
token_metadata: NFT.TokenMetadata.t,
operators: NFT.Operators.t,
token_ids : set<NFT.Storage.token_id>
};
Buy
and Sell
to parametertype 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];
Buy
and Sell
inside main
functionconst 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) => [list([]),s],
Balance_of: (p: NFT.balance_of) => [list([]),s],
Update_operators: (p: NFT.update_operator) => [list([]),s],
});
Explanations:
offer
is an NFT (owned by someone) with a pricestorage
has a new field to store offers
: a map
of offersparameter
has two new entrypoints buy
and sell
main
function exposes these two new entrypointsAlso update the initial storage on file nft.storages.jsligo
to initialize offers
#include "nft.jsligo"
const default_storage =
{administrators: Set.literal(list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
as address]))
as set<address>,
offers: Map.empty as map<nat,offer>,
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>
};
Finally, compile the contract
TAQ_LIGO_IMAGE=ligolang/ligo:0.57.0 taq compile nft.jsligo
Define the sell
function as below:
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<operation>,{...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
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) => [list([]),s],
Balance_of: (p: NFT.balance_of) => [list([]),s],
Update_operators: (p: NFT.update_operator) => [list([]),s],
});
Explanations:
storage
to publish the offersell
function inside the sell
case on main
Now that we have offers available on the marketplace, let's buy bottles!
Edit the smart contract to add the buy
feature
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<operation>, {...s, offers : Map.update(token_id,None(),s.offers), ledger : ledger}];
}
});
};
Call buy
function on main
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) => [list([]),s],
Balance_of: (p: NFT.balance_of) => [list([]),s],
Update_operators: (p: NFT.update_operator) => [list([]),s],
});
Explanations:
token_id
or return an error if it does not existsell
function inside the sell
case on main
We finished the smart contract implementation of this second training, let's deploy to ghostnet.
TAQ_LIGO_IMAGE=ligolang/ligo:0.57.0 taq compile nft.jsligo
taq deploy nft.tz -e "testing"
āāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāā¬āāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Contract ā Address ā Alias ā Balance In Mutez ā Destination ā
āāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāā¼āāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā nft.tz ā KT1PLvVGaM4YE1qLSdLZUZ1EhozqzYUQ1xed ā nft ā 0 ā https://ghostnet.ecadinfra.com ā
āāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāā“āāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
š Hooray! We have implemented and deployed the smart contract (backend) š
Generate Typescript classes and go to the frontend to run the server
taq generate types ./app/src
cd ./app
yarn install
yarn run start
Edit Sale Page on ./src/OffersPage.tsx
Add this code inside the file :
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<number>(0);
const [currentPageIndex, setCurrentPageIndex] = useState<number>(1);
let [offersTokenIDMap, setOffersTokenIDMap] = React.useState<Map<nat, Offer>>(
new Map()
);
let [ownerTokenIds, setOwnerTokenIds] = React.useState<Set<nat>>(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 (
<Paper>
<Typography style={{ paddingBottom: "10px" }} variant="h5">
Sell my bottles
</Typography>
{ownerTokenIds && ownerTokenIds.size != 0 ? (
<Fragment>
<Pagination
page={currentPageIndex}
onChange={(_, value) => setCurrentPageIndex(value)}
count={Math.ceil(
Array.from(ownerTokenIds.entries()).length / itemPerPage
)}
showFirstButton
showLastButton
/>
<ImageList
cols={isDesktop ? itemPerPage / 2 : isTablet ? itemPerPage / 3 : 1}
>
{Array.from(ownerTokenIds.entries())
.filter((_, index) =>
index >= currentPageIndex * itemPerPage - itemPerPage &&
index < currentPageIndex * itemPerPage
? true
: false
)
.map(([token_id]) => (
<Card key={token_id + "-" + token_id.toString()}>
<CardHeader
avatar={
<Tooltip
title={
<Box>
<Typography>
{" "}
{"ID : " + token_id.toString()}{" "}
</Typography>
<Typography>
{"Description : " +
nftContratTokenMetadataMap.get(
token_id.toNumber()
)?.description}
</Typography>
</Box>
}
>
<InfoOutlined />
</Tooltip>
}
title={
nftContratTokenMetadataMap.get(token_id.toNumber())?.name
}
/>
<CardMedia
sx={{ width: "auto", marginLeft: "33%" }}
component="img"
height="100px"
image={nftContratTokenMetadataMap
.get(token_id.toNumber())
?.thumbnailUri?.replace(
"ipfs://",
"https://gateway.pinata.cloud/ipfs/"
)}
/>
<CardContent>
<Box>
<Typography variant="body2">
{offersTokenIDMap.get(token_id)
? "Traded : " +
1 +
" (price : " +
offersTokenIDMap
.get(token_id)
?.price.dividedBy(1000000) +
" Tz)"
: ""}
</Typography>
</Box>
</CardContent>
<CardActions>
{!userAddress ? (
<Box marginLeft="5vw">
<ConnectButton
Tezos={Tezos}
nftContratTokenMetadataMap={
nftContratTokenMetadataMap
}
setUserAddress={setUserAddress}
setUserBalance={setUserBalance}
wallet={wallet}
/>
</Box>
) : (
<form
style={{ width: "100%" }}
onSubmit={(values) => {
setSelectedTokenId(token_id.toNumber());
formik.handleSubmit(values);
}}
>
<span>
<TextField
type="number"
name="price"
label="price"
placeholder="Enter a price"
variant="filled"
value={formik.values.price}
onChange={formik.handleChange}
error={
formik.touched.price &&
Boolean(formik.errors.price)
}
helperText={
formik.touched.price && formik.errors.price
}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Button
type="submit"
aria-label="add to favorites"
>
<SellIcon /> Sell
</Button>
</InputAdornment>
),
}}
/>
</span>
</form>
)}
</CardActions>
</Card>
))}{" "}
</ImageList>
</Fragment>
) : (
<Typography sx={{ py: "2em" }} variant="h4">
Sorry, you don't own any bottles, buy or mint some first
</Typography>
)}
</Paper>
);
}
Explanations :
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 smartcontract manipulates mutez.Connect with your wallet an 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
Enter these values on the form for example :
name
: Saint Emilion - Franc la Rosesymbol
: SEMILdescription
: Grand cru 2007Click on Upload an image
an select a bottle picture on your computer
Click on Mint button
Your picture will be pushed to IPFS and be 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.
Now, go to the Trading
menu and the Sell bottles
sub menu.
Click on the sub-menu entry
You are owner of this bottle so you can create an offer to sell it.
SELL
buttonEdit the Wine Catalogue page on ./src/WineCataloguePage.tsx
Add the following code inside the file
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<OfferEntry | null>(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<number>(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 (
<Paper>
<Typography style={{ paddingBottom: "10px" }} variant="h5">
Wine catalogue
</Typography>
{storage?.offers && storage?.offers.size != 0 ? (
<Fragment>
<Pagination
page={currentPageIndex}
onChange={(_, value) => setCurrentPageIndex(value)}
count={Math.ceil(
Array.from(storage?.offers.entries()).length / itemPerPage
)}
showFirstButton
showLastButton
/>
<ImageList
cols={isDesktop ? itemPerPage / 2 : isTablet ? itemPerPage / 3 : 1}
>
{Array.from(storage?.offers.entries())
.filter((_, index) =>
index >= currentPageIndex * itemPerPage - itemPerPage &&
index < currentPageIndex * itemPerPage
? true
: false
)
.map(([token_id, offer]) => (
<Card key={offer.owner + "-" + token_id.toString()}>
<CardHeader
avatar={
<Tooltip
title={
<Box>
<Typography>
{" "}
{"ID : " + token_id.toString()}{" "}
</Typography>
<Typography>
{"Description : " +
nftContratTokenMetadataMap.get(
token_id.toNumber()
)?.description}
</Typography>
<Typography>
{"Seller : " + offer.owner}{" "}
</Typography>
</Box>
}
>
<InfoOutlined />
</Tooltip>
}
title={
nftContratTokenMetadataMap.get(token_id.toNumber())?.name
}
/>
<CardMedia
sx={{ width: "auto", marginLeft: "33%" }}
component="img"
height="100px"
image={nftContratTokenMetadataMap
.get(token_id.toNumber())
?.thumbnailUri?.replace(
"ipfs://",
"https://gateway.pinata.cloud/ipfs/"
)}
/>
<CardContent>
<Box>
<Typography variant="body2">
{" "}
{"Price : " + offer.price.dividedBy(1000000) + " XTZ"}
</Typography>
</Box>
</CardContent>
<CardActions>
{!userAddress ? (
<Box marginLeft="5vw">
<ConnectButton
Tezos={Tezos}
nftContratTokenMetadataMap={
nftContratTokenMetadataMap
}
setUserAddress={setUserAddress}
setUserBalance={setUserBalance}
wallet={wallet}
/>
</Box>
) : (
<form
style={{ width: "100%" }}
onSubmit={(values) => {
setSelectedOfferEntry([token_id, offer]);
formik.handleSubmit(values);
}}
>
<Button type="submit" aria-label="add to favorites">
<ShoppingCartIcon /> BUY
</Button>
</form>
)}
</CardActions>
</Card>
))}
</ImageList>
</Fragment>
) : (
<Typography sx={{ py: "2em" }} variant="h4">
Sorry, there is not NFT to buy yet, you need to mint or sell bottles
first
</Typography>
)}
</Paper>
);
}
Now you can see on Trading
menu the Wine catalogue
sub menu, click on it.
As you are connected with the default administrator you can see your own unique offer on the market
BUY
buttonbottle offers
sub menuYou created an NFT collection marketplace from the ligo library, now you can buy and sell NFTs at your own price.
This concludes the NFT training!
On next training, you will see another kind of NFT called single asset
. Instead of creating x token types, you will be authorize to create only 1 token_id 0, on the other side, you can mint a quantity n of this token.