Part 2: Buying and selling tokens
In this section, you give users the ability to list a bottle for sale and buy bottles that are listed for sale.
You can continue from your code from part 1 or start from the completed version here: https://github.com/marigold-dev/training-nft-1/tree/main/solution.
If you start from the completed version, run these commands to install dependencies for the web application:
npm i
cd ./app
yarn install
cd ..
Updating the smart contract
To allow users to buy and sell tokens, the contract must have entrypoints that allow users to offer a token for sale and to buy a token that is offered for sale. The contract storage must store the tokens that are offered for sale and their prices.
- 
Update the contract storage to store the tokens that are offered for sale: - 
In the nft.jsligofile, before the definition of thestoragetype, add a type that represents a token that is offered for sale:export type offer = {
 owner : address,
 price : nat
 };
- 
Add a map named offersthat maps token IDs to their offer prices to thestoragetype. Now thestoragetype looks like this:export type storage = {
 administrators: set<address>,
 offers: map<nat, offer>, //user sells an offer
 ledger: FA2Impl.NFT.ledger,
 metadata: FA2Impl.TZIP16.metadata,
 token_metadata: FA2Impl.TZIP12.tokenMetadata,
 operators: FA2Impl.NFT.operators
 };
- 
In the nft.storageList.jsligofile, add an empty map for the offers by adding this code:offers: Map.empty as map<nat, Contract.offer>,Now the nft.storageList.jsligofile looks like this:#import "nft.jsligo" "Contract"
 const default_storage : Contract.storage = {
 administrators: Set.literal(
 list(["tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" as address])
 ) as set<address>,
 offers: Map.empty as map<nat, Contract.offer>,
 ledger: Big_map.empty as Contract.FA2Impl.NFT.ledger,
 metadata: Big_map.literal(
 list(
 [
 ["", bytes `tezos-storage:data`],
 [
 "data",
 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": []
 }`
 ]
 ]
 )
 ) as Contract.FA2Impl.TZIP16.metadata,
 token_metadata: Big_map.empty as Contract.FA2Impl.TZIP12.tokenMetadata,
 operators: Big_map.empty as Contract.FA2Impl.NFT.operators,
 };
 
- 
- 
As you did in the previous step, make sure that the administrators in the nft.storageList.jsligofile includes an address that you can use to mint tokens.
- 
In the nft.jsligofile, add asellentrypoint that creates an offer for a token that the sender owns:@entry
 const sell = ([token_id, price]: [nat, nat], s: storage): ret => {
 //check balance of seller
 const sellerBalance =
 FA2Impl.NFT.get_balance(
 [Tezos.get_source(), token_id],
 {
 ledger: s.ledger,
 metadata: s.metadata,
 operators: s.operators,
 token_metadata: s.token_metadata,
 }
 );
 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 =
 FA2Impl.NFT.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
 }
 ]
 };This function accepts the ID of the token and the selling price as parameters. It verifies that the transaction sender owns the token. Then it adds the contract itself as an operator of the token, which allows it to transfer the token without getting permission from the seller later. Finally, it adds the offer and updated operators to the storage. 
- 
Add a buyentrypoint:@entry
 const buy = ([token_id, seller]: [nat, address], s: storage): ret => {
 //search for the offer
 return match(Map.find_opt(token_id, s.offers)) {
 when (None()):
 failwith("3")
 when (Some(offer)):
 do {
 //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 =
 FA2Impl.NFT.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
 }
 ]
 }
 }
 };This entrypoint accepts the token ID and seller as parameters. It retrieves the offer from storage and verifies that the transaction sender sent enough tez to satisfy the offer price. Then it transfers the token to the buyer, transfers the sale price to the seller, and removes the offer from storage. 
- 
Compile and deploy the new contract: TAQ_LIGO_IMAGE=ligolang/ligo:1.1.0 taq compile nft.jsligo
 taq deploy nft.tz -e "testing"
Adding a selling page to the web application
- 
Stop the web application if it is running. 
- 
Generate the TypeScript classes and start the server: taq generate types ./app/src
 cd ./app
 yarn dev
- 
Open the sale page in the ./src/OffersPage.tsxfile and replace it with this code:import { InfoOutlined } from '@mui/icons-material';
 import SellIcon from '@mui/icons-material/Sell';
 import * as api from '@tzkt/sdk-api';
 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() {
 api.defaults.baseUrl = 'https://api.ghostnet.tzkt.io';
 const [selectedTokenId, setSelectedTokenId] = React.useState<number>(0);
 const [currentPageIndex, setCurrentPageIndex] = useState<number>(1);
 let [offersTokenIDMap, setOffersTokenIDMap] = React.useState<
 Map<string, Offer>
 >(new Map());
 let [ownerTokenIds, setOwnerTokenIds] = React.useState<Set<string>>(
 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();
 const token_metadataBigMapId = (
 storage.token_metadata as unknown as { id: BigNumber }
 ).id.toNumber();
 const token_ids = await api.bigMapsGetKeys(token_metadataBigMapId, {
 micheline: 'Json',
 active: true,
 });
 await Promise.all(
 token_ids.map(async (token_idKey) => {
 const token_idNat = new BigNumber(token_idKey.key) as nat;
 let owner = await storage.ledger.get(token_idNat);
 if (owner === userAddress) {
 ownerTokenIds.add(token_idKey.key);
 const ownerOffers = await storage.offers.get(token_idNat);
 if (ownerOffers)
 offersTokenIDMap.set(token_idKey.key, ownerOffers);
 console.log(
 'found for ' +
 owner +
 ' on token_id ' +
 token_idKey.key +
 ' 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)
 ?.description}
 </Typography>
 </Box>
 }
 >
 <InfoOutlined />
 </Tooltip>
 }
 title={nftContratTokenMetadataMap.get(token_id)?.name}
 />
 <CardMedia
 sx={{ width: 'auto', marginLeft: '33%' }}
 component="img"
 height="100px"
 image={nftContratTokenMetadataMap
 .get(token_id)
 ?.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(Number(token_id));
 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>
 );
 }This page shows the bottles that the connected account owns and allows the user to select bottles for sale. When the user selects bottles and adds a sale price, the page calls the sellentrypoint with this code:nftContrat?.methods
 .sell(BigNumber(token_id) as nat, BigNumber(price * 1000000) as nat)
 .send();This code multiplies the price by 1,000,000 because the UI shows prices in tez but the contract records prices in mutez. Then the contract creates an offer for the selected token. 
- 
As you did in the previous part, connect an administrator's wallet to the application and create at least one NFT. The new contract that you deployed in this section has no NFTs to start with. 
- 
Offer a bottle for sale: - 
Open the application and click Trading > Sell bottles. The sale page opens and shows the bottles that you own, as in this picture:  
- 
Set the price for a bottle and then click Sell. 
- 
Approve the transaction in your wallet and wait for the page to refresh. 
 When the page refreshes, the bottle updates to show "Traded" and the offer price, as in this picture:  
- 
Add a catalog and sales page
In this section, you add a catalog page to show the bottles that are on sale and allow users to buy them.
- 
Open the file ./src/WineCataloguePage.tsxand replace it with this code: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.toString()
 )?.description}
 </Typography>
 <Typography>
 {'Seller : ' + offer.owner}{' '}
 </Typography>
 </Box>
 }
 >
 <InfoOutlined />
 </Tooltip>
 }
 title={
 nftContratTokenMetadataMap.get(token_id.toString())
 ?.name
 }
 />
 <CardMedia
 sx={{ width: 'auto', marginLeft: '33%' }}
 component="img"
 height="100px"
 image={nftContratTokenMetadataMap
 .get(token_id.toString())
 ?.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>
 );
 }
- 
Disconnect your administrator account from the application and connect with a different account that has enough tez to buy a bottle. 
- 
In the web application, click Trading > Wine catalogue. The page looks like this:  
- 
Buy a bottle by clicking Buy and confirming the transaction in your wallet. 
- 
When the transaction completes, click Trading > Sell bottles and see that you own the bottle and that you can offer it for sale. 
Summary
Now you and other users can buy and sell NFTs from the marketplace dApp.
In the next part, you create a different type of token, called a single-asset token. Instead of creating multiple token types with a quantity of exactly 1 as with the NFTs in this part, you create a single token type with any quantity you want.
For the complete content of the contract and web app at the end of this part, see the completed part 2 app at https://github.com/marigold-dev/training-nft-2.
To continue, go to Part 3: Managing tokens with quantities.