diff --git a/README.md b/README.md index 110b5a6d..2502ca33 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ type Config = { }, contracts?: { nftFaucet?: string + marketplace?: { + fixedPrice: { + tez: string; + } + } } } ``` diff --git a/client/package.json b/client/package.json index b47a5411..a62e92c3 100644 --- a/client/package.json +++ b/client/package.json @@ -8,9 +8,9 @@ "@emotion/react": "11.1.4", "@emotion/styled": "11.0.0", "@reduxjs/toolkit": "1.5.0", - "@taquito/beacon-wallet": "8.0.3-beta.0", - "@taquito/tzip16": "8.0.3-beta.0", - "@taquito/taquito": "8.0.3-beta.0", + "@taquito/beacon-wallet": "8.0.4-beta.0", + "@taquito/tzip16": "8.0.4-beta.0", + "@taquito/taquito": "8.0.4-beta.0", "@types/lodash": "4.14.165", "@types/react": "16.9.12", "@types/react-dom": "16.9.0", diff --git a/client/src/components/Collections/Catalog/TokenGrid.tsx b/client/src/components/Collections/Catalog/TokenGrid.tsx index 92adefc9..3a650a6b 100644 --- a/client/src/components/Collections/Catalog/TokenGrid.tsx +++ b/client/src/components/Collections/Catalog/TokenGrid.tsx @@ -141,7 +141,7 @@ export default function TokenGrid({ state, walletAddress }: TokenGridProps) { } const tokens = collection.tokens.filter( - ({ owner }) => owner === walletAddress + ({ owner, sale }) => owner === walletAddress || sale?.seller === walletAddress ); if (tokens.length === 0) { diff --git a/client/src/components/Collections/TokenDetail/index.tsx b/client/src/components/Collections/TokenDetail/index.tsx index 4b3e45af..d6d7e758 100644 --- a/client/src/components/Collections/TokenDetail/index.tsx +++ b/client/src/components/Collections/TokenDetail/index.tsx @@ -1,13 +1,21 @@ import React, { useEffect, useState } from 'react'; import { useLocation } from 'wouter'; -import { AspectRatio, Box, Flex, Heading, Image, Text } from '@chakra-ui/react'; import { - ChevronLeft, - HelpCircle, - /* MoreHorizontal, */ Star -} from 'react-feather'; -import { MinterButton } from '../../common'; -import { TransferTokenButton } from '../../common/TransferToken'; + AspectRatio, + Box, + Flex, + Heading, + Image, + Menu, + MenuList, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { ChevronLeft, HelpCircle, MoreHorizontal, Star } from 'react-feather'; +import { MinterButton, MinterMenuButton, MinterMenuItem } from '../../common'; +import { TransferTokenModal } from '../../common/TransferToken'; +import { SellTokenButton, CancelTokenSaleButton } from '../../common/SellToken'; +import { BuyTokenButton } from '../../common/BuyToken'; import { ipfsUriToGatewayUrl, uriToCid } from '../../../lib/util/ipfs'; import { useSelector, useDispatch } from '../../../reducer'; import { @@ -117,6 +125,7 @@ interface TokenDetailProps { function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { const [, setLocation] = useLocation(); const { system, collections: state } = useSelector(s => s); + const disclosure = useDisclosure(); const dispatch = useDispatch(); const collection = state.collections[contractAddress]; @@ -195,8 +204,41 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { borderRadius="3px" py={6} mb={10} + pos="relative" > - {system.tzPublicKey && system.tzPublicKey === token.owner ? ( + {system.tzPublicKey && + (system.tzPublicKey === token.owner || + system.tzPublicKey === token.sale?.seller) ? ( + + + + + + + + Transfer + + + + + + ) : null} + + {system.tzPublicKey && + (system.tzPublicKey === token.owner || + system.tzPublicKey === token.sale?.seller) ? ( ) : null} + - {/* TODO: Add dropdown menu that contains transfer/share links */} - {/* */} - {/* */} - {/* */} {uriToCid(token.artifactUri) || 'No IPFS Hash'} - {system.status === 'WalletConnected' ? ( - - + + + + + + Market status + + {token.sale ? ( + + For sale + + ) : ( + + Not for sale + + )} + + {token.sale ? ( + + + Price + + + ꜩ {token.sale.price} + + + ) : null} + {system.tzPublicKey && + (system.tzPublicKey === token.owner || + system.tzPublicKey === token.sale?.seller) ? ( + + {token.sale ? ( + + ) : ( + + )} + + ) : token.sale ? ( + + ) : null} - ) : null} + diff --git a/client/src/components/CreateNonFungiblePage/FileUpload.tsx b/client/src/components/CreateNonFungiblePage/FileUpload.tsx index 773f8bc0..2df38fe1 100644 --- a/client/src/components/CreateNonFungiblePage/FileUpload.tsx +++ b/client/src/components/CreateNonFungiblePage/FileUpload.tsx @@ -11,7 +11,7 @@ import { export function FilePreview({ file }: { file: SelectedFile }) { const dispatch = useDispatch(); if (/^image\/.*/.test(file.type)) { - return ; + return ; } if (/^video\/.*/.test(file.type)) { const canvasRef = createRef(); @@ -101,8 +101,16 @@ export default function FileUpload() { > {state.selectedFile?.objectUrl ? ( - - + + + + ) : ( diff --git a/client/src/components/CreateNonFungiblePage/StatusModal.tsx b/client/src/components/CreateNonFungiblePage/StatusModal.tsx index 4a9f9a5b..e62e556b 100644 --- a/client/src/components/CreateNonFungiblePage/StatusModal.tsx +++ b/client/src/components/CreateNonFungiblePage/StatusModal.tsx @@ -6,16 +6,79 @@ import { Heading, Modal, ModalOverlay, - ModalContent + ModalContent, + Text } from '@chakra-ui/react'; -import { CheckCircle } from 'react-feather'; +import { CheckCircle, AlertCircle, X } from 'react-feather'; import { MinterButton } from '../common'; -import { StatusKey } from '../../reducer/slices/status'; +import { Status } from '../../reducer/slices/status'; interface StatusModalProps { isOpen: boolean; onClose: () => void; - status: StatusKey; + onRetry: () => void; + onCancel: () => void; + status: Status; +} + +function Content({ status, onClose, onRetry, onCancel }: StatusModalProps) { + if (status.error) { + return ( + + + + + + Error Creating Token + + + onRetry()}> + Retry + + onCancel()} + display="flex" + alignItems="center" + ml={4} + > + + + + + Close + + + + + ); + } + if (status.status === 'in_transit') { + return ( + + + + Creating token... + + + ); + } + if (status.status === 'complete') { + return ( + + + + + + Token creation complete + + onClose()}> + Close + + + ); + } + return null; } export default function StatusModal(props: StatusModalProps) { @@ -23,7 +86,7 @@ export default function StatusModal(props: StatusModalProps) { const initialRef = React.useRef(null); const close = () => { - if (status === 'complete') { + if (status.status === 'complete') { onClose(); } }; @@ -41,27 +104,7 @@ export default function StatusModal(props: StatusModalProps) { > - {status === 'in_transit' ? ( - - - - Creating token... - - - ) : null} - {status === 'complete' ? ( - - - - - - Token creation complete - - close()}> - Close - - - ) : null} + diff --git a/client/src/components/CreateNonFungiblePage/index.tsx b/client/src/components/CreateNonFungiblePage/index.tsx index 56d3c8c7..3ab54cc3 100644 --- a/client/src/components/CreateNonFungiblePage/index.tsx +++ b/client/src/components/CreateNonFungiblePage/index.tsx @@ -19,7 +19,7 @@ import { } from '../../reducer/slices/createNft'; import { mintTokenAction } from '../../reducer/async/actions'; import { validateCreateNftStep } from '../../reducer/validators/createNft'; -import { setStatus } from '../../reducer/slices/status'; +import { clearError, setStatus } from '../../reducer/slices/status'; function ProgressIndicator({ state }: { state: CreateNftState }) { const stepIdx = steps.indexOf(state.step); @@ -153,7 +153,16 @@ export default function CreateNonFungiblePage() { dispatch(setStatus({ method: 'mintToken', status: 'ready' })); dispatch(clearForm()); }} - status={status.status} + onRetry={() => { + dispatch(clearError({ method: 'mintToken' })); + dispatch(mintTokenAction()); + }} + onCancel={() => { + onClose(); + dispatch(clearError({ method: 'mintToken' })); + dispatch(setStatus({ method: 'mintToken', status: 'ready' })); + }} + status={status} /> diff --git a/client/src/components/common/BuyToken.tsx b/client/src/components/common/BuyToken.tsx new file mode 100644 index 00000000..bbd41689 --- /dev/null +++ b/client/src/components/common/BuyToken.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { + Box, + Button, + Flex, + Spinner, + Heading, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { CheckCircle } from 'react-feather'; +import { MinterButton } from '../common'; +import { useSelector, useDispatch } from '../../reducer'; +import { buyTokenAction } from '../../reducer/async/actions'; +import { setStatus } from '../../reducer/slices/status'; +import { Nft } from '../../lib/nfts/queries'; + +interface BuyTokenButtonProps { + contract: string; + token: Nft; +} + +export function BuyTokenButton(props: BuyTokenButtonProps) { + const { status } = useSelector(s => s.status.buyToken); + const dispatch = useDispatch(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const initialRef = React.useRef(null); + + const onSubmit = async () => { + dispatch( + buyTokenAction({ + contract: props.contract, + tokenId: props.token.id, + tokenSeller: props.token.sale?.seller || "", + salePrice: props.token.sale?.price || 0 + }) + ); + }; + + const close = () => { + if (status !== 'in_transit') { + dispatch(setStatus({ method: 'buyToken', status: 'ready' })); + onClose(); + } + }; + + return ( + <> + Buy now + + close()} + initialFocusRef={initialRef} + closeOnEsc={false} + closeOnOverlayClick={false} + onEsc={() => close()} + onOverlayClick={() => close()} + size="xs" + > + + + {status === 'ready' ? ( + <> + Checkout + + + + You are about to purchase {props.token.title} (ꜩ {props.token.sale?.price}) + + + + + + + ) : null} + {status === 'in_transit' ? ( + + + + Purchasing token... + + + ) : null} + {status === 'complete' ? ( + + + + + + Token purchased + + close()}> + Close + + + ) : null} + + + + ); +} diff --git a/client/src/components/common/CreateCollection.tsx b/client/src/components/common/CreateCollection.tsx index 8608dd87..d7e4726c 100644 --- a/client/src/components/common/CreateCollection.tsx +++ b/client/src/components/common/CreateCollection.tsx @@ -1,4 +1,9 @@ -import React, { useState, MutableRefObject } from 'react'; +import React, { + useState, + MutableRefObject, + SetStateAction, + Dispatch +} from 'react'; import { Box, Text, @@ -17,20 +22,25 @@ import { Flex, Heading } from '@chakra-ui/react'; -import { CheckCircle, Plus } from 'react-feather'; +import { CheckCircle, Plus, AlertCircle, X } from 'react-feather'; import { MinterButton } from '../common'; - import { useSelector, useDispatch } from '../../reducer'; import { createAssetContractAction } from '../../reducer/async/actions'; -import { setStatus } from '../../reducer/slices/status'; +import { clearError, setStatus, Status } from '../../reducer/slices/status'; interface FormProps { initialRef: MutableRefObject; onSubmit: (form: { contractName: string }) => void; + contractName: string; + setContractName: Dispatch>; } -function Form({ initialRef, onSubmit }: FormProps) { - const [contractName, setContractName] = useState(''); +function Form({ + initialRef, + onSubmit, + contractName, + setContractName +}: FormProps) { return ( <> New Collection @@ -60,18 +70,88 @@ function Form({ initialRef, onSubmit }: FormProps) { ); } +interface ContentProps { + isOpen: boolean; + onClose: () => void; + onRetry: () => void; + onCancel: () => void; + status: Status; +} + +function Content({ status, onClose, onRetry, onCancel }: ContentProps) { + if (status.error) { + return ( + + + + + + Error Creating Collection + + + onRetry()}> + Retry + + onCancel()} + display="flex" + alignItems="center" + ml={4} + > + + + + + Close + + + + + ); + } + if (status.status === 'in_transit') { + return ( + + + + Creating collection... + + + ); + } + if (status.status === 'complete') { + return ( + + + + + + Collection created + + onClose()}> + Close + + + ); + } + return null; +} + export function CreateCollectionButton() { - const { status } = useSelector(s => s.status.createAssetContract); + const status = useSelector(s => s.status.createAssetContract); const dispatch = useDispatch(); + const [contractName, setContractName] = useState(''); + const { isOpen, onOpen, onClose } = useDisclosure(); const initialRef = React.useRef(null); - const onSubmit = async (form: { contractName: string }) => { - dispatch(createAssetContractAction(form.contractName)); + const onSubmit = async () => { + dispatch(createAssetContractAction(contractName)); }; const close = () => { - if (status !== 'in_transit') { + if (status.status !== 'in_transit') { dispatch(setStatus({ method: 'createAssetContract', status: 'ready' })); onClose(); } @@ -97,30 +177,31 @@ export function CreateCollectionButton() { > - {status === 'ready' ? ( -
- ) : null} - {status === 'in_transit' ? ( - - - - Creating new collection... - - - ) : null} - {status === 'complete' ? ( - - - - - - Collection created - - close()}> - Close - - - ) : null} + {status.status === 'ready' ? ( + + ) : ( + close()} + onCancel={() => { + onClose(); + dispatch(clearError({ method: 'createAssetContract' })); + dispatch( + setStatus({ method: 'createAssetContract', status: 'ready' }) + ); + }} + onRetry={() => { + dispatch(clearError({ method: 'createAssetContract' })); + onSubmit(); + }} + /> + )} diff --git a/client/src/components/common/SellToken.tsx b/client/src/components/common/SellToken.tsx new file mode 100644 index 00000000..3e8542fe --- /dev/null +++ b/client/src/components/common/SellToken.tsx @@ -0,0 +1,227 @@ +import React, { useState, MutableRefObject } from 'react'; +import { + Box, + Button, + Flex, + Spinner, + Heading, + FormControl, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { Check, CheckCircle } from 'react-feather'; +import { MinterButton } from '../common'; +import { useSelector, useDispatch } from '../../reducer'; +import { listTokenAction, cancelTokenSaleAction } from '../../reducer/async/actions'; +import { setStatus } from '../../reducer/slices/status'; + +interface FormProps { + initialRef: MutableRefObject; + onSubmit: (form: { salePrice: string }) => void; +} + +interface SellTokenButtonProps { + contract: string; + tokenId: number; +} + +function FormFixedPrice({ initialRef, onSubmit }: FormProps) { + const [salePrice, setSalePrice] = useState(''); + return ( + <> + Set your price + + + + + + + setSalePrice(e.target.value)} + /> + + + + onSubmit({ salePrice })} + > + + + + + + + + ); +} + +export function SellTokenButton(props: SellTokenButtonProps) { + const { status } = useSelector(s => s.status.listToken); + const dispatch = useDispatch(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const initialRef = React.useRef(null); + + const onSubmit = async (form: { salePrice: string }) => { + let salePrice = Math.floor(Number(form.salePrice) * 1000000); + if (Number.isNaN(salePrice)) { + salePrice = 0; + } + dispatch( + listTokenAction({ + ...props, + salePrice: salePrice + }) + ); + }; + + const close = () => { + if (status !== 'in_transit') { + dispatch(setStatus({ method: 'listToken', status: 'ready' })); + onClose(); + } + }; + + return ( + <> + List for sale + + close()} + initialFocusRef={initialRef} + closeOnEsc={false} + closeOnOverlayClick={false} + onEsc={() => close()} + onOverlayClick={() => close()} + > + + + {status === 'ready' ? ( + + ) : null} + {status === 'in_transit' ? ( + + + + Listing token for sale... + + + ) : null} + {status === 'complete' ? ( + + + + + + Listing complete + + close()}> + Close + + + ) : null} + + + + ); +} + +export function CancelTokenSaleButton(props: SellTokenButtonProps) { + const { status } = useSelector(s => s.status.cancelTokenSale); + const dispatch = useDispatch(); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const initialRef = React.useRef(null); + + const onSubmit = async () => { + dispatch( + cancelTokenSaleAction({ + ...props + }) + ); + }; + + const close = () => { + if (status !== 'in_transit') { + dispatch(setStatus({ method: 'cancelTokenSale', status: 'ready' })); + onClose(); + } + }; + + return ( + <> + Cancel sale + + close()} + initialFocusRef={initialRef} + closeOnEsc={false} + closeOnOverlayClick={false} + onEsc={() => close()} + onOverlayClick={() => close()} + > + + + {status === 'ready' ? ( + <> + Are you sure? + + + + Are you sure you want to cancel the sale? + + + + + + + + ) : null} + {status === 'in_transit' ? ( + + + + Canceling sale... + + + ) : null} + {status === 'complete' ? ( + + + + + + Listing canceled + + close()}> + Close + + + ) : null} + + + + ); +} diff --git a/client/src/components/common/TransferToken.tsx b/client/src/components/common/TransferToken.tsx index 6d1d4ab6..b06a4483 100644 --- a/client/src/components/common/TransferToken.tsx +++ b/client/src/components/common/TransferToken.tsx @@ -1,10 +1,14 @@ -import React, { useState, MutableRefObject } from 'react'; +import React, { + useState, + MutableRefObject, + SetStateAction, + Dispatch +} from 'react'; import { Box, Flex, Spinner, Heading, - Text, FormControl, FormLabel, Input, @@ -15,21 +19,24 @@ import { ModalFooter, ModalBody, ModalCloseButton, - useDisclosure + Text, + useDisclosure, + UseDisclosureReturn } from '@chakra-ui/react'; -import { Plus, CheckCircle } from 'react-feather'; +import { CheckCircle, AlertCircle, X, Plus } from 'react-feather'; import { MinterButton } from '../common'; import { useSelector, useDispatch } from '../../reducer'; import { transferTokenAction } from '../../reducer/async/actions'; -import { setStatus } from '../../reducer/slices/status'; +import { clearError, setStatus, Status } from '../../reducer/slices/status'; interface FormProps { initialRef: MutableRefObject; onSubmit: (form: { toAddress: string }) => void; + toAddress: string; + setToAddress: Dispatch>; } -function Form({ initialRef, onSubmit }: FormProps) { - const [toAddress, setToAddress] = useState(''); +function Form({ initialRef, onSubmit, toAddress, setToAddress }: FormProps) { return ( <> Transfer Token @@ -59,81 +66,161 @@ function Form({ initialRef, onSubmit }: FormProps) { ); } -interface TransferTokenButtonProps { +interface ContentProps { + isOpen: boolean; + onClose: () => void; + onRetry: () => void; + onCancel: () => void; + status: Status; +} + +function Content({ status, onClose, onRetry, onCancel }: ContentProps) { + if (status.error) { + return ( + + + + + + Error Transferring Token + + + onRetry()}> + Retry + + onCancel()} + display="flex" + alignItems="center" + ml={4} + > + + + + + Close + + + + + ); + } + if (status.status === 'in_transit') { + return ( + + + + Transferring token... + + + ); + } + if (status.status === 'complete') { + return ( + + + + + + Token transfer complete + + onClose()}> + Close + + + ); + } + return null; +} + +interface TransferTokenModalProps { contractAddress: string; tokenId: number; + disclosure: UseDisclosureReturn; } -export function TransferTokenButton(props: TransferTokenButtonProps) { - const { status } = useSelector(s => s.status.transferToken); +export function TransferTokenModal(props: TransferTokenModalProps) { + const status = useSelector(s => s.status.transferToken); const dispatch = useDispatch(); + const [toAddress, setToAddress] = useState(''); + const { isOpen, onClose } = props.disclosure; - const { isOpen, onOpen, onClose } = useDisclosure(); const initialRef = React.useRef(null); - const onSubmit = async (form: { toAddress: string }) => { + const onSubmit = async () => { dispatch( transferTokenAction({ contract: props.contractAddress, tokenId: props.tokenId, - to: form.toAddress + to: toAddress }) ); }; const close = () => { - if (status !== 'in_transit') { + if (status.status !== 'in_transit') { dispatch(setStatus({ method: 'transferToken', status: 'ready' })); onClose(); } }; + return ( + close()} + initialFocusRef={initialRef} + closeOnEsc={false} + closeOnOverlayClick={false} + onEsc={() => close()} + onOverlayClick={() => close()} + > + + + {status.status === 'ready' ? ( + + ) : ( + close()} + onCancel={() => { + onClose(); + dispatch(clearError({ method: 'transferToken' })); + dispatch(setStatus({ method: 'transferToken', status: 'ready' })); + }} + onRetry={() => { + dispatch(clearError({ method: 'transferToken' })); + onSubmit(); + }} + /> + )} + + + ); +} + +interface TransferTokenButtonProps { + contractAddress: string; + tokenId: number; +} + +export function TransferTokenButton(props: TransferTokenButtonProps) { + const disclosure = useDisclosure(); return ( <> - + Transfer Token - - close()} - initialFocusRef={initialRef} - closeOnEsc={false} - closeOnOverlayClick={false} - onEsc={() => close()} - onOverlayClick={() => close()} - > - - - {status === 'ready' ? ( - - ) : null} - {status === 'in_transit' ? ( - - - - Transferring token... - - - ) : null} - {status === 'complete' ? ( - - - - - - Transfer complete - - close()}> - Close - - - ) : null} - - + ); } diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index fab259d7..8553b3e1 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -4,6 +4,10 @@ import { ButtonProps, Link, LinkProps, + MenuButton, + MenuButtonProps, + MenuItem, + MenuItemProps, useStyleConfig } from '@chakra-ui/react'; @@ -24,3 +28,19 @@ export function MinterLink( const styles = useStyleConfig('Link', { size, variant }); return ; } + +export function MinterMenuButton( + props: MenuButtonProps & { variant?: string } +) { + const { variant, ...rest } = props; + const styles = useStyleConfig('MenuButton', { variant }); + return ; +} + +export function MinterMenuItem( + props: MenuItemProps & { variant?: string } +) { + const { variant, ...rest } = props; + const styles = useStyleConfig('MenuItem', { variant }); + return ; +} diff --git a/client/src/index.tsx b/client/src/index.tsx index 9d1ba20e..fb661835 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -112,6 +112,19 @@ const Button = { bg: 'brand.red', color: 'white' } + }, + tertiaryAction: { + bg: 'gray.200', + color: 'gray.500', + borderRadius: '2px', + _hover: { + bg: 'gray.100', + color: 'gray.400' + }, + _active: { + bg: 'gray.100', + color: 'gray.400' + } } } }; @@ -205,6 +218,26 @@ const theme = extendTheme({ } } } + }, + MenuButton: { + variants: { + primary: { + color: 'gray.300', + _hover: { color: "brand.blue" }, + _expanded: { color: "brand.blue" }, + _focus: { color: "brand.blue" } + } + } + }, + MenuItem: { + variants: { + primary: { + _focus: { + bg: "brand.lightBlue", + color: "brand.blue" + } + } + } } }, fonts: { diff --git a/client/src/lib/nfts/actions.ts b/client/src/lib/nfts/actions.ts index c3ca4335..6a0c9c72 100644 --- a/client/src/lib/nfts/actions.ts +++ b/client/src/lib/nfts/actions.ts @@ -116,3 +116,74 @@ export async function transferToken( ]) .send(); } + +export async function listTokenForSale( + system: SystemWithWallet, + marketplaceContract: string, + tokenContract: string, + tokenId: number, + salePrice: number +) { + const contract = await system.toolkit.wallet.at(marketplaceContract); + return contract.methods + .sell(salePrice, tokenContract, tokenId) + .send(); +} + +export async function cancelTokenSale( + system: SystemWithWallet, + marketplaceContract: string, + tokenContract: string, + tokenId: number +) { + const contractM = await system.toolkit.wallet.at(marketplaceContract); + const contractT = await system.toolkit.wallet.at(tokenContract); + const batch = await system.toolkit.wallet.batch() + .withContractCall(contractM.methods.cancel(system.tzPublicKey, tokenContract, tokenId)) + .withContractCall(contractT.methods.update_operators([ + { remove_operator: { owner: system.tzPublicKey, operator: marketplaceContract, token_id: tokenId }} + ])); + return batch.send(); +} + +export async function approveTokenOperator( + system: SystemWithWallet, + contractAddress: string, + tokenId: number, + operatorAddress: string +) { + const contract = await system.toolkit.wallet.at(contractAddress); + return contract.methods + .update_operators([ + { add_operator: { owner: system.tzPublicKey, operator: operatorAddress, token_id: tokenId }} + ]) + .send(); +} + +export async function removeTokenOperator( + system: SystemWithWallet, + contractAddress: string, + tokenId: number, + operatorAddress: string +) { + const contract = await system.toolkit.wallet.at(contractAddress); + return contract.methods + .update_operators([ + { remove_operator: { owner: system.tzPublicKey, operator: operatorAddress, token_id: tokenId }} + ]) + .send(); +} + +export async function buyToken( + system: SystemWithWallet, + marketplaceContract: string, + tokenContract: string, + tokenId: number, + tokenSeller: string, + salePrice: number +) { + const contract = await system.toolkit.wallet.at(marketplaceContract); + return contract.methods + .buy(tokenSeller, tokenContract, tokenId) + .send({ amount: salePrice }); +} diff --git a/client/src/lib/nfts/queries.ts b/client/src/lib/nfts/queries.ts index 9e13df00..cfac6c1f 100644 --- a/client/src/lib/nfts/queries.ts +++ b/client/src/lib/nfts/queries.ts @@ -12,6 +12,13 @@ function fromHexString(input: string) { return input; } +interface NftSale { + seller: string; + price: number; + mutez: number; + type: string; +} + export interface Nft { id: number; title: string; @@ -19,6 +26,7 @@ export interface Nft { description: string; artifactUri: string; metadata: Record; + sale?: NftSale; } export async function getContractNfts( @@ -49,6 +57,10 @@ export async function getContractNfts( if (!tokens) return []; + // get tokens listed for sale + const fixedPriceStorage = await system.betterCallDev.getContractStorage(system.config.contracts.marketplace.fixedPrice.tez); + const fixedPriceSales = await system.betterCallDev.getBigMapKeys(fixedPriceStorage.value); + return Promise.all( tokens.map( async (token: any): Promise => { @@ -66,13 +78,29 @@ export async function getContractNfts( const entry = ledger.filter((v: any) => v.data.key.value === tokenId); const owner = select(entry, { type: 'address' })?.value; + const saleData = fixedPriceSales.filter((v: any) => { + return select(v, { name: 'token_for_sale_address' })?.value === address && + select(v, { name: 'token_for_sale_token_id' })?.value === tokenId + }); + + let sale = undefined; + if (saleData.length > 0 && saleData[0].data.value) { + sale = { + seller: select(saleData, { name: 'sale_seller' })?.value, + price: Number.parseInt(saleData[0].data.value.value, 10) / 1000000, + mutez: Number.parseInt(saleData[0].data.value.value, 10), + type: 'fixedPrice' + }; + } + return { id: parseInt(tokenId, 10), title: metadata.name, owner, description: metadata.description, artifactUri: metadata.artifactUri, - metadata: metadata + metadata: metadata, + sale: sale }; } ) diff --git a/client/src/lib/system.ts b/client/src/lib/system.ts index a1aa2b48..a7ed5b2b 100644 --- a/client/src/lib/system.ts +++ b/client/src/lib/system.ts @@ -15,6 +15,11 @@ export interface Config { }; contracts: { nftFaucet: string; + marketplace: { + fixedPrice: { + tez: string; + } + } }; } @@ -170,15 +175,21 @@ async function initWallet( if (!activeAccount) { if (forceConnect) { - await wallet.requestPermissions({ - network: { - type: - system.config.network === 'edo2net' - ? (system.config.network as NetworkType) - : network, - rpcUrl: system.config.rpc - } - }); + try { + await wallet.requestPermissions({ + network: { + type: + system.config.network === 'edo2net' + ? (system.config.network as NetworkType) + : network, + rpcUrl: system.config.rpc + } + }); + } catch (error) { + // requestPermissions failed - reset wallet selection + wallet.clearActiveAccount(); + throw error; + } } else { return false; } diff --git a/client/src/reducer/async/actions.ts b/client/src/reducer/async/actions.ts index d77ec99d..6ac1634d 100644 --- a/client/src/reducer/async/actions.ts +++ b/client/src/reducer/async/actions.ts @@ -3,7 +3,11 @@ import { State } from '..'; import { createAssetContract, mintToken, - transferToken + transferToken, + listTokenForSale, + cancelTokenSale, + approveTokenOperator, + buyToken } from '../../lib/nfts/actions'; // import {getNftAssetContract} from '../../lib/nfts/queries' import { ErrorKind, RejectValue } from './errors'; @@ -14,6 +18,7 @@ import { uploadIPFSImageWithThumbnail } from '../../lib/util/ipfs'; import { SelectedFile } from '../slices/createNft'; +import { connectWallet } from './wallet'; type Options = { state: State; @@ -186,7 +191,7 @@ export const mintTokenAction = createAsyncThunk< try { const op = await mintToken(system, address, metadata); - await op.confirmation(); + await op.confirmation(2); dispatch(getContractNftsQuery(address)); return { contract: address }; } catch (e) { @@ -213,7 +218,7 @@ export const transferTokenAction = createAsyncThunk< } try { const op = await transferToken(system, contract, tokenId, to); - await op.confirmation(); + await op.confirmation(2); dispatch(getContractNftsQuery(contract)); return { contract: '', tokenId: 0 }; } catch (e) { @@ -223,3 +228,93 @@ export const transferTokenAction = createAsyncThunk< }); } }); + +export const listTokenAction = createAsyncThunk< + { contract: string; tokenId: number, salePrice: number }, + { contract: string; tokenId: number, salePrice: number }, + Options +>('actions/listToken', async (args, api) => { + const { getState, rejectWithValue, dispatch } = api; + const { contract, tokenId, salePrice } = args; + const { system } = getState(); + const marketplaceContract = system.config.contracts.marketplace.fixedPrice.tez; + if (system.status !== 'WalletConnected') { + return rejectWithValue({ + kind: ErrorKind.WalletNotConnected, + message: 'Could not list token: no wallet connected' + }); + } + try { + const op1 = await approveTokenOperator(system, contract, tokenId, marketplaceContract); + await op1.confirmation(); + const op2 = await listTokenForSale(system, marketplaceContract, contract, tokenId, salePrice); + await op2.confirmation(2); + dispatch(getContractNftsQuery(contract)); + return { contract: contract, tokenId: tokenId, salePrice: salePrice }; + } catch (e) { + return rejectWithValue({ + kind: ErrorKind.ListTokenFailed, + message: 'List token failed' + }); + } +}); + +export const cancelTokenSaleAction = createAsyncThunk< + { contract: string; tokenId: number }, + { contract: string; tokenId: number }, + Options +>('actions/cancelTokenSale', async (args, api) => { + const { getState, rejectWithValue, dispatch } = api; + const { contract, tokenId } = args; + const { system } = getState(); + const marketplaceContract = system.config.contracts.marketplace.fixedPrice.tez; + if (system.status !== 'WalletConnected') { + return rejectWithValue({ + kind: ErrorKind.WalletNotConnected, + message: 'Could not list token: no wallet connected' + }); + } + try { + const op = await cancelTokenSale(system, marketplaceContract, contract, tokenId); + await op.confirmation(2); + dispatch(getContractNftsQuery(contract)); + return { contract: contract, tokenId: tokenId }; + } catch (e) { + return rejectWithValue({ + kind: ErrorKind.CancelTokenSaleFailed, + message: 'Cancel token sale failed' + }); + } +}); + +export const buyTokenAction = createAsyncThunk< + { contract: string; tokenId: number }, + { contract: string; tokenId: number, tokenSeller: string; salePrice: number }, + Options +>('actions/buyToken', async (args, api) => { + const { getState, rejectWithValue, dispatch } = api; + const { contract, tokenId, tokenSeller, salePrice } = args; + let { system } = getState(); + const marketplaceContract = system.config.contracts.marketplace.fixedPrice.tez; + if (system.status !== 'WalletConnected') { + const res = await dispatch(connectWallet()); + if (!res.payload || !("wallet" in res.payload)) { + return rejectWithValue({ + kind: ErrorKind.WalletNotConnected, + message: 'Could not list token: no wallet connected' + }); + } + system = res.payload; + } + try { + const op = await buyToken(system, marketplaceContract, contract, tokenId, tokenSeller, salePrice); + await op.confirmation(2); + dispatch(getContractNftsQuery(contract)); + return { contract: contract, tokenId: tokenId }; + } catch (e) { + return rejectWithValue({ + kind: ErrorKind.BuyTokenFailed, + message: 'Purchase token failed' + }); + } +}); diff --git a/client/src/reducer/async/errors.ts b/client/src/reducer/async/errors.ts index 6bb5c320..8820e75d 100644 --- a/client/src/reducer/async/errors.ts +++ b/client/src/reducer/async/errors.ts @@ -5,6 +5,9 @@ export enum ErrorKind { CreateNftFormInvalid, MintTokenFailed, TransferTokenFailed, + ListTokenFailed, + CancelTokenSaleFailed, + BuyTokenFailed, GetNftAssetContractFailed, GetContractNftsFailed, GetWalletNftAssetContractsFailed, diff --git a/client/src/reducer/slices/notifications.ts b/client/src/reducer/slices/notifications.ts index 98c46ccb..998b314c 100644 --- a/client/src/reducer/slices/notifications.ts +++ b/client/src/reducer/slices/notifications.ts @@ -2,7 +2,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAssetContractAction, mintTokenAction, - transferTokenAction + transferTokenAction, + listTokenAction, + cancelTokenSaleAction, + buyTokenAction } from '../async/actions'; import { connectWallet, disconnectWallet } from '../async/wallet'; import { @@ -52,6 +55,9 @@ const slice = createSlice({ createAssetContractAction, mintTokenAction, transferTokenAction, + listTokenAction, + cancelTokenSaleAction, + buyTokenAction, getContractNftsQuery, getNftAssetContractQuery, getWalletAssetContractsQuery, diff --git a/client/src/reducer/slices/status.ts b/client/src/reducer/slices/status.ts index 3e9c9207..6741ac41 100644 --- a/client/src/reducer/slices/status.ts +++ b/client/src/reducer/slices/status.ts @@ -2,7 +2,10 @@ import { createSlice, PayloadAction, SerializedError } from '@reduxjs/toolkit'; import { createAssetContractAction, mintTokenAction, - transferTokenAction + transferTokenAction, + listTokenAction, + cancelTokenSaleAction, + buyTokenAction } from '../async/actions'; import { getContractNftsQuery, @@ -13,7 +16,7 @@ import { ErrorKind, RejectValue } from '../async/errors'; export type StatusKey = 'ready' | 'in_transit' | 'complete'; -interface Status { +export interface Status { status: StatusKey; error: { rejectValue: RejectValue; @@ -25,6 +28,9 @@ export interface StatusState { createAssetContract: Status; mintToken: Status; transferToken: Status; + listToken: Status; + cancelTokenSale: Status; + buyToken: Status; getContractNfts: Status; getNftAssetContract: Status; getWalletAssetContracts: Status; @@ -38,6 +44,9 @@ const initialState: StatusState = { createAssetContract: defaultStatus, mintToken: defaultStatus, transferToken: defaultStatus, + listToken: defaultStatus, + cancelTokenSale: defaultStatus, + buyToken: defaultStatus, getContractNfts: defaultStatus, getNftAssetContract: defaultStatus, getWalletAssetContracts: defaultStatus @@ -66,6 +75,9 @@ const slice = createSlice({ methodMap('createAssetContract', createAssetContractAction), methodMap('mintToken', mintTokenAction), methodMap('transferToken', transferTokenAction), + methodMap('listToken', listTokenAction), + methodMap('cancelTokenSale', cancelTokenSaleAction), + methodMap('buyToken', buyTokenAction), methodMap('getContractNfts', getContractNftsQuery), methodMap('getNftAssetContract', getNftAssetContractQuery), methodMap('getWalletAssetContracts', getWalletAssetContractsQuery) @@ -77,17 +89,14 @@ const slice = createSlice({ state[method].status = 'complete'; }); addCase(action.rejected, (state, action) => { - if (action.payload) { - state[method].error = { - rejectValue: action.payload, - serialized: action.error - }; - } + const rejectValue = action.payload + ? action.payload + : { + kind: ErrorKind.UknownError, + message: 'Unknown error' + }; state[method].error = { - rejectValue: { - kind: ErrorKind.UknownError, - message: 'Unknown error' - }, + rejectValue, serialized: action.error }; }); diff --git a/client/yarn.lock b/client/yarn.lock index 993347e6..4ad0c44d 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2248,77 +2248,77 @@ "@svgr/plugin-svgo" "^4.3.1" loader-utils "^1.2.3" -"@taquito/beacon-wallet@8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/beacon-wallet/-/beacon-wallet-8.0.3-beta.0.tgz#1a6debe79160318991fc26687d75457550b4ec01" - integrity sha512-XIHfgLxMCHQWnuhXNoCDKKExzbCuMMH4dyY1gd7IKM1cHtHVf6WkiMzUh9ulKm8G94VhPRNyrynVlw10iAqmuw== +"@taquito/beacon-wallet@8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/beacon-wallet/-/beacon-wallet-8.0.4-beta.0.tgz#2e31c63a2288e0eca99ac44bbba741c1c7cb57af" + integrity sha512-XJ34C34YSW94d2lkOpEZbcPL1JV4jw3oX3E5sT3B4vdIrfkDh1hLU5dtYOWFAV3YMvpAoR23coCt2fsuo6aYrw== dependencies: "@airgap/beacon-sdk" "^2.2.1" - "@taquito/taquito" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/taquito" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" -"@taquito/http-utils@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/http-utils/-/http-utils-8.0.3-beta.0.tgz#aa26b286cb57df52d8eb18d2235302ad4f52e75e" - integrity sha512-9z3terTHUvFATleeD65ULRwqSsK10f/c1jZ1aJCnOgFkGdZ+8SHKFFu0MzY5rXRMwIE0yNQR+uqauFLZ970HUQ== +"@taquito/http-utils@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/http-utils/-/http-utils-8.0.4-beta.0.tgz#12b38bdcd9b406eaadec7ad2264fa446e0a8919c" + integrity sha512-yoPrvkZDWZ/25w3VuTKaUMFwnjPEosD4sMrwOwrT8ZC6ZkHzewB6oVyGb9fPviRALzQH4Wj2jV0Gtmnn9FDsDg== dependencies: xhr2-cookies "^1.1.0" -"@taquito/michel-codec@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/michel-codec/-/michel-codec-8.0.3-beta.0.tgz#7b5be2f371885fea01864950648ec37bcae580b4" - integrity sha512-ike4TTHsRMdg6iE0hypqNyz94x2I24yySdVHU4t19kxuzEJRJDns0SskplveJ5fIlAO7TX/cCJqjumBLIaprVg== +"@taquito/michel-codec@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/michel-codec/-/michel-codec-8.0.4-beta.0.tgz#84f723cba4f1d27af7ce20903f5a76287a72b419" + integrity sha512-cWxewE64vMKRXPeWnanLsYXWYCO2aHiOgsUTdwtFLy9AMgGLOcK04cVQQKmNePWX8NRNJFNDwNUKDvSCKVRxOw== -"@taquito/michelson-encoder@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/michelson-encoder/-/michelson-encoder-8.0.3-beta.0.tgz#e8919e2b0f0f14edfa03205551c2f30ec4b950c1" - integrity sha512-CloKcxh57rcrFazSAscOIrKdlcfO0rVXVoAdIVNfWYyWeEhkRZkSySSPLCYiws8MC9jxUlQYvQo/lT+xGA/gzQ== +"@taquito/michelson-encoder@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/michelson-encoder/-/michelson-encoder-8.0.4-beta.0.tgz#ad078fe49655103a24b109cb17cc2f402486d009" + integrity sha512-2iR50/kJVmP0x43OHpPtP6sETtbKItE1EbeGP5eWvlMxUmZxF2k6cQZVCDyfnSIhwKvC+EWaaSfljy9qYWYU5w== dependencies: - "@taquito/rpc" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/rpc" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" fast-json-stable-stringify "^2.1.0" -"@taquito/rpc@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/rpc/-/rpc-8.0.3-beta.0.tgz#801d352263d97510fc90897eb0b132d214f235c6" - integrity sha512-jrfOR8+RzK9I1VA8VMXJsoy/SGKlO4jN/DFaJHEYZ+KO+woN1bRoOhKKLR15mxe4Zq+P0/Pu+dH1foJn+GbR0w== +"@taquito/rpc@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/rpc/-/rpc-8.0.4-beta.0.tgz#1e8db49976db17392624893c3cb306fa288c7514" + integrity sha512-3L7yaANVJfFJtpcYpZF5JVS9Utrnicyu0hG94gDJlVlugomDRIaURK3tOQ38DF6fus14BHKjOBxChuAp9hQ8gw== dependencies: - "@taquito/http-utils" "^8.0.3-beta.0" + "@taquito/http-utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" lodash "^4.17.20" -"@taquito/taquito@8.0.3-beta.0", "@taquito/taquito@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/taquito/-/taquito-8.0.3-beta.0.tgz#c68867ff8145753d8a95b6a69c8a03c0ccc3f10c" - integrity sha512-IzRoX6JtBvu4WG1m++345I0fZo7ZZ94jfXl1DI7by9vgWpXKt2FM2GCFx1+hpyLdoKxRhZR5+BpNBHbnBXON4w== +"@taquito/taquito@8.0.4-beta.0", "@taquito/taquito@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/taquito/-/taquito-8.0.4-beta.0.tgz#6cae5116869b55a0ec22173cd0a6b779e03bf9c3" + integrity sha512-Z/+7unJ66AX5Wgni4rtiHoxM87dq0Y+pJh2kPLtB3qwmUbd1TyKwSykxJ2ACo2aj35Q426TlDIwV6NCYpquaLw== dependencies: - "@taquito/http-utils" "^8.0.3-beta.0" - "@taquito/michel-codec" "^8.0.3-beta.0" - "@taquito/michelson-encoder" "^8.0.3-beta.0" - "@taquito/rpc" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/http-utils" "^8.0.4-beta.0" + "@taquito/michel-codec" "^8.0.4-beta.0" + "@taquito/michelson-encoder" "^8.0.4-beta.0" + "@taquito/rpc" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" rx-sandbox "^1.0.3" rxjs "^6.6.3" -"@taquito/tzip16@8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/tzip16/-/tzip16-8.0.3-beta.0.tgz#5c7b156248267aca7406c219ba24cc455a245895" - integrity sha512-VnP8ZbtUxzKR4jZ3wfN/HbD0PuBzGqbPEtcg+RHDjl+nCeEt533SOyRKZSUkz1WBMjn5qr1s1768bLkACar/xQ== +"@taquito/tzip16@8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/tzip16/-/tzip16-8.0.4-beta.0.tgz#a12497f1854c890bb1fae2ce0427774b7630356b" + integrity sha512-Rd/JnJgHTkrJYCr/2IKg5Fjv6qNHXDU6t9ol68d3pj175gW56C6tjNOeVi26S0C3LWyg9ro0O9QkEe9IgumaAA== dependencies: - "@taquito/http-utils" "^8.0.3-beta.0" - "@taquito/michelson-encoder" "^8.0.3-beta.0" - "@taquito/rpc" "^8.0.3-beta.0" - "@taquito/taquito" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/http-utils" "^8.0.4-beta.0" + "@taquito/michelson-encoder" "^8.0.4-beta.0" + "@taquito/rpc" "^8.0.4-beta.0" + "@taquito/taquito" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" crypto-js "^4.0.0" -"@taquito/utils@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/utils/-/utils-8.0.3-beta.0.tgz#55a21f2dca39d227e1c623af3fb97f5e75aa45cb" - integrity sha512-59EmJGNSTRZ2wfFxu2EjzJ3BnLnRFC0PUFiey+KyFYXF4Z6TUZTo8KFRM6CNhpeGh3vfWPU+hdsRd+MO4DeNWA== +"@taquito/utils@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/utils/-/utils-8.0.4-beta.0.tgz#13f0e3bad5ca8099a66c626a601d974bc55e961f" + integrity sha512-pOX1majqHppzS3p3YSkRx+juTHND+DEtchVXzMUnUPGgAK2uG+qxEQKO3QItCfK8A/51S2cVPZ801lHiCkbVeA== dependencies: blakejs "^1.1.0" bs58check "^2.1.2" diff --git a/package.json b/package.json index 2914c80f..0ed63bca 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "bootstrap": "ts-node -P scripts/tsconfig.json scripts/bootstrap-contracts-config.ts" }, "devDependencies": { + "@taquito/signer": "8.0.4-beta.0", + "@taquito/taquito": "8.0.4-beta.0", + "@tsed/logger": "5.5.2", "@types/async-retry": "^1.4.2", "@types/configstore": "^4.0.0", - "@taquito/signer": "8.0.3-beta.0", - "@taquito/taquito": "8.0.3-beta.0", - "@tsed/logger": "5.5.2", "async-retry": "^1.3.1", "axios": "0.21.1", "configstore": "^5.0.1", diff --git a/scripts/bootstrap-contracts-config.ts b/scripts/bootstrap-contracts-config.ts index 2b019773..6382e864 100644 --- a/scripts/bootstrap-contracts-config.ts +++ b/scripts/bootstrap-contracts-config.ts @@ -6,7 +6,28 @@ import retry from 'async-retry'; import Configstore from 'configstore'; import { MichelsonMap, TezosToolkit } from '@taquito/taquito'; import { InMemorySigner } from '@taquito/signer'; +import { OriginateParams } from '@taquito/taquito/dist/types/operations/types'; import { OriginationOperation } from '@taquito/taquito/dist/types/operations/origination-operation'; +import { ContractAbstraction } from '@taquito/taquito/dist/types/contract'; +import { ContractProvider } from '@taquito/taquito/dist/types/contract/interface'; + +type Contract = ContractAbstraction; + +interface BoostrapStorageCallback { + (): object +} + +interface BootstrapContractParams { + configKey: string; + contractFilename: string; + contractAlias: string; + initStorage: BoostrapStorageCallback; +} + +interface ContractCodeResponse { + code: string; + url: string; +} // Client & Server Config Generation @@ -34,10 +55,7 @@ function toHexString(input: string) { return Buffer.from(input).toString('hex'); } -export async function originateNftFaucet( - toolkit: TezosToolkit, - code: string -): Promise { +export function initStorageNftFaucet() { const metadata = new MichelsonMap(); const contents = { name: 'Minter', @@ -47,43 +65,35 @@ export async function originateNftFaucet( }; metadata.set('', toHexString('tezos-storage:contents')); metadata.set('contents', toHexString(JSON.stringify(contents))); - return await toolkit.contract.originate({ - code: code, - storage: { - assets: { - ledger: new MichelsonMap(), - next_token_id: 0, - operators: new MichelsonMap(), - token_metadata: new MichelsonMap() - }, - metadata: metadata - } - }); + return { + assets: { + ledger: new MichelsonMap(), + next_token_id: 0, + operators: new MichelsonMap(), + token_metadata: new MichelsonMap() + }, + metadata: metadata + }; } -async function exitOnExistingBootstrap( +async function getContractAddress( config: Configstore, toolkit: TezosToolkit, configKey: string -): Promise { - const address = config.get(configKey); - if (!address) return; - - try { - await toolkit.contract.at(address); - $log.info( - `Contract already exists at address ${address}. Skipping origination` - ); - process.exit(0); - } catch (e) { - return; - } +): Promise { + const existingAddress = config.get(configKey); + if (!existingAddress) return ""; + + return toolkit.contract + .at(existingAddress) + .then(() => existingAddress) + .catch(() => ""); } -async function fetchFaucetContractCode() { +async function fetchContractCode(contractFilename: string): Promise { const rawRepoUrl = 'https://raw.githubusercontent.com/tqtezos/minter-sdk'; const gitHash = '8f67bb8c2abc12b8e6f8e529e1412262972deab3'; - const contractCodeUrl = `${rawRepoUrl}/${gitHash}/contracts/bin/fa2_multi_nft_faucet.tz`; + const contractCodeUrl = `${rawRepoUrl}/${gitHash}/contracts/bin/${contractFilename}`; const response = await axios.get(contractCodeUrl); return { code: response.data, url: contractCodeUrl }; } @@ -122,50 +132,80 @@ function readEnv(): string { return env; } -async function bootstrap(env: string) { - $log.info(`Bootstrapping ${env} environment config...`); - const configKey = 'contracts.nftFaucet'; - const config = readConfig(env); - const toolkit = await createToolkit(config); - - $log.info('Connecting to network...'); - await waitForNetwork(toolkit); - $log.info('Connected'); - - // Exit the script if the contract address defined in the configuration - // already exists on chain - await exitOnExistingBootstrap(config, toolkit, configKey); +async function bootstrapContract( + config: Configstore, + toolkit: TezosToolkit, + params: BootstrapContractParams +): Promise { + const address = await getContractAddress(config, toolkit, params.configKey); + if (address) { + $log.info( + `${params.contractAlias} contract already exists at address ${address}. Skipping origination.` + ); + return; + } let contract; try { - const { code, url: contractCodeUrl } = await fetchFaucetContractCode(); + const { code, url: contractCodeUrl } = await fetchContractCode(params.contractFilename); - $log.info(`Originating contract from ${contractCodeUrl} ...`); + $log.info(`Originating ${params.contractAlias} contract from ${contractCodeUrl} ...`); - const origOp = await originateNftFaucet(toolkit, code); + const storage = params.initStorage(); + const origOp = await toolkit.contract.originate({ + code: code, + storage: storage + }); + contract = await origOp.contract(); - $log.info(`Originated nftFaucet contract at address ${contract.address}`); + $log.info(`Originated ${params.contractAlias} contract at address ${contract.address}`); $log.info(` Consumed gas: ${origOp.consumedGas}`); } catch (error) { const jsonError = JSON.stringify(error, null, 2); - $log.error(`nftFaucet origination error ${jsonError}`); + $log.error(`${params.contractAlias} origination error ${jsonError}`); process.exit(1); } - config.set(configKey, contract.address); + config.set(params.configKey, contract.address); $log.info(`Updated configuration`); +} + +async function bootstrap(env: string) { + $log.info(`Bootstrapping ${env} environment config...`); + const configKey = 'contracts.nftFaucet'; + const config = readConfig(env); + const toolkit = await createToolkit(config); + + $log.info('Connecting to network...'); + await waitForNetwork(toolkit); + $log.info('Connected'); + + // bootstrap NFT faucet + await bootstrapContract(config, toolkit, { + configKey: 'contracts.nftFaucet', + contractAlias: 'nftFaucet', + contractFilename: 'fa2_multi_nft_faucet.tz', + initStorage: initStorageNftFaucet + }); + + // bootstrap marketplace fixed price (tez) + await bootstrapContract(config, toolkit, { + configKey: 'contracts.marketplace.fixedPrice.tez', + contractAlias: 'fixedPriceMarketTez', + contractFilename: 'fixed_price_sale_market_tez.tz', + initStorage: (() => new MichelsonMap()) + }); genClientConfig(config); genServerConfig(config); - - process.exit(0); } async function main() { const env = readEnv(); try { await bootstrap(env); + process.exit(0); } catch (err) { $log.error(`Error while bootstrapping environment ${env}`); $log.error(err); diff --git a/yarn.lock b/yarn.lock index a79d5f59..73e9d6a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,44 +34,44 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@taquito/http-utils@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/http-utils/-/http-utils-8.0.3-beta.0.tgz#aa26b286cb57df52d8eb18d2235302ad4f52e75e" - integrity sha512-9z3terTHUvFATleeD65ULRwqSsK10f/c1jZ1aJCnOgFkGdZ+8SHKFFu0MzY5rXRMwIE0yNQR+uqauFLZ970HUQ== +"@taquito/http-utils@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/http-utils/-/http-utils-8.0.4-beta.0.tgz#12b38bdcd9b406eaadec7ad2264fa446e0a8919c" + integrity sha512-yoPrvkZDWZ/25w3VuTKaUMFwnjPEosD4sMrwOwrT8ZC6ZkHzewB6oVyGb9fPviRALzQH4Wj2jV0Gtmnn9FDsDg== dependencies: xhr2-cookies "^1.1.0" -"@taquito/michel-codec@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/michel-codec/-/michel-codec-8.0.3-beta.0.tgz#7b5be2f371885fea01864950648ec37bcae580b4" - integrity sha512-ike4TTHsRMdg6iE0hypqNyz94x2I24yySdVHU4t19kxuzEJRJDns0SskplveJ5fIlAO7TX/cCJqjumBLIaprVg== +"@taquito/michel-codec@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/michel-codec/-/michel-codec-8.0.4-beta.0.tgz#84f723cba4f1d27af7ce20903f5a76287a72b419" + integrity sha512-cWxewE64vMKRXPeWnanLsYXWYCO2aHiOgsUTdwtFLy9AMgGLOcK04cVQQKmNePWX8NRNJFNDwNUKDvSCKVRxOw== -"@taquito/michelson-encoder@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/michelson-encoder/-/michelson-encoder-8.0.3-beta.0.tgz#e8919e2b0f0f14edfa03205551c2f30ec4b950c1" - integrity sha512-CloKcxh57rcrFazSAscOIrKdlcfO0rVXVoAdIVNfWYyWeEhkRZkSySSPLCYiws8MC9jxUlQYvQo/lT+xGA/gzQ== +"@taquito/michelson-encoder@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/michelson-encoder/-/michelson-encoder-8.0.4-beta.0.tgz#ad078fe49655103a24b109cb17cc2f402486d009" + integrity sha512-2iR50/kJVmP0x43OHpPtP6sETtbKItE1EbeGP5eWvlMxUmZxF2k6cQZVCDyfnSIhwKvC+EWaaSfljy9qYWYU5w== dependencies: - "@taquito/rpc" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/rpc" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" fast-json-stable-stringify "^2.1.0" -"@taquito/rpc@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/rpc/-/rpc-8.0.3-beta.0.tgz#801d352263d97510fc90897eb0b132d214f235c6" - integrity sha512-jrfOR8+RzK9I1VA8VMXJsoy/SGKlO4jN/DFaJHEYZ+KO+woN1bRoOhKKLR15mxe4Zq+P0/Pu+dH1foJn+GbR0w== +"@taquito/rpc@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/rpc/-/rpc-8.0.4-beta.0.tgz#1e8db49976db17392624893c3cb306fa288c7514" + integrity sha512-3L7yaANVJfFJtpcYpZF5JVS9Utrnicyu0hG94gDJlVlugomDRIaURK3tOQ38DF6fus14BHKjOBxChuAp9hQ8gw== dependencies: - "@taquito/http-utils" "^8.0.3-beta.0" + "@taquito/http-utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" lodash "^4.17.20" -"@taquito/signer@8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/signer/-/signer-8.0.3-beta.0.tgz#bb479050b70202ad4f5d1c1796404bb7f7daf807" - integrity sha512-4SS1vBU7Hw9NcEGfdptl6iMWdS83wokAGMwl8ADY2J6UawAoW05DaWcAE/372G9obBzfYlCCgV0QhTgIwvOKWw== +"@taquito/signer@8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/signer/-/signer-8.0.4-beta.0.tgz#1cb38651f1a6e9fe854f332798a0939464b05b2b" + integrity sha512-5GLYYp8Iojx6e1q80cDQ3E6gfXi1o072/fXqk9QM5LhB/buxPDaiuVy4hVo9O9tTud4BiGnNEQU9Ov/f7h9iuA== dependencies: - "@taquito/taquito" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/taquito" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" bip39 "^3.0.2" elliptic "^6.5.3" @@ -79,24 +79,24 @@ pbkdf2 "^3.1.1" typedarray-to-buffer "^3.1.5" -"@taquito/taquito@8.0.3-beta.0", "@taquito/taquito@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/taquito/-/taquito-8.0.3-beta.0.tgz#c68867ff8145753d8a95b6a69c8a03c0ccc3f10c" - integrity sha512-IzRoX6JtBvu4WG1m++345I0fZo7ZZ94jfXl1DI7by9vgWpXKt2FM2GCFx1+hpyLdoKxRhZR5+BpNBHbnBXON4w== +"@taquito/taquito@8.0.4-beta.0", "@taquito/taquito@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/taquito/-/taquito-8.0.4-beta.0.tgz#6cae5116869b55a0ec22173cd0a6b779e03bf9c3" + integrity sha512-Z/+7unJ66AX5Wgni4rtiHoxM87dq0Y+pJh2kPLtB3qwmUbd1TyKwSykxJ2ACo2aj35Q426TlDIwV6NCYpquaLw== dependencies: - "@taquito/http-utils" "^8.0.3-beta.0" - "@taquito/michel-codec" "^8.0.3-beta.0" - "@taquito/michelson-encoder" "^8.0.3-beta.0" - "@taquito/rpc" "^8.0.3-beta.0" - "@taquito/utils" "^8.0.3-beta.0" + "@taquito/http-utils" "^8.0.4-beta.0" + "@taquito/michel-codec" "^8.0.4-beta.0" + "@taquito/michelson-encoder" "^8.0.4-beta.0" + "@taquito/rpc" "^8.0.4-beta.0" + "@taquito/utils" "^8.0.4-beta.0" bignumber.js "^9.0.1" rx-sandbox "^1.0.3" rxjs "^6.6.3" -"@taquito/utils@^8.0.3-beta.0": - version "8.0.3-beta.0" - resolved "https://registry.yarnpkg.com/@taquito/utils/-/utils-8.0.3-beta.0.tgz#55a21f2dca39d227e1c623af3fb97f5e75aa45cb" - integrity sha512-59EmJGNSTRZ2wfFxu2EjzJ3BnLnRFC0PUFiey+KyFYXF4Z6TUZTo8KFRM6CNhpeGh3vfWPU+hdsRd+MO4DeNWA== +"@taquito/utils@^8.0.4-beta.0": + version "8.0.4-beta.0" + resolved "https://registry.yarnpkg.com/@taquito/utils/-/utils-8.0.4-beta.0.tgz#13f0e3bad5ca8099a66c626a601d974bc55e961f" + integrity sha512-pOX1majqHppzS3p3YSkRx+juTHND+DEtchVXzMUnUPGgAK2uG+qxEQKO3QItCfK8A/51S2cVPZ801lHiCkbVeA== dependencies: blakejs "^1.1.0" bs58check "^2.1.2"