diff --git a/README.md b/README.md index 0e7fbfa7..bf2c0468 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ OpenMinter supports the following networks and software components: #### 🎨 Multimedia NFTs powered by [TZIP-21](https://tzip.tezosagora.org/proposal/tzip-21/) #### ⚙️ Smart contracts based on [minter-sdk](https://github.com/tqtezos/minter-sdk) #### 👛 Wallets compatible with [Beacon](https://www.walletbeacon.io/) -#### 📖 Indexing via [Better Call Dev][bcdhub] +#### 📖 Indexing via [tzkt][https://api.tzkt.io/] #### 🚀 [IPFS](https://ipfs.io/) via a local node or [Pinata](https://pinata.cloud/) The following dependencies are required to run OpenMinter. @@ -96,8 +96,7 @@ To install and build the dependences required for local development, run: $ yarn install ``` -The installation process will fetch toplevel NPM dependences and build -the `minter-ui-dev` and `minter-api-dev` Docker images. +The installation process will fetch toplevel NPM dependences ### Running @@ -113,6 +112,32 @@ To run OpenMinter configured for `mainnet`, run: yarn start:mainnet ``` +### Bootstrapping Your Own Contracts + +OpenMinter ships with a set of contracts on mainnet and testnet that are intended +only as a reference implementation and demo. In most cases, you will want to +originate your own contracts to run OpenMinter. OpenMinter includes a configuration +wizard CLI tool to make this process quick and easy. + +To start the configuration wizard, run: + +```sh +yarn bootstrap +``` + +Once the configuration wizard is complete, you can run OpenMinter with your +custom config by running: + +```sh +yarn start:custom +``` + +And to build OpenMinter for a production deployment with your custom config, run: + +```sh +yarn build:custom +``` + ## Sandboxed Mode Sandboxed mode is available for OpenMinter for local testing purposes. Make sure diff --git a/config/mainnet.json b/config/mainnet.json index 65fb36a6..7a5c3563 100644 --- a/config/mainnet.json +++ b/config/mainnet.json @@ -5,6 +5,9 @@ "api": "https://api.better-call.dev", "gui": "https://better-call.dev" }, + "tzkt": { + "api": "https://staging.api.mainnet.tzkt.io" + }, "contracts": { "nftFaucet": "KT1QcxwB4QyPKfmSwjH1VRxa6kquUjeDWeEy", "marketplace": { @@ -13,5 +16,6 @@ } } }, - "ipfsApi": "https://minter-api.tqhosted.com" -} + "ipfsApi": "https://minter-api.tqhosted.com", + "ipfsGateway": "https://tqtezos.mypinata.cloud" +} \ No newline at end of file diff --git a/config/sandbox.json b/config/sandbox.json index 0ccd8e68..45263c9c 100644 --- a/config/sandbox.json +++ b/config/sandbox.json @@ -17,5 +17,6 @@ } } }, - "ipfsApi": "http://localhost:3300" + "ipfsApi": "http://localhost:3300", + "ipfsGateway": "http://localhost:8080" } \ No newline at end of file diff --git a/config/testnet.json b/config/testnet.json index 142ba66c..98061180 100644 --- a/config/testnet.json +++ b/config/testnet.json @@ -5,6 +5,9 @@ "api": "https://api.better-call.dev", "gui": "https://better-call.dev" }, + "tzkt": { + "api": "https://staging.api.edo2net.tzkt.io" + }, "contracts": { "nftFaucet": "KT1Hagc5XQYzkX7HfRbUNXdi4CBfiENzbHiU", "marketplace": { @@ -13,5 +16,6 @@ } } }, - "ipfsApi": "https://minter-api.tqhosted.com" -} + "ipfsApi": "https://minter-api.tqhosted.com", + "ipfsGateway": "https://tqtezos.mypinata.cloud" +} \ No newline at end of file diff --git a/package.json b/package.json index 2c8296a9..e0efb8b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "client", - "version": "0.2.0", + "version": "0.7.0", "private": true, + "license": "MIT", "dependencies": { "@chakra-ui/react": "1.1.2", "@emotion/core": "10.0.28", @@ -11,9 +12,9 @@ "@taquito/beacon-wallet": "8.1.0", "@taquito/signer": "8.1.0", "@taquito/taquito": "8.1.0", - "@taquito/tzip16": "8.1.0", "@taquito/tzip12": "8.1.0", - "@tqtezos/minter-contracts": "1.0.3", + "@taquito/tzip16": "8.1.0", + "@tqtezos/minter-contracts": "1.2.0", "@types/lodash": "4.14.165", "@types/react": "16.9.12", "@types/react-dom": "16.9.0", @@ -21,8 +22,13 @@ "@types/react-redux": "7.1.16", "axios-retry": "3.1.9", "buffer": "6.0.3", + "clear": "0.1.0", + "clui": "0.3.6", + "figlet": "1.5.0", + "fp-ts": "2.10.3", "framer-motion": "3.1.4", "immer": "8.0.0", + "io-ts": "2.2.16", "joi": "17.3.0", "react": "16.13.1", "react-dom": "16.13.1", @@ -30,6 +36,7 @@ "react-feather": "2.0.9", "react-icons": "4.2.0", "react-redux": "7.2.2", + "shelljs": "0.8.4", "typescript": "4.1.3", "wouter": "2.5.1" }, @@ -39,8 +46,13 @@ "@testing-library/user-event": "7.1.2", "@tsed/logger": "5.5.2", "@types/async-retry": "1.4.2", + "@types/clear": "0.1.1", + "@types/clui": "0.3.0", "@types/configstore": "4.0.0", + "@types/figlet": "1.5.1", + "@types/inquirer": "7.3.1", "@types/jest": "24.0.0", + "@types/shelljs": "0.8.8", "async-retry": "1.3.1", "axios": "0.21.1", "configstore": "5.0.1", @@ -53,10 +65,14 @@ "start:testnet": "cp ./config/testnet.json src/config.json && react-scripts start", "start:sandbox": "cp ./config/sandbox-bootstrapped.json src/config.json && react-scripts start", "start:mainnet": "cp ./config/mainnet.json src/config.json && react-scripts start", + "start:custom": "cp ./config/custom-bootstrapped.json src/config.json && react-scripts start", "build": "react-scripts build", + "build:custom": "cp ./config/custom-bootstrapped.json src/config.json && react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", + "bootstrap": "ts-node -P scripts/tsconfig.json scripts/bootstrap.ts", "bootstrap:contracts": "ts-node -P scripts/tsconfig.json scripts/bootstrap-contracts-config.ts", + "bootstrap:custom": "yarn bootstrap:contracts custom", "bootstrap:sandbox": "docker-compose down && docker-compose up -d && yarn bootstrap:contracts sandbox", "teardown:sandbox": "docker-compose down" }, diff --git a/public/index.html b/public/index.html index 1bcb0621..5746d403 100644 --- a/public/index.html +++ b/public/index.html @@ -43,6 +43,10 @@ } }); + + diff --git a/scripts/bootstrap-contracts-config.ts b/scripts/bootstrap-contracts-config.ts index db7bd7e4..31fd28b4 100644 --- a/scripts/bootstrap-contracts-config.ts +++ b/scripts/bootstrap-contracts-config.ts @@ -7,7 +7,8 @@ import { MichelsonMap, TezosToolkit } from '@taquito/taquito'; import { InMemorySigner } from '@taquito/signer'; import { Fa2MultiNftFaucetCode, - FixedPriceSaleMarketTezCode + FixedPriceSaleMarketTezCode, + FixedPriceSaleTezFixedFeeCode } from '@tqtezos/minter-contracts'; function toHexString(input: string) { @@ -161,19 +162,46 @@ async function bootstrap(env: string) { }); // bootstrap marketplace fixed price (tez) - await bootstrapContract(bootstrappedConfig, toolkit, { - configKey: 'contracts.marketplace.fixedPrice.tez', - contractAlias: 'fixedPriceMarketTez', - contractCode: FixedPriceSaleMarketTezCode.code, - initStorage: () => ({ sales: new MichelsonMap() }) - }); + const marketplaceFeePercent = config.get("contractOpts.marketplace.fee.percent"); + const marketplaceFeeAddress = config.get("contractOpts.marketplace.fee.address"); + if (marketplaceFeePercent && marketplaceFeeAddress) { + await bootstrapContract(bootstrappedConfig, toolkit, { + configKey: 'contracts.marketplace.fixedPrice.tez', + contractAlias: 'fixedPriceMarketTez', + contractCode: FixedPriceSaleTezFixedFeeCode.code, + initStorage: () => ({ + admin: { + admin: config.get("admin.address"), + paused: false + }, + fee: { + fee_address: marketplaceFeeAddress, + fee_percent: marketplaceFeePercent + }, + sales: new MichelsonMap() + }) + }); + } else { + await bootstrapContract(bootstrappedConfig, toolkit, { + configKey: 'contracts.marketplace.fixedPrice.tez', + contractAlias: 'fixedPriceMarketTez', + contractCode: FixedPriceSaleMarketTezCode.code, + initStorage: () => ({ + admin: { + admin: config.get("admin.address"), + paused: false + }, + sales: new MichelsonMap() + }) + }); + } } async function main() { console.log(process.argv[2]); const envArg = process.argv[2]; let env; - if (['mainnet', 'testnet', 'sandbox'].includes(envArg)) { + if (['mainnet', 'testnet', 'custom', 'sandbox'].includes(envArg)) { env = envArg; } else { env = readEnv(); diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts new file mode 100644 index 00000000..09f8f842 --- /dev/null +++ b/scripts/bootstrap.ts @@ -0,0 +1,262 @@ +import path from 'path'; +import Configstore from 'configstore'; +import { validateKeyHash, ValidationResult } from '@taquito/utils'; +import * as figlet from 'figlet'; +import clear from 'clear'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import shelljs from 'shelljs'; +import { Spinner } from 'clui'; + +interface ConfigInput { + network: string; + rpc: string; + rpcCustom?: string; + adminPkh: string; + adminSk: string; + marketplaceHasFee: boolean; + marketplaceFeePercent?: number; + marketplaceFeeAddress?: string; +} + +const stripAnsi = (input: string): string => { + return input.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); +}; + +// initializes and displays the welcome screen +const init = () => { + clear(); + console.log( + "\n" + + chalk.green(figlet.textSync('>OpenMinter', { font: 'ANSI Shadow' })) + + "\n\n" + + chalk.cyan("Welcome to OpenMinter!") + + "\n\n" + + "The following questions will help configure and bootstrap a custom\n" + + "installation of OpenMinter. " + + chalk.yellow("Please have your wallet private key on hand.") + "\n" + + "You will be asked for it in order to originate the OpenMinter contracts.\n" + + "\n" + ); +}; + +const finish = () => { + console.log( + "\n" + + chalk.bold("Your customized OpenMinter is bootstrapped and ready to go!") + "\n" + + "\n" + + "Your customized config is located at " + chalk.yellow("config/custom-bootstrapped.json") + "\n" + + "We " + chalk.bold("strongly") + " recommend that you create a backup copy" + + "of this configuration file.\n" + + "\n\n" + + "You can now start your customized OpenMinter by running: " + chalk.yellow("yarn start:custom") + "\n" + + "\n" + + "‎️‍🔥🔥🔥 The OpenMinter Team 🔥🔥🔥\n" + ); +}; + +const askConfigQuestions = (): Promise => { + const questions = [ + { + name: 'network', + type: 'list', + choices: ['Mainnet', 'Edonet'], + message: 'Select the network to deploy OpenMinter contracts to:', + filter: function (val: string) { + return val.toLowerCase(); + }, + }, + { + name: 'rpc', + type: 'list', + choices: (input: any) => { + let rpcOpts: any[] = []; + if (input.network === "mainnet") { + rpcOpts = [ + "https://rpc.tzbeta.net", + "https://mainnet.smartpy.io", + "https://api.tez.ie/rpc/mainnet", + "https://mainnet-tezos.giganode.io", + "Other" + ] + } else if (input.network === "edonet") { + rpcOpts = [ + "https://rpctest.tzbeta.net", + "https://edonet.smartpy.io", + "https://api.tez.ie/rpc/edonet", + "https://edonet-tezos.giganode.io", + "Other" + ] + } + return rpcOpts; + }, + message: 'Select an RPC node to connect to:', + filter: function (val: string) { + return val; + }, + }, + { + name: 'rpcCustom', + type: 'input', + when: (input: any) => input.rpc === "Other", + message: 'Enter the RPC node you want to connect to:', + validate: (input: string) => { + const regex = new RegExp(/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi); + if (input.match(regex)) { + return true; + } + return "Please enter a valid url (starting with http:// or https://)"; + }, + filter: function (val: string) { + if (val.substr(-1) === '/') { + val = val.substr(0, val.length - 1); + } + return val; + }, + }, + { + name: 'adminPkh', + type: 'input', + message: 'Enter the wallet address for the admin of the contracts:', + validate: (input: string) => { + if (validateKeyHash(input) === ValidationResult.VALID) { + return true; + } + return "Please enter a valid wallet address"; + } + }, + { + name: 'marketplaceHasFee', + type: 'confirm', + message: 'Do you want to collect a fee on sales in the marketplace?' + }, + { + name: 'marketplaceFeePercent', + type: 'number', + message: 'Enter the marketplace percentage fee (0-100):', + when: (input: any) => input.marketplaceHasFee, + validate: (input: number) => { + if (Number.isNaN(input) || !Number.isInteger(input) || input < 0 || input > 100) { + return "Please enter a whole number between 0 and 100"; + } + return true; + } + }, + { + name: 'marketplaceFeeAddress', + type: 'input', + message: 'Enter the address of the wallet that will receive the fee:', + when: (input: any) => input.marketplaceHasFee, + validate: (input: string) => { + if (validateKeyHash(input) === ValidationResult.VALID) { + return true; + } + return "Please enter a valid wallet address"; + } + }, + { + name: 'adminSk', + type: 'password', + message: 'Enter the secret key of the wallet that will originate the contracts:' + }, + ] + + return inquirer.prompt(questions); +} + +const getConfigstore = (): Configstore => { + const configFileName = path.join( + __dirname, + `../config/custom.json` + ); + return new Configstore('minter', {}, { configPath: configFileName }); +} + +const getBootstrappedConfigstore = (): Configstore => { + const configFileName = path.join( + __dirname, + `../config/custom-bootstrapped.json` + ); + return new Configstore('minter', {}, { configPath: configFileName }); +} + +const saveConfig = (input: ConfigInput) => { + const config = getConfigstore(); + config.set('network', input.network); + config.set('rpc', input.rpc === "Other" ? input.rpcCustom : input.rpc); + config.set('bcd.api', "https://api.better-call.dev"); + config.set('bcd.gui', "https://better-call.dev"); + config.set('tzkt.api', input.network === "mainnet" ? "https://staging.api.mainnet.tzkt.io" : "https://staging.api.edo2net.tzkt.io"); + config.set('admin.address', input.adminPkh); + config.set('admin.secret', input.adminSk); + config.set('ipfsApi', "https://minter-api.tqhosted.com"); + config.set('ipfsGateway', "https://tqtezos.mypinata.cloud"); + + if (input.marketplaceHasFee) { + config.set("contractOpts.marketplace.fee.percent", input.marketplaceFeePercent); + config.set("contractOpts.marketplace.fee.address", input.marketplaceFeeAddress); + } +} + +const bootstrapContracts = async (): Promise => { + console.log( + "\n" + + chalk.cyan("Bootstrapping contracts...") + ); + + shelljs.rm('-f', path.join( + __dirname, + `../config/custom-bootstrapped.json` + )); + + return new Promise((resolve, reject) => { + const spinner = new Spinner('Bootstrapping contracts...', ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷']); + const child = shelljs.exec('yarn bootstrap:custom', { async: true, silent: true }); + spinner.start(); + + let longest = 0; + child.stdout?.on('data', function(data) { + const msg = stripAnsi(data).trim(); + longest = Math.max(longest, msg.length); + spinner.message(msg.padEnd(longest, ' ')); + }); + + child.stderr?.on('data', function(data) { + spinner.stop(); + console.log( + "Encountered error:" + "\n" + + chalk.red(stripAnsi(data).trim()) + ); + reject(); + }); + + child.on('exit', (code) => { + console.log( + "\n" + + ' ' + chalk.green('✔ Done bootstrapping contracts') + ); + spinner.stop(); + resolve(); + }); + }); +} + +const cleanup = () => { + const config = getBootstrappedConfigstore(); + config.delete("admin.secret"); +} + +// Main +(async () => { + + init(); + + saveConfig(await askConfigQuestions()); + + await bootstrapContracts(); + + cleanup(); + + finish(); + +})(); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index b21a5919..1ea01609 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "lib": [ "es2015", "dom" ], "target": "ES5", "module": "commonjs", "strict": true, diff --git a/src/@types/ipfs-http-client.d.ts b/src/@types/ipfs-http-client.d.ts index c30c2b99..4cd4486f 100644 --- a/src/@types/ipfs-http-client.d.ts +++ b/src/@types/ipfs-http-client.d.ts @@ -1,5 +1,4 @@ declare module 'ipfs-http-client' { - export type FileContent = any | Blob | string; export interface Cid { @@ -16,6 +15,5 @@ declare module 'ipfs-http-client' { add: (data: FileContent) => Promise; } - export default function(any): IpfsClientApi; + export default function (any): IpfsClientApi; } - diff --git a/src/@types/jsx-intrinsic.d.ts b/src/@types/jsx-intrinsic.d.ts new file mode 100644 index 00000000..a434be72 --- /dev/null +++ b/src/@types/jsx-intrinsic.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace JSX { + interface IntrinsicElements { + 'model-viewer': any; + } + } +} + +export {}; diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index bb92cc2f..9da7ff9c 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import { Switch, Route } from 'wouter'; -import SplashPage from '../SplashPage'; import CreateNonFungiblePage from '../CreateNonFungiblePage'; import CollectionsCatalog from '../Collections/Catalog'; import CollectionDisplay from '../Collections/Catalog/CollectionDisplay'; @@ -11,19 +10,21 @@ import { Flex } from '@chakra-ui/react'; import Notifications from '../common/Notifications'; import { useSelector, useDispatch } from '../../reducer'; import { reconnectWallet } from '../../reducer/async/wallet'; -import { getMarketplaceNftsQuery } from '../../reducer/async/queries'; +// import { getMarketplaceNftsQuery } from '../../reducer/async/queries'; export default function App() { const dispatch = useDispatch(); - const state = useSelector( - s => s - ); + const state = useSelector(s => s); - let walletReconnectAttempted = state.system.walletReconnectAttempted + let walletReconnectAttempted = state.system.walletReconnectAttempted; - useEffect(() => { - dispatch(getMarketplaceNftsQuery(state.marketplace.marketplace.address)); - }, [ state.marketplace.marketplace.address, dispatch ]); + // // This causes excessive resource consumption as *all* marketplace data + // // loads when the app is mounted, even if the user has not landed or will + // // not land on the `/marketplace` view + // + // useEffect(() => { + // dispatch(getMarketplaceNftsQuery(state.marketplace.marketplace.address)); + // }, [state.marketplace.marketplace.address, dispatch]); useEffect(() => { if (!walletReconnectAttempted) { @@ -31,17 +32,13 @@ export default function App() { } }, [walletReconnectAttempted, dispatch]); - if (!walletReconnectAttempted) { - return null; - } - return (
- + diff --git a/src/components/Collections/Catalog/CollectionDisplay.tsx b/src/components/Collections/Catalog/CollectionDisplay.tsx index f143081d..796a5c6c 100644 --- a/src/components/Collections/Catalog/CollectionDisplay.tsx +++ b/src/components/Collections/Catalog/CollectionDisplay.tsx @@ -12,11 +12,14 @@ import { Text } from '@chakra-ui/react'; import { MinterButton } from '../../common'; -import { RefreshCw, ExternalLink, Wind, HelpCircle } from 'react-feather'; +import { ExternalLink, Wind, HelpCircle } from 'react-feather'; import { Token } from '../../../reducer/slices/collections'; -import { ipfsUriToGatewayUrl } from '../../../lib/util/ipfs'; +import { IpfsGatewayConfig, ipfsUriToGatewayUrl } from '../../../lib/util/ipfs'; import { useDispatch, useSelector } from '../../../reducer'; -import { getContractNftsQuery } from '../../../reducer/async/queries'; +import { + getContractNftsQuery, + getNftAssetContractQuery +} from '../../../reducer/async/queries'; import CollectionsDropdown from './CollectionsDropdown'; function MediaNotFound() { @@ -35,14 +38,15 @@ function MediaNotFound() { ); } -function TokenImage(props: { src: string }) { +function TokenImage(props: TokenTileProps) { + const src = ipfsUriToGatewayUrl(props.config, props.artifactUri); const [errored, setErrored] = useState(false); const [obj, setObj] = useState<{ url: string; type: string } | null>(null); useEffect(() => { (async () => { let blob; try { - blob = await fetch(props.src).then(r => r.blob()); + blob = await fetch(src).then(r => r.blob()); } catch (e) { return setErrored(true); } @@ -51,7 +55,7 @@ function TokenImage(props: { src: string }) { type: blob.type }); })(); - }, [props.src]); + }, [src]); if (errored) { return ; @@ -62,7 +66,7 @@ function TokenImage(props: { src: string }) { if (/^image\/.*/.test(obj.type)) { return ( e.preventDefault()} onMouseEnter={e => e.currentTarget.play()} onMouseLeave={e => e.currentTarget.pause()} + muted > ); } + if (props.metadata.formats?.length) { + if ( + props.metadata.formats[0].mimeType === 'model/gltf-binary' || + props.metadata.formats[0].mimeType === 'model/gltf+json' + ) { + return ( + <> + + + ); + } + } + return ; } interface TokenTileProps extends Token { - network: string; + config: IpfsGatewayConfig; address: string; } @@ -116,9 +139,7 @@ function TokenTile(props: TokenTileProps) { > - + { if (address !== null) { - dispatch(getContractNftsQuery(address)); + dispatch(getNftAssetContractQuery(address)).then(() => + dispatch(getContractNftsQuery(address)) + ); } }, [address, dispatch]); @@ -275,12 +298,7 @@ export default function CollectionDisplay({ base: 4, md: 0 }} - > - - - - Refresh - + > {tokens.map(token => { @@ -288,7 +306,7 @@ export default function CollectionDisplay({ ); diff --git a/src/components/Collections/Catalog/CollectionsDropdown.tsx b/src/components/Collections/Catalog/CollectionsDropdown.tsx index 7e062eb0..10907349 100644 --- a/src/components/Collections/Catalog/CollectionsDropdown.tsx +++ b/src/components/Collections/Catalog/CollectionsDropdown.tsx @@ -12,8 +12,7 @@ import { } from '@chakra-ui/react'; import { useSelector, useDispatch } from '../../../reducer'; import { selectCollection } from '../../../reducer/slices/collections'; -import { ChevronDown, RefreshCw } from 'react-feather'; -import { getContractNftsQuery } from '../../../reducer/async/queries'; +import { ChevronDown } from 'react-feather'; export default function CollectionsDropdown() { const state = useSelector(s => s.collections); @@ -73,23 +72,6 @@ export default function CollectionsDropdown() { - { - const selectedCollection = state.selectedCollection; - if (selectedCollection !== null) { - dispatch(getContractNftsQuery(selectedCollection)); - } - }} - padding={2} - borderRadius="5px" - border="1px solid" - borderColor="brand.blue" - marginLeft={3} - cursor="pointer" - > - - ); } diff --git a/src/components/Collections/Catalog/Sidebar.tsx b/src/components/Collections/Catalog/Sidebar.tsx index 42fedd83..108c9c2f 100644 --- a/src/components/Collections/Catalog/Sidebar.tsx +++ b/src/components/Collections/Catalog/Sidebar.tsx @@ -55,6 +55,7 @@ function CollectionTab({ } export default function Sidebar() { + const tzPublicKey = useSelector(s => s.system.tzPublicKey); const state = useSelector(s => s.collections); const dispatch = useDispatch(); return ( @@ -71,12 +72,14 @@ export default function Sidebar() { > Featured - dispatch(selectCollection(address))} - {...state.collections[state.globalCollection]} - /> + {state.collections[state.globalCollection] ? ( + dispatch(selectCollection(address))} + {...state.collections[state.globalCollection]} + /> + ) : null} {Object.keys(state.collections) - .filter(address => address !== state.globalCollection) + .filter( + address => + address !== state.globalCollection && + state.collections[address]?.creator?.address === tzPublicKey + ) .map(address => ( s.system); const collections = useSelector(s => s.collections); const dispatch = useDispatch(); + const globalCollection = + collections.collections[collections.globalCollection]; + useEffect(() => { + if (!globalCollection) { + dispatch(getNftAssetContractQuery(collections.globalCollection)); + return; + } if (collections.selectedCollection === null) { dispatch(selectCollection(collections.globalCollection)); + return; } - }, [collections.selectedCollection, collections.globalCollection, dispatch]); + }, [ + globalCollection, + collections.selectedCollection, + collections.globalCollection, + dispatch + ]); useEffect(() => { - if (system.status !== 'WalletConnected') { - setLocation('/', { replace: true }); - } else { + if (system.status === 'WalletConnected') { dispatch(getWalletAssetContractsQuery()); } - }, [system.status, setLocation, dispatch]); + }, [system.status, dispatch]); const selectedCollection = collections.selectedCollection; - if (system.status !== 'WalletConnected' || !selectedCollection) { - return null; + if (system.walletReconnectAttempted && system.status !== 'WalletConnected') { + return ( + + + + + Create NFTs on Tezos + + + { + e.preventDefault(); + dispatch(connectWallet()); + }} + > + Connect your wallet + + + + + + OpenMinter Version v{process.env.REACT_APP_VERSION} + + + + GitHub + + + + + ); } return ( diff --git a/src/components/Collections/TokenDetail/index.tsx b/src/components/Collections/TokenDetail/index.tsx index f3086da2..7d038631 100644 --- a/src/components/Collections/TokenDetail/index.tsx +++ b/src/components/Collections/TokenDetail/index.tsx @@ -11,6 +11,7 @@ import { Flex, Heading, Image, + Link, Menu, MenuList, Modal, @@ -24,7 +25,7 @@ import { Text, useDisclosure } from '@chakra-ui/react'; -import { ChevronLeft, HelpCircle, MoreHorizontal, Star } from 'react-feather'; +import { ChevronLeft, HelpCircle, MoreHorizontal } from 'react-feather'; import { MinterButton, MinterMenuButton, MinterMenuItem } from '../../common'; import { TransferTokenModal } from '../../common/modals/TransferToken'; import { SellTokenButton } from '../../common/modals/SellToken'; @@ -36,8 +37,10 @@ import { getContractNftsQuery, getNftAssetContractQuery } from '../../../reducer/async/queries'; - +import lk from '../../common/assets/link-icon.svg' +import tz from '../../common/assets/tezos-sym.svg' import { Maximize2 } from 'react-feather'; +import { NftMetadata } from '../../../lib/nfts/decoders'; function NotFound() { return ( @@ -91,12 +94,15 @@ function MediaNotFound() { function TokenImage(props: { id?: string; src: string; + metadata: NftMetadata; width?: string; maxWidth?: string; maxHeight?: string; height?: string; objectFit?: ResponsiveValue; - onLoad?: (event: React.SyntheticEvent) => void; + cursor?: string; + onClick?: (event: React.SyntheticEvent) => void; + onLoad?: (event: React.SyntheticEvent) => void; onFetch?: (type: string) => void; }) { const [errored, setErrored] = useState(false); @@ -129,11 +135,12 @@ function TokenImage(props: { key={props.id || 'assetImage'} src={props.src} objectFit={props.objectFit ?? "scale-down"} - flex="1" height={props.height ?? "100%"} width={props.width} maxWidth={props.maxWidth} maxHeight={props.maxHeight ?? 'unset'} + cursor={props.cursor} + onClick={props.onClick} onError={() => setErrored(true)} onLoad={props.onLoad} /> @@ -145,17 +152,41 @@ function TokenImage(props: { ); } + if (props.metadata.formats?.length) { + if ( + props.metadata.formats[0].mimeType === 'model/gltf-binary' || + props.metadata.formats[0].mimeType === 'model/gltf+json' + ) { + return ( + <> + + + ); + } + } + return ; } @@ -235,7 +266,7 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { justifyContent="center" alignItems="center" position="relative" - backgroundColor="#333333f9" + backgroundColor="#222222f9" zIndex="2000" margin="0 !important" borderRadius="0" @@ -269,17 +300,35 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { - + - + { @@ -288,96 +337,40 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { }} > - + - - - {isOwner ? ( - - - - - - {token.sale ? ( - <> - ) : ( - - Transfer - - )} - - - - ) : ( - <> - )} - - {token.sale ? ( - isOwner ? ( - - - - ꜩ - - - {token.sale.price} - - - - - ) : ( - <> - - {token.sale.price.toFixed(2)}ꜩ - - - - ) - ) : isOwner ? ( - - ) : ( - <> - )} - - + + + - + + + - {isOwner ? ( - - - - - You own this asset - - + + + {token.title} + + + {token.sale ? ( + isOwner ? ( + <> + + {token.sale.price} + + + + + + ) : ( + <> + + {token.sale.price.toFixed(2)} + + + + + + ) + ) : isOwner ? ( + + + + ) : ( + <> + )} + {isOwner ? ( + + + + + + {token.sale ? ( + <> + ) : ( + + Transfer + + )} + + + + ) : ( + <> + )} - ) : null} - - {token.title} - - - Minter: {token.metadata?.minter || 'Unknown'} - + {token.description || 'No description provided'} - - - Collection - - {contractAddress} - - - - Metadata + Token Info + + Minter: + + {token.owner}  + + + + Collection: + + {contractAddress}  + + {token.metadata?.attributes?.map(({ name, value }) => ( {name}: - + {value} @@ -453,8 +495,8 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { - + ); } -export default TokenDetail; \ No newline at end of file +export default TokenDetail; diff --git a/src/components/CreateNonFungiblePage/CollectionSelect.tsx b/src/components/CreateNonFungiblePage/CollectionSelect.tsx index a57c6f55..45828bfe 100644 --- a/src/components/CreateNonFungiblePage/CollectionSelect.tsx +++ b/src/components/CreateNonFungiblePage/CollectionSelect.tsx @@ -40,6 +40,7 @@ export default function CollectionSelect() { fontWeight="normal" py={3} height="auto" + backgroundColor="white" color={state.collectionAddress ? 'brand.black' : 'brand.gray'} > diff --git a/src/components/CreateNonFungiblePage/FileUpload.tsx b/src/components/CreateNonFungiblePage/FileUpload.tsx index 1b29154a..3b29b637 100644 --- a/src/components/CreateNonFungiblePage/FileUpload.tsx +++ b/src/components/CreateNonFungiblePage/FileUpload.tsx @@ -11,7 +11,14 @@ 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(); @@ -19,6 +26,7 @@ export function FilePreview({ file }: { file: SelectedFile }) { <> ); } diff --git a/src/components/Marketplace/Catalog/TokenCard.tsx b/src/components/Marketplace/Catalog/TokenCard.tsx index 897af4f3..bff46713 100644 --- a/src/components/Marketplace/Catalog/TokenCard.tsx +++ b/src/components/Marketplace/Catalog/TokenCard.tsx @@ -1,31 +1,33 @@ import React from 'react'; import { Token } from '../../../reducer/slices/collections'; import { useLocation } from 'wouter'; -import { ipfsUriToGatewayUrl } from '../../../lib/util/ipfs'; -import { AspectRatio, Box, Flex, Text, Heading } from '@chakra-ui/react'; +import { IpfsGatewayConfig } from '../../../lib/util/ipfs'; +import { AspectRatio, Box, Flex } from '@chakra-ui/react'; import { TokenMedia } from '../../common/TokenMedia'; +import tz from '../../common/assets/tezos-sym.svg' interface TokenCardProps extends Token { - network: string; + config: IpfsGatewayConfig; } export default function TokenCard(props: TokenCardProps) { const [, setLocation] = useLocation(); return ( setLocation(`/collection/${props.address}/token/${props.id}`) @@ -33,9 +35,7 @@ export default function TokenCard(props: TokenCardProps) { > - + - {props.title} - Seller: {props.sale?.seller.substr(0, 5)}...{props.sale?.seller.substr(-5)} - - - Current Price - {props.sale?.price} ꜩ + {props.title} + + {props.sale?.price} + ); -} +} \ No newline at end of file diff --git a/src/components/Marketplace/Catalog/index.tsx b/src/components/Marketplace/Catalog/index.tsx index fdb3f211..e1bd7e81 100644 --- a/src/components/Marketplace/Catalog/index.tsx +++ b/src/components/Marketplace/Catalog/index.tsx @@ -1,10 +1,14 @@ import React, { useEffect } from 'react'; -import { Box, Container, Text, Flex, Heading, SimpleGrid, Spinner } from '@chakra-ui/react'; +import { Text, Flex, Heading, SimpleGrid, Spinner, Box } from '@chakra-ui/react'; import { Wind } from 'react-feather'; import { useSelector, useDispatch } from '../../../reducer'; -import { getMarketplaceNftsQuery } from '../../../reducer/async/queries'; +import { + getMarketplaceNftsQuery, + loadMoreMarketplaceNftsQuery +} from '../../../reducer/async/queries'; import TokenCard from './TokenCard'; import FeaturedToken from './FeaturedToken'; +import { VisibilityTrigger } from '../../common/VisibilityTrigger'; export default function Catalog() { const { system, marketplace: state } = useSelector(s => s); @@ -12,16 +16,18 @@ export default function Catalog() { useEffect(() => { dispatch(getMarketplaceNftsQuery(state.marketplace.address)); - }, [ state.marketplace.address, dispatch ]); + }, [state.marketplace.address, dispatch]); - let tokens = state.marketplace.tokens; - if (tokens === null) { - tokens = []; - } + const loadMore = () => { + dispatch(loadMoreMarketplaceNftsQuery({})); + }; + + let tokens = + state.marketplace.tokens?.filter(x => x.token).map(x => x.token!) ?? []; return ( {state.marketplace.loaded && tokens.length > 0 ? ( - - - + + + ) : null} - - - {!state.marketplace.loaded ? ( - - - - Loading... + + {!state.marketplace.loaded ? ( + + + + Loading... - - ) : - tokens.length === 0 ? ( - - - - - No tokens to display in this marketplace + + ) : tokens.length === 0 ? ( + + + + + No tokens to display in this marketplace - - ) : ( + + ) : ( + <> + <> - - {tokens.slice(1).map(token => { - return ( + {tokens.slice(1).map(token => { + return ( + - ); - })} - + + ); + })} + - )} - - + + + )} + ); } diff --git a/src/components/SplashPage/index.tsx b/src/components/SplashPage/index.tsx deleted file mode 100644 index 6ba81f5e..00000000 --- a/src/components/SplashPage/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect } from 'react'; -import { useLocation } from 'wouter'; -import { Flex, Text, Heading, Image, Link } from '@chakra-ui/react'; -import { MinterButton /* , MinterLink */ } from '../common'; -import logo from '../common/assets/splash-logo.svg'; -import { useSelector, useDispatch } from '../../reducer'; -import { connectWallet } from '../../reducer/async/wallet'; - -export default function SplashPage() { - const [, setLocation] = useLocation(); - const system = useSelector(s => s.system); - const dispatch = useDispatch(); - - useEffect(() => { - if (system.status === 'WalletConnected') { - setLocation('/collections'); - } - }, [system.status, setLocation]); - - return ( - - - - - Create NFTs on Tezos - - - { - e.preventDefault(); - dispatch(connectWallet()); - }} - > - Connect your wallet - - - - - - OpenMinter Version v{process.env.REACT_APP_VERSION} - - - - GitHub - - - - - ); -} diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index e299bb30..f2a48417 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -17,15 +17,15 @@ import { DrawerBody, Heading } from '@chakra-ui/react'; -import { Plus, Settings, Menu as HamburgerIcon } from 'react-feather'; +import { Plus, Menu as HamburgerIcon } from 'react-feather'; import { RiStore2Line } from 'react-icons/ri'; -// import { IoCubeOutline } from 'react-icons/io5'; import { MdCollections } from 'react-icons/md'; import headerLogo from './assets/header-logo.svg'; import { useSelector, useDispatch } from '../../reducer'; import { connectWallet, disconnectWallet } from '../../reducer/async/wallet'; import { MinterButton } from '.'; import logo from './assets/splash-logo.svg'; +import wallet_icon from './assets/wallet.svg'; interface MobileHeaderLinkProps { to: string; @@ -120,12 +120,24 @@ function WalletDisplay() { const dispatch = useDispatch(); return ( <> - - - - - - {system.status === 'WalletConnected' ? ( + {system.status === 'WalletConnected' ? ( + + + + + Network: {system.config.network} @@ -142,24 +154,20 @@ function WalletDisplay() { Disconnect - ) : ( - - - No wallet connected - - { - e.preventDefault(); - dispatch(connectWallet()); - }} - > - Connect your Wallet - - - )} - - + + + ) : ( + { + e.preventDefault(); + dispatch(connectWallet()); + }} + > + Connect Wallet + + + )} ); } @@ -238,7 +246,13 @@ function NavItems() { }} mb={4} > - Connect your Wallet + Connect Wallet + )} @@ -294,10 +308,8 @@ function NavItems() { } export function Header() { - const [location, setLocation] = useLocation(); - if (location === '/' || location === '') { - return null; - } + const [, setLocation] = useLocation(); + return ( { e.preventDefault(); - setLocation('/collections'); + setLocation('/marketplace'); }} cursor="pointer" /> @@ -330,7 +342,7 @@ export function Header() { src={headerLogo} onClick={e => { e.preventDefault(); - setLocation('/collections'); + setLocation('/marketplace'); }} cursor="pointer" /> diff --git a/src/components/common/TokenMedia.tsx b/src/components/common/TokenMedia.tsx index 38f8dc92..327268cd 100644 --- a/src/components/common/TokenMedia.tsx +++ b/src/components/common/TokenMedia.tsx @@ -1,6 +1,14 @@ import React, { useEffect, useState } from 'react'; import { Flex, Image } from '@chakra-ui/react'; import { FiHelpCircle } from 'react-icons/fi'; +import { IpfsGatewayConfig, ipfsUriToGatewayUrl } from '../../lib/util/ipfs'; +import { Token } from '../../reducer/slices/collections'; + +interface TokenMediaProps extends Token { + config: IpfsGatewayConfig; + maxW?: string; + class?: string; +} function MediaNotFound() { return ( @@ -18,14 +26,15 @@ function MediaNotFound() { ); } -export function TokenMedia(props: { src: string, maxW?: string }) { +export function TokenMedia(props: TokenMediaProps) { + const src = ipfsUriToGatewayUrl(props.config, props.artifactUri); const [errored, setErrored] = useState(false); const [obj, setObj] = useState<{ url: string; type: string } | null>(null); useEffect(() => { (async () => { let blob; try { - blob = await fetch(props.src).then(r => r.blob()); + blob = await fetch(src).then(r => r.blob()); } catch (e) { return setErrored(true); } @@ -34,7 +43,7 @@ export function TokenMedia(props: { src: string, maxW?: string }) { type: blob.type }); })(); - }, [props.src]); + }, [src]); if (errored) { return ; @@ -43,15 +52,13 @@ export function TokenMedia(props: { src: string, maxW?: string }) { if (!obj) return null; if (/^image\/.*/.test(obj.type)) { - console.log(props.src) return ( setErrored(true)} /> ); @@ -64,13 +71,31 @@ export function TokenMedia(props: { src: string, maxW?: string }) { onClick={e => e.preventDefault()} onMouseEnter={e => e.currentTarget.play()} onMouseLeave={e => e.currentTarget.pause()} - height="100%" - style={{maxWidth:props.maxW}} + style={{ maxWidth: props.maxW }} + muted > ); } + if (props.metadata.formats?.length) { + if ( + props.metadata.formats[0].mimeType === 'model/gltf-binary' || + props.metadata.formats[0].mimeType === 'model/gltf+json' + ) { + return ( + <> + + + ); + } + } + return ; } diff --git a/src/components/common/VisibilityTrigger.tsx b/src/components/common/VisibilityTrigger.tsx new file mode 100644 index 00000000..a41b2f67 --- /dev/null +++ b/src/components/common/VisibilityTrigger.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef } from 'react'; + +// Based on https://github.com/olistic/react-use-visibility#readme MIT License +function isElementNearViewport( + element: HTMLElement, + allowedDistanceToViewport = 0 +) { + const { + top, + right, + bottom, + left, + height, + width + } = element.getBoundingClientRect(); + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + const topCheck = top + height; + const leftCheck = left + width; + const bottomCheck = bottom - height; + const rightCheck = right - width; + + return ( + topCheck >= -allowedDistanceToViewport && + leftCheck >= -allowedDistanceToViewport && + bottomCheck <= windowHeight + allowedDistanceToViewport && + rightCheck <= windowWidth + allowedDistanceToViewport + ); +} + +/** Simple Cross Browser Visibility Trigger */ +export const useVisibilityTrigger = ( + elementRef: { current: undefined | null | HTMLElement }, + onVisible: () => void, + allowedDistanceToViewport = 0 +) => { + const hasTriggered = useRef(false); + + useEffect(() => { + const intervalId = setInterval(() => { + if (hasTriggered.current) { + return; + } + if (!elementRef.current) { + return; + } + + if ( + !isElementNearViewport(elementRef.current, allowedDistanceToViewport) + ) { + return; + } + + hasTriggered.current = true; + onVisible(); + }, 100); + return () => clearInterval(intervalId); + }, [allowedDistanceToViewport, elementRef, onVisible]); + + return { + reset: () => { + hasTriggered.current = false; + } + }; +}; + +export const VisibilityTrigger = ({ + onVisible, + allowedDistanceToViewport +}: { + onVisible: () => void; + allowedDistanceToViewport?: number; +}) => { + const divRef = useRef(null as null | HTMLDivElement); + useVisibilityTrigger(divRef, onVisible, allowedDistanceToViewport ?? 0); + + return
; +}; diff --git a/src/components/common/assets/link-icon.svg b/src/components/common/assets/link-icon.svg new file mode 100644 index 00000000..b0176187 --- /dev/null +++ b/src/components/common/assets/link-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/common/assets/tezos-sym.svg b/src/components/common/assets/tezos-sym.svg new file mode 100644 index 00000000..cb047e0e --- /dev/null +++ b/src/components/common/assets/tezos-sym.svg @@ -0,0 +1 @@ +tezos-logo-01 \ No newline at end of file diff --git a/src/components/common/assets/wallet.svg b/src/components/common/assets/wallet.svg new file mode 100644 index 00000000..f24717a6 --- /dev/null +++ b/src/components/common/assets/wallet.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/src/components/common/index.tsx b/src/components/common/index.tsx index 8553b3e1..37235999 100644 --- a/src/components/common/index.tsx +++ b/src/components/common/index.tsx @@ -37,9 +37,7 @@ export function MinterMenuButton( return ; } -export function MinterMenuItem( - props: MenuItemProps & { variant?: string } -) { +export function MinterMenuItem(props: MenuItemProps & { variant?: string }) { const { variant, ...rest } = props; const styles = useStyleConfig('MenuItem', { variant }); return ; diff --git a/src/components/common/modals/BuyToken.tsx b/src/components/common/modals/BuyToken.tsx index 4bd25aaf..7e83b5fd 100644 --- a/src/components/common/modals/BuyToken.tsx +++ b/src/components/common/modals/BuyToken.tsx @@ -12,8 +12,9 @@ import { import { MinterButton } from '../../common'; import { useDispatch } from '../../../reducer'; import { buyTokenAction } from '../../../reducer/async/actions'; -import { Nft } from '../../../lib/nfts/queries'; +import { Nft } from '../../../lib/nfts/decoders'; import FormModal, { BaseModalProps, BaseModalButtonProps } from './FormModal'; +import tz from '../assets/tezos-sym.svg' interface BuyTokenModalProps extends BaseModalProps { contract: string; @@ -50,7 +51,7 @@ export function BuyTokenModal(props: BuyTokenModalProps) { You are about to purchase {' '} - {props.token.title} (ꜩ {props.token.sale?.price}) + {props.token.title} ( {props.token.sale?.price}) diff --git a/src/components/common/modals/FormModal.tsx b/src/components/common/modals/FormModal.tsx index 334b6613..6c26dec5 100644 --- a/src/components/common/modals/FormModal.tsx +++ b/src/components/common/modals/FormModal.tsx @@ -30,6 +30,7 @@ interface ContentProps { pendingMessage?: React.ReactNode; pendingAsyncMessage?: React.ReactNode; completeMessage?: React.ReactNode; + errorMessage?: React.ReactNode; } function Content(props: ContentProps) { @@ -41,7 +42,7 @@ function Content(props: ContentProps) { - Error Creating Collection + {props.errorMessage || 'Operation failed'} onRetry()}> diff --git a/src/components/common/modals/SellToken.tsx b/src/components/common/modals/SellToken.tsx index b35f254b..6c9d7f2d 100644 --- a/src/components/common/modals/SellToken.tsx +++ b/src/components/common/modals/SellToken.tsx @@ -17,6 +17,7 @@ import { MinterButton } from '../../common'; import { useDispatch } from '../../../reducer'; import { listTokenAction } from '../../../reducer/async/actions'; import FormModal, { BaseModalProps, BaseModalButtonProps } from './FormModal'; +import tz from '../assets/tezos-sym.svg' interface SellTokenModalProps extends BaseModalProps { contract: string; @@ -60,7 +61,7 @@ export function SellTokenModal(props: SellTokenModalProps) { pointerEvents="none" color="gray.900" fontSize="1.2em" - children="ꜩ" + children={} /> (storage: S) => + t.type({ + type: t.string, + kind: t.string, + tzips: t.union([t.array(t.string), t.undefined]), + address: t.string, + balance: t.number, + creator: t.type({ + address: t.string + }), + numContracts: t.number, + numDelegations: t.number, + numOriginations: t.number, + numTransactions: t.number, + numReveals: t.number, + numMigrations: t.number, + firstActivity: t.number, + firstActivityTime: t.string, + lastActivity: t.number, + lastActivityTime: t.string, + storage: storage + }); + +// Generic BigMaps + +export const BigMapRow = (props: { + key: K; + value: V; +}) => + t.type({ + id: t.number, + active: t.boolean, + hash: t.string, + key: props.key, + value: props.value, + firstLevel: t.number, + lastLevel: t.number, + updates: t.number + }); + +export const BigMapUpdateRow = (content: { + key: K; + value: V; +}) => + t.type({ + id: t.number, + level: t.number, + timestamp: t.string, + bigmap: t.number, + contract: t.intersection([ + t.partial({ alias: t.string }), + t.type({ address: t.string }) + ]), + path: t.string, + action: t.string, + content: t.type({ hash: t.string, key: content.key, value: content.value }) + }); + +// FA2 BigMaps + +export type AssetMetadataBigMap = t.TypeOf; +export const AssetMetadataBigMap = t.array( + BigMapRow({ key: t.string, value: t.string }) +); + +export type LedgerBigMap = t.TypeOf; +export const LedgerBigMap = t.array( + BigMapRow({ key: t.string, value: t.string }) +); + +export type TokenMetadataBigMap = t.TypeOf; +export const TokenMetadataBigMap = t.array( + BigMapRow({ + key: t.string, + value: t.type({ + token_id: t.string, + token_info: t.type({ + '': t.string + }) + }) + }) +); + +// FixedPriceSale BigMaps + +export type FixedPriceSaleBigMap = t.TypeOf; +export const FixedPriceSaleBigMap = t.array( + BigMapRow({ + key: t.type({ + sale_token: t.type({ + token_for_sale_address: t.string, + token_for_sale_token_id: t.string + }), + sale_seller: t.string + }), + value: t.string + }) +); + +// NFT Metadata + +export type NftMetadataFormat = t.TypeOf; +export const NftMetadataFormat = t.partial({ + uri: t.string, + hash: t.string, + mimeType: t.string, + fileSize: t.number, + fileName: t.string, + duration: t.string, + dimensions: t.partial({ + value: t.string, + unit: t.string + }), + dataRate: t.partial({ + value: t.number, + unit: t.string + }) +}); + +export type NftMetadataAttribute = t.TypeOf; +export const NftMetadataAttribute = t.intersection([ + t.type({ name: t.string, value: t.string }), + t.partial({ type: t.string }) +]); + +export type NftMetadata = t.TypeOf; +export const NftMetadata = t.partial({ + '': t.string, + name: t.string, + minter: t.string, + symbol: t.string, + decimals: t.number, + rightUri: t.string, + artifactUri: t.string, + displayUri: t.string, + thumbnailUri: t.string, + externalUri: t.string, + description: t.string, + creators: t.array(t.string), + contributors: t.array(t.string), + publishers: t.array(t.string), + date: t.string, + blocklevel: t.number, + type: t.string, + tags: t.array(t.string), + genres: t.array(t.string), + language: t.string, + identifier: t.string, + rights: t.string, + isTransferable: t.boolean, + isBooleanAmount: t.boolean, + shouldPreferSymbol: t.boolean, + formats: t.array(NftMetadataFormat), + attributes: t.array(NftMetadataAttribute) +}); + +export type NftSale = t.TypeOf; +export const NftSale = t.type({ + id: t.number, + seller: t.string, + price: t.number, + mutez: t.number, + type: t.string +}); + +export type Nft = t.TypeOf; +export const Nft = t.intersection([ + t.type({ + id: t.number, + title: t.string, + owner: t.string, + description: t.string, + artifactUri: t.string, + metadata: NftMetadata + }), + t.partial({ + sale: NftSale, + address: t.string + }) +]); + +// Contract Metadata + +export const AssetContractMetadata = t.type({ + name: t.string +}); + +export type AssetContract = t.TypeOf; +export const AssetContract = t.intersection([ + ContractRow(t.unknown), + t.type({ + metadata: AssetContractMetadata + }) +]); diff --git a/src/lib/nfts/queries.ts b/src/lib/nfts/queries.ts index ca0cc035..a34ed203 100644 --- a/src/lib/nfts/queries.ts +++ b/src/lib/nfts/queries.ts @@ -1,10 +1,13 @@ +/* eslint-disable no-redeclare */ import { Buffer } from 'buffer'; -import Joi from 'joi'; +import * as t from 'io-ts'; +import _ from 'lodash'; import { SystemWithToolkit, SystemWithWallet } from '../system'; -import select from '../util/selectObjectByKeys'; -import { ipfsUriToCid } from '../util/ipfs'; -import { ContractAbstraction } from '@taquito/taquito'; -import { tzip12 } from '@taquito/tzip12'; +import { TzKt, Params } from '../service/tzkt'; +import { isLeft } from 'fp-ts/lib/Either'; +import { compact } from 'fp-ts/lib/Array'; +import { getRight } from 'fp-ts/lib/Option'; +import * as D from './decoders'; function fromHexString(input: string) { if (/^([A-Fa-f0-9]{2})*$/.test(input)) { @@ -13,272 +16,164 @@ function fromHexString(input: string) { return input; } -interface NftSale { - seller: string; - price: number; - mutez: number; - type: string; -} +//// Data retrieval and decoding functions -export interface Nft { - id: number; - title: string; - owner: string; - description: string; - artifactUri: string; - metadata: NftMetadata; - sale?: NftSale; - address?: string; +async function getAssetMetadataBigMap( + tzkt: TzKt, + address: string +): Promise { + const path = 'metadata'; + const data = await tzkt.getContractBigMapKeys(address, path); + const decoded = D.LedgerBigMap.decode(data); + if (isLeft(decoded)) { + throw Error('Failed to decode `getAssetMetadata` response'); + } + return decoded.right; } -const contractCache: Record = {}; - -export async function getMarketplaceNfts( - system: SystemWithToolkit | SystemWithWallet, +async function getLedgerBigMap( + tzkt: TzKt, address: string -): Promise { - const storage = await system.betterCallDev.getContractStorage(address); - const bigMapId = select(storage, { - type: 'big_map' - })?.value; - const tokenSales = await system.betterCallDev.getBigMapKeys(bigMapId); - const activeSales = tokenSales.filter((v: any) => { - return v.data.value; - }); - - return Promise.all( - activeSales.map( - async (tokenSale: any): Promise => { - const saleAddress = select(tokenSale, { name: 'token_for_sale_address' })?.value; - const tokenId = parseInt(select(tokenSale, { name: 'token_for_sale_token_id' })?.value, 10); - const sale = { - seller: select(tokenSale, { name: 'sale_seller' })?.value, - price: Number.parseInt(tokenSale.data.value?.value || 0, 10) / 1000000, - mutez: Number.parseInt(tokenSale.data.value?.value || 0, 10), - type: 'fixedPrice' - }; - - if (!(contractCache[saleAddress] instanceof ContractAbstraction)) { - contractCache[saleAddress] = await system.toolkit.contract.at(saleAddress, tzip12); - } +): Promise { + const path = 'assets.ledger'; + const data = await tzkt.getContractBigMapKeys(address, path); + const decoded = D.LedgerBigMap.decode(data); + if (isLeft(decoded)) { + throw Error('Failed to decode `getLedger` response'); + } + return decoded.right; +} - const metadata = await contractCache[saleAddress].tzip12().getTokenMetadata(tokenId); +async function getTokenMetadataBigMap( + tzkt: TzKt, + address: string +): Promise { + const path = 'assets.token_metadata'; + const data = await tzkt.getContractBigMapKeys(address, path); + const decoded = D.TokenMetadataBigMap.decode(data); + if (isLeft(decoded)) { + throw Error('Failed to decode `getTokenMetadata` response'); + } + return decoded.right; +} - return { - address: saleAddress, - id: tokenId, - title: metadata.name, - owner: sale.seller, - description: metadata.description, - artifactUri: metadata.artifactUri, - metadata: metadata, - sale: sale - }; - } - ) - ); +function transformFixedPriceSales(fixedPriceSales: any): t.Mixed[] { + fixedPriceSales.forEach((fixedPriceSale: any, i: number) => { + if (fixedPriceSale.key.hasOwnProperty('seller')) { + fixedPriceSales[i].key['sale_seller'] = fixedPriceSale.key.seller; + delete fixedPriceSales[i].key.seller; + } + }); + return fixedPriceSales; } -export class NftMetadata { - [index: string]: - | string - | undefined - | number - | Array - | boolean; - ''?: string; - name?: string; - minter?: string; - symbol?: string; - decimals?: number; - rightUri?: string; - artifactUri?: string; - displayUri?: string; - thumbnailUri?: string; - externalUri?: string; - description?: string; - creators?: Array; - contributors?: Array; - publishers?: Array; - date?: string; - blocklevel?: number; - type?: string; - tags?: Array; - genres?: Array; - language?: string; - identifier?: string; - rights?: string; - isTransferable?: boolean; - isBooleanAmount?: boolean; - shouldPreferSymbol?: boolean; - formats?: Array; - attributes?: Array; - - constructor( - root?: string, - name?: string, - minter?: string, - symbol?: string, - decimals?: number, - rightUri?: string, - artifactUri?: string, - displayUri?: string, - thumbnailUri?: string, - externalUri?: string, - description?: string, - creators?: Array, - contributors?: Array, - publishers?: Array, - date?: string, - blocklevel?: number, - type?: string, - tags?: Array, - genres?: Array, - language?: string, - identifier?: string, - rights?: string, - isTransferable?: boolean, - isBooleanAmount?: boolean, - shouldPreferSymbol?: boolean, - formats?: Array, - attributes?: Array - ) { - this[''] = root; - this.name = name; - this.minter = minter; - this.symbol = symbol; - this.decimals = decimals; - this.rightUri = rightUri; - this.artifactUri = artifactUri; - this.displayUri = displayUri; - this.thumbnailUri = thumbnailUri; - this.externalUri = externalUri; - this.description = description; - this.creators = creators; - this.contributors = contributors; - this.publishers = publishers; - this.date = date; - this.blocklevel = blocklevel; - this.type = type; - this.tags = tags; - this.genres = genres; - this.language = language; - this.identifier = identifier; - this.rights = rights; - this.isTransferable = isTransferable; - this.isBooleanAmount = isBooleanAmount; - this.shouldPreferSymbol = shouldPreferSymbol; - this.formats = formats; - this.attributes = attributes; +async function getFixedPriceSalesBigMap( + tzkt: TzKt, + address: string +): Promise { + let fixedPriceBigMapId; + const fixedPriceStorage = await tzkt.getContractStorage(address); + if (fixedPriceStorage.hasOwnProperty('sales')) { + fixedPriceBigMapId = fixedPriceStorage.sales; + } else { + fixedPriceBigMapId = fixedPriceStorage; // legacy marketplace contract + } + if (isLeft(t.number.decode(fixedPriceBigMapId))) { + throw Error('Failed to decode `getFixedPriceSales` bigMap ID'); + } + const fixedPriceSales = transformFixedPriceSales(await tzkt.getBigMapKeys(fixedPriceBigMapId)); + const decoded = D.FixedPriceSaleBigMap.decode(fixedPriceSales); + if (isLeft(decoded)) { + throw Error('Failed to decode `getFixedPriceSales` response'); } + return decoded.right; } -export interface NftMetadataFormat { - uri?: string; - hash?: string; - mimeType: string; - fileSize?: number; - fileName?: string; - duration?: string; - dimensions?: NtfMetadataFormatDimensions; - dataRate?: NtfMetadataFormatDataRate; +async function getBigMapUpdates( + tzkt: TzKt, + params: Params, + content: { key: K; value: V } +) { + const bigMapUpdates = await tzkt.getBigMapUpdates(params); + const decoder = t.array(D.BigMapUpdateRow(content)); + const decoded = decoder.decode(bigMapUpdates); + if (isLeft(decoded)) { + throw Error('Failed to decode `getBigMapUpdates` response'); + } + return decoded.right; } -export interface NtfMetadataFormatDataRate { - value: number; - unit: string; -} -export interface NtfMetadataFormatDimensions { - value: string; - unit: string; +async function getContracts( + tzkt: TzKt, + params: Params, + storage: S +) { + const contracts = await tzkt.getContracts(params); + const contractsArray = t.array(t.unknown).decode(contracts); + if (isLeft(contractsArray)) { + throw Error('Failed to decode `getContracts` response'); + } + const decodedArray = contractsArray.right.map(D.ContractRow(storage).decode); + return compact(decodedArray.map(getRight)); } -export interface NftMetadataAttribute { - name: string | null; - value: string | null; - type?: string; +async function getContract( + tzkt: TzKt, + address: string, + params: Params, + storage: S +) { + const contract = await tzkt.getContract(address, params); + const decoded = D.ContractRow(storage).decode(contract); + if (isLeft(decoded)) { + throw Error('Failed to decode `getContracts` response'); + } + return decoded.right; } +//// Main query functions + export async function getContractNfts( system: SystemWithToolkit | SystemWithWallet, address: string -): Promise { - const storage = await system.betterCallDev.getContractStorage(address); - - const ledgerBigMapId = select(storage, { - type: 'big_map', - name: 'ledger' - })?.value; - - if (ledgerBigMapId === undefined || ledgerBigMapId === null) return []; - - const tokensBigMapId = select(storage, { - type: 'big_map', - name: 'token_metadata' - })?.value; - - if (tokensBigMapId === undefined || ledgerBigMapId === null) return []; - - const ledger = await system.betterCallDev.getBigMapKeys(ledgerBigMapId); - - if (!ledger) return []; - - const tokens = await system.betterCallDev.getBigMapKeys(tokensBigMapId); - - if (!tokens) return []; - - // get tokens listed for sale - const fixedPriceStorage = await system.betterCallDev.getContractStorage( - system.config.contracts.marketplace.fixedPrice.tez - ); - const fixedPriceBigMapId = select(fixedPriceStorage, { - type: 'big_map' - })?.value; - const fixedPriceSales = await system.betterCallDev.getBigMapKeys( - fixedPriceBigMapId - ); +): Promise { + const ledger = await getLedgerBigMap(system.tzkt, address); + const tokens = await getTokenMetadataBigMap(system.tzkt, address); + const mktAddress = system.config.contracts.marketplace.fixedPrice.tez; + const tokenSales = await getFixedPriceSalesBigMap(system.tzkt, mktAddress); + const activeSales = tokenSales.filter(sale => sale.active); return Promise.all( tokens.map( - async (token: any): Promise => { - const tokenId = select(token, { name: 'token_id' })?.value; - const metadataMap = select(token, { name: 'token_info' })?.children; - let metadata = metadataMap.reduce((acc: any, next: any) => { - return { ...acc, [next.name]: fromHexString(next.value) }; - }, {}); - - if (ipfsUriToCid(metadata['""'])) { - const resolvedMetadata = await system.resolveMetadata(metadata['""']); - metadata = { ...metadata, ...resolvedMetadata.metadata }; - } else if (ipfsUriToCid(metadata[''])) { - const resolvedMetadata = await system.resolveMetadata(metadata['']); - metadata = { ...metadata, ...resolvedMetadata.metadata }; - } - - 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' - }; - } + async (token): Promise => { + const { token_id: tokenId, token_info: tokenInfo } = token.value; + + // TODO: Write decoder function for data retrieval + const decodedInfo = _.mapValues(tokenInfo, fromHexString) as any; + const resolvedInfo = await system.resolveMetadata( + decodedInfo[''], + address + ); + const metadata = { ...decodedInfo, ...resolvedInfo.metadata }; + + const saleData = activeSales.find( + v => + v.key.sale_token.token_for_sale_address === address && + v.key.sale_token.token_for_sale_token_id === tokenId + ); + + const sale = saleData && { + id: saleData.id, + seller: saleData.key.sale_seller, + price: Number.parseInt(saleData.value, 10) / 1000000, + mutez: Number.parseInt(saleData.value, 10), + type: 'fixedPrice' + }; return { id: parseInt(tokenId, 10), + owner: ledger.find(e => e.key === tokenId)?.value!, title: metadata.name, - owner, description: metadata.description, artifactUri: metadata.artifactUri, metadata: metadata, @@ -289,57 +184,84 @@ export async function getContractNfts( ); } -export interface AssetContract { - address: string; - metadata: Record; -} - -const metadataSchema = Joi.object({ - name: Joi.string().required().disallow(null) -}); - export async function getNftAssetContract( system: SystemWithToolkit | SystemWithWallet, address: string -): Promise { - const bcd = system.betterCallDev; - const storage = await bcd.getContractStorage(address); - - const metadataBigMapId = select(storage, { - type: 'big_map', - name: 'metadata' - })?.value; +): Promise { + const contract = await getContract(system.tzkt, address, {}, t.unknown); + const metaBigMap = await getAssetMetadataBigMap(system.tzkt, address); + const metaUri = metaBigMap.find(v => v.key === '')?.value; + if (!metaUri) { + throw Error(`Could not extract metadata URI from ${address} storage`); + } - const metaBigMap = await system.betterCallDev.getBigMapKeys(metadataBigMapId); - const metaUri = select(metaBigMap, { key_string: '' })?.value.value; - const { metadata } = await system.resolveMetadata(fromHexString(metaUri)); + const { metadata } = await system.resolveMetadata( + fromHexString(metaUri), + address + ); + const decoded = D.AssetContractMetadata.decode(metadata); - const { error } = metadataSchema.validate(metadata, { allowUnknown: true }); - if (error) { + if (isLeft(decoded)) { throw Error('Metadata validation failed'); } - return { address, metadata }; + return { ...contract, metadata: decoded.right }; } -export async function getWalletNftAssetContracts(system: SystemWithWallet) { - const bcd = system.betterCallDev; - const response = await bcd.getWalletContracts(system.tzPublicKey); - const assetContracts = response.items.filter( - (i: any) => - Object.keys(i.body).includes('tags') && - i.body.tags.includes('fa2') && - Object.keys(i.body).includes('entrypoints') && - i.body.entrypoints.includes('balance_of') && - i.body.entrypoints.includes('mint') && - i.body.entrypoints.includes('transfer') && - i.body.entrypoints.includes('update_operators') +export async function getWalletNftAssetContracts( + system: SystemWithWallet +): Promise { + const contracts = await getContracts( + system.tzkt, + { + creator: system.tzPublicKey, + includeStorage: 'true' + }, + t.unknown + ); + + const addresses = _.uniq( + contracts + .filter(c => c.kind === 'asset' && c.tzips?.includes('fa2')) + .map(c => c.address) ); - const results = []; - for (let assetContract of assetContracts) { + const results: D.AssetContract[] = []; + + if (addresses.length === 0) { + return results; + } + + const assetBigMapRows = ( + await getBigMapUpdates( + system.tzkt, + { + path: 'metadata', + action: 'add_key', + 'contract.in': addresses.join(','), + limit: '10000' + }, + { + key: t.string, + value: t.string + } + ) + ).filter(v => v.content.key === ''); + + for (const row of assetBigMapRows) { + const contract = contracts.find(c => c.address === row.contract.address); + if (!contract) { + continue; + } try { - const result = await getNftAssetContract(system, assetContract.value); - results.push(result); + const metaUri = row.content.value; + const { metadata } = await system.resolveMetadata( + fromHexString(metaUri), + contract.address + ); + const decoded = D.AssetContractMetadata.decode(metadata); + if (!isLeft(decoded)) { + results.push({ ...contract, metadata: decoded.right }); + } } catch (e) { console.log(e); } @@ -347,3 +269,124 @@ export async function getWalletNftAssetContracts(system: SystemWithWallet) { return results; } + +export type MarketplaceNftLoadingData = { + loaded: boolean; + error?: string; + token: null | D.Nft; + tokenSale: D.FixedPriceSaleBigMap[number]; + tokenMetadata: undefined | string; +}; + +export async function getMarketplaceNfts( + system: SystemWithToolkit | SystemWithWallet, + address: string +): Promise { + const tokenSales = await getFixedPriceSalesBigMap(system.tzkt, address); + const activeSales = tokenSales.filter(v => v.active); + const addresses = _.uniq( + activeSales.map(s => s.key.sale_token.token_for_sale_address) + ); + + const uniqueAddresses = Array.from(new Set(addresses)); + + if (uniqueAddresses.length === 0) { + return []; + } + + const tokenBigMapRows = await getBigMapUpdates( + system.tzkt, + { + path: 'assets.token_metadata', + action: 'add_key', + 'contract.in': addresses.join(','), + limit: '10000' + }, + { + key: t.string, + value: t.type({ + token_id: t.string, + token_info: t.record(t.string, t.string) + }) + } + ); + + // Sort descending (newest first) + const salesToView = [...activeSales].reverse(); + const salesWithTokenMetadata = salesToView + .map(x => ({ + tokenSale: x, + tokenItem: tokenBigMapRows.find( + item => + x.key.sale_token.token_for_sale_address === item.contract.address && + x.key.sale_token.token_for_sale_token_id === + item.content.value.token_id + '' + ) + })) + .map(x => ({ + loaded: false, + token: null, + tokenSale: x.tokenSale, + tokenMetadata: x.tokenItem?.content?.value?.token_info[''] + })); + + return salesWithTokenMetadata; +} + +export const loadMarketplaceNft = async ( + system: SystemWithToolkit | SystemWithWallet, + tokenLoadData: MarketplaceNftLoadingData +): Promise => { + const { token, loaded, tokenSale, tokenMetadata } = tokenLoadData; + const result = { ...tokenLoadData }; + + if (token || loaded) { + return result; + } + result.loaded = true; + + try { + const { + token_for_sale_address: saleAddress, + token_for_sale_token_id: tokenIdStr + } = tokenSale.key.sale_token; + + const tokenId = parseInt(tokenIdStr, 10); + const mutez = Number.parseInt(tokenSale.value, 10); + const sale = { + id: tokenSale.id, + seller: tokenSale.key.sale_seller, + price: mutez / 1000000, + mutez: mutez, + type: 'fixedPrice' + }; + + if (!tokenMetadata) { + result.error = "Couldn't retrieve tokenMetadata"; + console.error("Couldn't retrieve tokenMetadata", { tokenSale }); + return result; + } + + const { metadata } = (await system.resolveMetadata( + fromHexString(tokenMetadata), + saleAddress + )) as any; + + result.token = { + address: saleAddress, + id: tokenId, + title: metadata.name || '', + owner: sale.seller, + description: metadata.description || '', + artifactUri: metadata.artifactUri || '', + metadata: metadata, + sale: sale + }; + + return result; + } catch (err) { + result.error = "Couldn't load token"; + console.error("Couldn't load token", { tokenSale, err }); + return result; + } +}; diff --git a/src/lib/service/bcd.ts b/src/lib/service/bcd.ts index 4c4c0dd0..2fa0c1b9 100644 --- a/src/lib/service/bcd.ts +++ b/src/lib/service/bcd.ts @@ -1,11 +1,8 @@ import axios, { AxiosResponse } from 'axios'; import { Config } from '../system'; -export async function getBigMapKeys( - config: Config, - id: number -) { - let response : AxiosResponse; +export async function getBigMapKeys(config: Config, id: number) { + let response: AxiosResponse; let result = []; let offset = 0; const uri = `${config.bcd.api}/v1/bigmap/${config.network}/${id}/keys`; @@ -33,29 +30,12 @@ export async function getContractStorage(config: Config, address: string) { return response.data; } -export async function getContractOperations( - config: Config, - address: string, - since?: Date -) { - const from = since ? `?from=${since.getTime()}` : ''; - const uri = `${config.bcd.api}/v1/contract/${config.network}/${address}/operations${from}`; - const response = await axios.get(uri); - return response.data; -} - export async function getWalletContracts(config: Config, address: string) { const uri = `${config.bcd.api}/v1/search?q=${address}&i=contract&n=${config.network}&g=0&s=0`; const response = await axios.get(uri); return response.data; } -export async function getAccountMetadata(config: Config, address: string) { - const uri = `${config.bcd.api}/v1/account/${config.network}/${address}/metadata`; - const response = await axios.get(uri); - return response.data; -} - export class BetterCallDev { config: Config; @@ -75,15 +55,7 @@ export class BetterCallDev { return getContractStorage(this.config, address); } - getContractOperations(address: string, since?: Date) { - return getContractOperations(this.config, address, since); - } - getWalletContracts(address: string) { return getWalletContracts(this.config, address); } - - getAccountMetadata(address: string) { - return getAccountMetadata(this.config, address); - } } diff --git a/src/lib/service/tzkt.ts b/src/lib/service/tzkt.ts new file mode 100644 index 00000000..55e1f699 --- /dev/null +++ b/src/lib/service/tzkt.ts @@ -0,0 +1,102 @@ +import axios from 'axios'; +import { Config } from '../system'; + +export type Params = Record; + +function mkQueryParams(params: Params | undefined) { + const finalParams = { limit: '10000', ...params }; + return new URLSearchParams(finalParams).toString(); +} + +export async function getBigMapKeys( + config: Config, + id: number, + params?: Params +) { + const uri = `${config.tzkt.api}/v1/bigmaps/${id}/keys?${mkQueryParams( + params + )}`; + const response = await axios.get(uri); + return response.data; +} + +export async function getBigMapUpdates(config: Config, params?: Params) { + const uri = `${config.tzkt.api}/v1/bigmaps/updates?${mkQueryParams(params)}`; + const response = await axios.get(uri); + return response.data; +} + +export async function getContracts(config: Config, params?: Params) { + const uri = `${config.tzkt.api}/v1/contracts?${mkQueryParams(params)}`; + const response = await axios.get(uri); + return response.data; +} + +export async function getContract( + config: Config, + address: string, + params?: Params +) { + const uri = `${config.tzkt.api}/v1/contracts/${address}?${mkQueryParams( + params + )}`; + const response = await axios.get(uri); + return response.data; +} + +export async function getContractBigMapKeys( + config: Config, + address: string, + name: string, + params?: Params +) { + const uri = `${ + config.tzkt.api + }/v1/contracts/${address}/bigmaps/${name}/keys?${mkQueryParams(params)}`; + const response = await axios.get(uri); + return response.data; +} + +export async function getContractStorage( + config: Config, + address: string, + params?: Params +) { + const uri = `${ + config.tzkt.api + }/v1/contracts/${address}/storage?${mkQueryParams(params)}`; + const response = await axios.get(uri); + return response.data; +} + +export class TzKt { + config: Config; + + constructor(config: Config) { + this.config = config; + } + + getBigMapKeys(id: number, params?: Params) { + return getBigMapKeys(this.config, id, params); + } + + getBigMapUpdates(params?: Params) { + return getBigMapUpdates(this.config, params); + } + + getContracts(params?: Params) { + return getContracts(this.config, params); + } + + getContract(address: string, params?: Params) { + return getContract(this.config, address, params); + } + + getContractBigMapKeys(address: string, name: string, params?: Params) { + return getContractBigMapKeys(this.config, address, name, params); + } + + getContractStorage(address: string, params?: Params) { + return getContractStorage(this.config, address, params); + } +} diff --git a/src/lib/system.ts b/src/lib/system.ts index 1f4c91e2..6488ff65 100644 --- a/src/lib/system.ts +++ b/src/lib/system.ts @@ -1,4 +1,10 @@ -import { TezosToolkit, MichelCodecPacker, Context } from '@taquito/taquito'; +import { + TezosToolkit, + MichelCodecPacker, + Context, + ContractAbstraction, + ContractProvider +} from '@taquito/taquito'; import { BeaconWallet } from '@taquito/beacon-wallet'; import { MetadataProvider, DEFAULT_HANDLERS } from '@taquito/tzip16'; import { Tzip12Module } from '@taquito/tzip12'; @@ -6,6 +12,8 @@ import CustomIpfsHttpHandler from './util/taquito-custom-ipfs-http-handler'; import { BetterCallDev } from './service/bcd'; import * as tzUtils from './util/tezosToolkit'; import { DAppClientOptions, NetworkType } from '@airgap/beacon-sdk'; +import { TzKt } from './service/tzkt'; +import { isIpfsUri } from './util/ipfs'; export interface Config { rpc: string; @@ -14,6 +22,9 @@ export interface Config { api: string; gui: string; }; + tzkt: { + api: string; + }; contracts: { nftFaucet: string; marketplace: { @@ -23,6 +34,7 @@ export interface Config { }; }; ipfsApi: string; + ipfsGateway: string; } export enum Status { @@ -35,6 +47,7 @@ export interface SystemConfigured { status: Status.Configured; config: Config; betterCallDev: BetterCallDev; + tzkt: TzKt; toolkit: null; wallet: null; walletReconnectAttempted: boolean; @@ -42,13 +55,15 @@ export interface SystemConfigured { } type ResolveMetadata = ( - uri: string + uri: string, + address: string ) => ReturnType; export interface SystemWithToolkit { status: Status.ToolkitConnected; config: Config; betterCallDev: BetterCallDev; + tzkt: TzKt; toolkit: TezosToolkit; resolveMetadata: ResolveMetadata; wallet: null; @@ -60,6 +75,7 @@ export interface SystemWithWallet { status: Status.WalletConnected; config: Config; betterCallDev: BetterCallDev; + tzkt: TzKt; toolkit: TezosToolkit; resolveMetadata: ResolveMetadata; wallet: BeaconWallet; @@ -82,6 +98,7 @@ export function configure(config: Config): SystemConfigured { status: Status.Configured, config: compatibilityConfig, betterCallDev: new BetterCallDev(compatibilityConfig), + tzkt: new TzKt(compatibilityConfig), toolkit: null, wallet: null, walletReconnectAttempted: false, @@ -94,28 +111,28 @@ function createMetadataResolver( toolkit: TezosToolkit, contractAddress: string ): ResolveMetadata { - const ipfsGateway = - system.config.network === 'sandboxnet' - ? 'localhost:8080' - : 'gateway.pinata.cloud'; - const gatewayProtocol = - system.config.network === 'sandboxnet' ? 'http' : 'https'; + const ipfsUrl = system.config.ipfsGateway; + const ipfsGateway = ipfsUrl.replace(/^https?:\/\//, ''); + const gatewayProtocol = ipfsUrl.startsWith('https') ? 'https' : 'http'; + const ipfsHandler = new CustomIpfsHttpHandler(ipfsGateway, gatewayProtocol); DEFAULT_HANDLERS.set('ipfs', ipfsHandler); const provider = new MetadataProvider(DEFAULT_HANDLERS); const context = new Context(toolkit.rpc); - // This is a performance optimization: We're only resolving off-chain - // metadata, however the storage handler requires a ContractAbstraction - // instance present - if we fetch a contract on each invokation, the time - // to resolution can take several hundred milliseconds. - // - // TODO: Is it possible to only fetch contracts at the storage resolver level - // and make an "off-chain" metadata resolver that excludes the need for a - // ContractAbstraction instance? + const defaultContract = toolkit.contract.at(contractAddress); - return async uri => { - const contract = await defaultContract; - return provider.provideMetadata(contract, uri, context); + type Contract = ContractAbstraction; + const contractCache: Record = {}; + + return async (uri, address) => { + if (isIpfsUri(uri)) { + const contract = await defaultContract; + return provider.provideMetadata(contract, uri, context); + } + if (!contractCache[address]) { + contractCache[address] = await toolkit.contract.at(address); + } + return provider.provideMetadata(contractCache[address], uri, context); }; } diff --git a/src/lib/util/ipfs.ts b/src/lib/util/ipfs.ts index c065b476..b5ff1311 100644 --- a/src/lib/util/ipfs.ts +++ b/src/lib/util/ipfs.ts @@ -33,6 +33,10 @@ export async function uploadIPFSImageWithThumbnail(api: string, file: File) { // URI Utils +export function isIpfsUri(uri: string) { + return /^ipfs:\/\/.+/.test(uri); +} + export function ipfsUriToCid(uri: string) { const baseRegex = /^ipfs:\/\//; const ipfsRegex = new RegExp(baseRegex.source + '.+'); @@ -42,13 +46,10 @@ export function ipfsUriToCid(uri: string) { return null; } -export function ipfsUriToGatewayUrl(network: string, uri: string) { - const ipfsHost = - network === 'sandboxnet' - ? 'http://localhost:8080' - : 'https://gateway.pinata.cloud'; +export type IpfsGatewayConfig = { ipfsGateway: string }; +export function ipfsUriToGatewayUrl(config: IpfsGatewayConfig, uri: string) { const cid = ipfsUriToCid(uri); - return cid ? `${ipfsHost}/ipfs/${cid}` : uri; + return cid ? `${config.ipfsGateway}/ipfs/${cid}` : uri; } export function uriToCid(uri: string) { diff --git a/src/lib/util/tezosToolkit.ts b/src/lib/util/tezosToolkit.ts index 9bdbb08a..87d6c2d5 100644 --- a/src/lib/util/tezosToolkit.ts +++ b/src/lib/util/tezosToolkit.ts @@ -1,11 +1,14 @@ -import { TezosToolkit } from "@taquito/taquito"; +import { TezosToolkit } from '@taquito/taquito'; -export const setConfirmationPollingInterval = async (tzToolkit: TezosToolkit) => { +export const setConfirmationPollingInterval = async ( + tzToolkit: TezosToolkit +) => { const constants = await tzToolkit.rpc.getConstants(); - + // Polling interval has to be smaller than the time between block // or TezosToolkit throws an exception. Here we pick 1/5 of the time // between blocks. - const confirmationPollingIntervalSecond = Number(constants.time_between_blocks[0]) / 5; - tzToolkit.setProvider({ config: { confirmationPollingIntervalSecond } }); -} \ No newline at end of file + const confirmationPollingIntervalSecond = + Number(constants.time_between_blocks[0]) / 5; + tzToolkit.setProvider({ config: { confirmationPollingIntervalSecond } }); +}; diff --git a/src/reducer/async/actions.ts b/src/reducer/async/actions.ts index db381f30..abb6382a 100644 --- a/src/reducer/async/actions.ts +++ b/src/reducer/async/actions.ts @@ -18,7 +18,7 @@ import { } from '../../lib/util/ipfs'; import { SelectedFile } from '../slices/createNft'; import { connectWallet } from './wallet'; -import { NftMetadata } from '../../lib/nfts/queries'; +import { NftMetadata } from '../../lib/nfts/decoders'; import { SystemWithToolkit, SystemWithWallet } from '../../lib/system'; import { notifyPending, notifyFulfilled } from '../slices/notificationsActions'; @@ -34,8 +34,18 @@ export const readFileAsDataUrlAction = createAsyncThunk< >('action/readFileAsDataUrl', async ({ ns, file }, { rejectWithValue }) => { const readFile = new Promise<{ ns: string; result: SelectedFile }>( (resolve, reject) => { - const { name, type, size } = file; + let { name, type, size } = file; const reader = new FileReader(); + + if (!type) { + if (name.substr(-4) === '.glb') { + type = 'model/gltf-binary'; + } + if (name.substr(-5) === '.gltf') { + type = 'model/gltf+json'; + } + } + reader.onload = e => { const buffer = e.target?.result; if (!buffer || !(buffer instanceof ArrayBuffer)) { @@ -98,28 +108,23 @@ function appendStateMetadata( metadata: NftMetadata, system: SystemWithToolkit | SystemWithWallet ) { - const appendedMetadata = { ...metadata }; - appendedMetadata.name = state.fields.name as string; + const appendedMetadata: NftMetadata = { + ...metadata, + name: state.fields.name as string, + minter: system.tzPublicKey || undefined, + description: state.fields.description || undefined, + attributes: [] + }; - if (state.fields.description) { - appendedMetadata.description = state.fields.description; - } - - for (let row of state.attributes) { - if (row.name !== null && row.value !== null) { - const keys = Object.getOwnPropertyNames(new NftMetadata()); - if (keys.indexOf(row.name) !== -1) { - appendedMetadata[row.name as keyof NftMetadata] = row.value; - } else { - if (!appendedMetadata.attributes) appendedMetadata.attributes = []; - appendedMetadata.attributes.push({ name: row.name, value: row.value }); - } + return state.attributes.reduce((acc, row) => { + const keys = Object.keys(NftMetadata.props); + const key = keys.find(k => k === row.name) as keyof NftMetadata; + if (key && NftMetadata.props[key].decode(row.value)._tag === 'Right') { + return { ...acc, [key]: row.value }; } - } - - appendedMetadata.minter = system.tzPublicKey || ''; - - return appendedMetadata; + const attribute = { name: row.name, value: row.value }; + return { ...acc, attributes: [...acc.attributes!, attribute] }; + }, appendedMetadata); } export const mintTokenAction = createAsyncThunk< @@ -205,8 +210,8 @@ export const mintTokenAction = createAsyncThunk< ipfsMetadata.thumbnailUri = imageResponse.data.thumbnail.ipfsUri; ipfsMetadata.formats = [ { - fileSize: imageResponse.headers['content-length'], - mimeType: imageResponse.headers['content-type'] + fileSize: fileResponse.headers['content-length'], + mimeType: fileResponse.headers['content-type'] } ]; } else { @@ -214,8 +219,8 @@ export const mintTokenAction = createAsyncThunk< ipfsMetadata.artifactUri = fileResponse.data.ipfsUri; ipfsMetadata.formats = [ { - fileSize: fileResponse.headers['content-length'], - mimeType: fileResponse.headers['content-type'] + fileSize: fileResponse.data.size, + mimeType: file.type } ]; } diff --git a/src/reducer/async/queries.ts b/src/reducer/async/queries.ts index 232183e0..97e02b53 100644 --- a/src/reducer/async/queries.ts +++ b/src/reducer/async/queries.ts @@ -2,12 +2,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { State } from '../index'; import { getNftAssetContract, - AssetContract, getContractNfts, getMarketplaceNfts, - Nft, - getWalletNftAssetContracts + getWalletNftAssetContracts, + MarketplaceNftLoadingData, + loadMarketplaceNft } from '../../lib/nfts/queries'; +import { Nft, AssetContract } from '../../lib/nfts/decoders'; import { ErrorKind, RejectValue } from './errors'; type Opts = { state: State; rejectValue: RejectValue }; @@ -34,14 +35,16 @@ export const getContractNftsQuery = createAsyncThunk< string, Opts >('query/getContractNfts', async (address, { getState, rejectWithValue }) => { - const { system } = getState(); + const { system, collections } = getState(); try { const tokens = await getContractNfts(system, address); return { address, tokens }; } catch (e) { return rejectWithValue({ kind: ErrorKind.GetContractNftsFailed, - message: `Failed to retrieve contract nfts from: ${address}` + message: `Failed to retrieve contract nfts from: ${ + collections.collections[address]?.metadata?.name ?? address + }` }); } }); @@ -64,6 +67,7 @@ export const getWalletAssetContractsQuery = createAsyncThunk< try { return await getWalletNftAssetContracts(system); } catch (e) { + console.log(e); return rejectWithValue({ kind: ErrorKind.GetWalletNftAssetContractsFailed, message: "Failed to retrieve wallet's asset contracts" @@ -73,18 +77,59 @@ export const getWalletAssetContractsQuery = createAsyncThunk< ); export const getMarketplaceNftsQuery = createAsyncThunk< - { tokens: Nft[] }, + { tokens: MarketplaceNftLoadingData[] }, string, Opts ->('query/getMarketplaceNfts', async (address, { getState, rejectWithValue }) => { - const { system } = getState(); - try { - const tokens = await getMarketplaceNfts(system, address); - return { tokens }; - } catch (e) { - return rejectWithValue({ - kind: ErrorKind.GetMarketplaceNftsFailed, - message: `Failed to retrieve marketplace nfts from: ${address}` - }); +>( + 'query/getMarketplaceNfts', + async (address, { getState, rejectWithValue }) => { + const { system } = getState(); + try { + const tokens = await getMarketplaceNfts(system, address); + + // Load 9 initially (1-feature + at least 2 rows) + for (const i in tokens.slice(0, 9)) { + tokens[i] = await loadMarketplaceNft(system, tokens[i]); + } + + return { tokens }; + } catch (e) { + return rejectWithValue({ + kind: ErrorKind.GetMarketplaceNftsFailed, + message: `Failed to retrieve marketplace nfts from: ${address}` + }); + } } -}); +); + +export const loadMoreMarketplaceNftsQuery = createAsyncThunk< + { tokens: MarketplaceNftLoadingData[] }, + {}, + Opts +>( + 'query/loadMoreMarketplaceNftsQuery', + async (_, { getState, rejectWithValue }) => { + const { system, marketplace } = getState(); + try { + const tokens = marketplace.marketplace.tokens ?? []; + + // Load 8 more (at least 2 rows) + const iStart = tokens.findIndex(x => !x.loaded); + const iEnd = iStart + 8; + + // Need to rebuild the array + const tokensAfter = await Promise.all( + tokens.map(async (x, i) => + i >= iStart && i < iEnd ? await loadMarketplaceNft(system, x) : x + ) + ); + + return { tokens: tokensAfter }; + } catch (e) { + return rejectWithValue({ + kind: ErrorKind.GetMarketplaceNftsFailed, + message: `Failed to load marketplace nfts` + }); + } + } +); diff --git a/src/reducer/slices/collections.ts b/src/reducer/slices/collections.ts index 0559d197..111a38e1 100644 --- a/src/reducer/slices/collections.ts +++ b/src/reducer/slices/collections.ts @@ -4,7 +4,7 @@ import { getNftAssetContractQuery, getWalletAssetContractsQuery } from '../async/queries'; -import { Nft, AssetContract } from '../../lib/nfts/queries'; +import { Nft, AssetContract } from '../../lib/nfts/decoders'; import config from '../../config.json'; //// State @@ -28,21 +28,10 @@ type Reducer = CaseReducer>; // Data -const globalCollectionAddress = config.contracts.nftFaucet; - export const initialState: CollectionsState = { selectedCollection: null, - globalCollection: globalCollectionAddress, - collections: { - [globalCollectionAddress]: { - address: globalCollectionAddress, - metadata: { - name: 'Minter' - }, - tokens: null, - loaded: false - } - } + globalCollection: config.contracts.nftFaucet, + collections: {} }; //// Reducers & Slice @@ -59,14 +48,22 @@ const populateCollectionR: PopulateCollection = (state, { payload }) => { const updateCollectionsR: Reducer = (state, action) => { for (let coll of action.payload) { if (!state.collections[coll.address]) { - state.collections[coll.address] = { ...coll, tokens: null, loaded: false }; + state.collections[coll.address] = { + ...coll, + tokens: null, + loaded: false + }; } } }; const updateCollectionR: Reducer = (state, { payload }) => { if (!state.collections[payload.address]) { - state.collections[payload.address] = { ...payload, tokens: null, loaded: false }; + state.collections[payload.address] = { + ...payload, + tokens: null, + loaded: false + }; } }; diff --git a/src/reducer/slices/createNft.ts b/src/reducer/slices/createNft.ts index da318edf..ba3ac6fe 100644 --- a/src/reducer/slices/createNft.ts +++ b/src/reducer/slices/createNft.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { NftMetadataAttribute } from '../../lib/nfts/queries'; +import { NftMetadataAttribute } from '../../lib/nfts/decoders'; import { readFileAsDataUrlAction } from '../async/actions'; // State @@ -96,7 +96,7 @@ const slice = createSlice({ state.displayImageFile = null; }, addMetadataRow(state) { - state.attributes.push({ name: null, value: null }); + state.attributes.push({ name: '', value: '' }); }, updateMetadataRowName(state, action: UpdateRowNameAction) { if (state.attributes[action.payload.key]) { diff --git a/src/reducer/slices/marketplace.ts b/src/reducer/slices/marketplace.ts index 4d557e3d..880fbb97 100644 --- a/src/reducer/slices/marketplace.ts +++ b/src/reducer/slices/marketplace.ts @@ -1,6 +1,10 @@ import { createSlice, PayloadAction, CaseReducer } from '@reduxjs/toolkit'; -import { getMarketplaceNftsQuery } from '../async/queries'; -import { Nft } from '../../lib/nfts/queries'; +import { + getMarketplaceNftsQuery, + loadMoreMarketplaceNftsQuery +} from '../async/queries'; +import { Nft } from '../../lib/nfts/decoders'; +import { MarketplaceNftLoadingData } from '../../lib/nfts/queries'; import config from '../../config.json'; //// State @@ -11,7 +15,7 @@ export type Token = Nft; export interface Marketplace { address: string; - tokens: Token[] | null; + tokens: MarketplaceNftLoadingData[] | null; loaded: boolean; } @@ -35,7 +39,7 @@ export const initialState: MarketplaceState = { //// Reducers & Slice -type PopulateMarketplace = Reducer<{ tokens: Token[] }>; +type PopulateMarketplace = Reducer<{ tokens: MarketplaceNftLoadingData[] }>; const populateMarketplaceR: PopulateMarketplace = (state, { payload }) => { state.marketplace.tokens = payload.tokens; @@ -50,11 +54,10 @@ const slice = createSlice({ }, extraReducers: ({ addCase }) => { addCase(getMarketplaceNftsQuery.fulfilled, populateMarketplaceR); + addCase(loadMoreMarketplaceNftsQuery.fulfilled, populateMarketplaceR); } }); -export const { - populateMarketplace -} = slice.actions; +export const { populateMarketplace } = slice.actions; export default slice; diff --git a/src/reducer/slices/system.ts b/src/reducer/slices/system.ts index 7d0b3c56..7036716d 100644 --- a/src/reducer/slices/system.ts +++ b/src/reducer/slices/system.ts @@ -1,7 +1,11 @@ import { Minter, SystemWithToolkit, SystemWithWallet } from '../../lib/system'; import { createSlice } from '@reduxjs/toolkit'; import config from '../../config.json'; -import { connectWallet, disconnectWallet, reconnectWallet } from '../async/wallet'; +import { + connectWallet, + disconnectWallet, + reconnectWallet +} from '../async/wallet'; const initialState = Minter.connectToolkit(Minter.configure(config)) as | SystemWithToolkit diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index b09523f1..3385f545 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -28,10 +28,7 @@ type Config = { export function register(config?: Config) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL( - process.env.PUBLIC_URL, - window.location.href - ); + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to diff --git a/tsconfig.json b/tsconfig.json index 7f2c9dd3..7902a906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,10 +18,6 @@ "isolatedModules": true, "noEmit": true, "jsx": "react", - - // The below makes typescript more strict about types - // Consider changing them if you run into problems with - // existing libraries "noImplicitAny": true, "strictNullChecks": true, "noImplicitThis": true, diff --git a/yarn.lock b/yarn.lock index db1e2579..ad286cc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2404,10 +2404,10 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-7.1.2.tgz#3a71bb8a45a1e08b71a54c9efcee9927f3895e80" integrity sha512-lDyCVxxgX5lrgCa75ELCfWcdEDyfisjqoDIM3YsghQ+lyViIac/qT67qabQ/HmoVxyikFKovjKwWdn3b/oKhZA== -"@tqtezos/minter-contracts@1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tqtezos/minter-contracts/-/minter-contracts-1.0.3.tgz#e46e6706f72d5a200c2be96585d67aa211ee048d" - integrity sha512-FmOlwRSCR38o41Kcnlxs0D27K2/D/yHt9iUJ+mfPOVAvAut/Lq4IWfRpZIbdQ4oWTY/Wb2//XjsZ9ptzOB+4Aw== +"@tqtezos/minter-contracts@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@tqtezos/minter-contracts/-/minter-contracts-1.2.0.tgz#5b98addeb6428e7cba27a35475fa601a32df6737" + integrity sha512-KrgdApZnHzTzedUjsNzWxEWYisyeXvd2NIfUj+CcmzL00708EgCbX1zXOo5qLcsRKuffpJGbeARH32rWKUGRAw== "@tsed/logger@5.5.2": version "5.5.2" @@ -2473,11 +2473,33 @@ "@types/filesystem" "*" "@types/har-format" "*" +"@types/clear@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@types/clear/-/clear-0.1.1.tgz#f5b5217d74f540682dce941f6f6323768ab1bc91" + integrity sha512-Wu6DxCnSjFiqymbTeyb63VdU1oKYW0qCnmOSBjpMyuvcuvI9keXfS6RbEcKYqUY0dPOLa34qV+XHAdgiRzPBtg== + +"@types/cli-color@*": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/cli-color/-/cli-color-2.0.0.tgz#dc64e32da0fb9ea1814300fb468a58e833ce71a6" + integrity sha512-E2Oisr73FjwxMHkYU6RcN9P9mmrbG4TNQMIebWhazYxOgWRzA7s4hM+DtAs6ZwiwKFbPst42v1XUAC1APIhRJA== + +"@types/clui@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@types/clui/-/clui-0.3.0.tgz#a7512770d50c06b403e018c46f850a0064e53c74" + integrity sha512-4GM6iuKwOs4Lq2qUWbxw8LMiamh6YXEuPq4uKeYd7SfFWNK1sNsw41M9GjIhwbIRBOaVgxkutZLLdfZwSNDwtg== + dependencies: + "@types/cli-color" "*" + "@types/configstore@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/configstore/-/configstore-4.0.0.tgz#cb718f9507e9ee73782f40d07aaca1cd747e36fa" integrity sha512-SvCBBPzOIe/3Tu7jTl2Q8NjITjLmq9m7obzjSyb8PXWWZ31xVK6w4T6v8fOx+lrgQnqk3Yxc00LDolFsSakKCA== +"@types/figlet@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/figlet/-/figlet-1.5.1.tgz#a9f06317b8900b7d3ad8d8bb577ec26225ac16a8" + integrity sha512-dwOwRPJY122FcWRdiXic+H72AOD+Cx69NO6Z9STtm9eIvM47qBe0vXdD/w4ad+ygIqvSTdnQyeMwlxotDjlvPg== + "@types/filesystem@*": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.29.tgz#ee3748eb5be140dcf980c3bd35f11aec5f7a3748" @@ -2490,7 +2512,7 @@ resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.28.tgz#c054e8af4d9dd75db4e63abc76f885168714d4b3" integrity sha1-wFTor02d11205jq8dviFFocU1LM= -"@types/glob@^7.1.1": +"@types/glob@*", "@types/glob@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== @@ -2511,6 +2533,14 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/inquirer@7.3.1": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d" + integrity sha512-osD38QVIfcdgsPCT0V3lD7eH0OFurX71Jft18bZrsVQWVRt6TuxRzlr0GJLrxoHZR2V5ph7/qP8se/dcnI7o0g== + dependencies: + "@types/through" "*" + rxjs "^6.4.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -2657,6 +2687,14 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/shelljs@0.8.8": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@types/shelljs/-/shelljs-0.8.8.tgz#e439c69929b88a2c8123c1a55e09eb708315addf" + integrity sha512-lD3LWdg6j8r0VRBFahJVaxoW0SIcswxKaFUrmKl33RJVeeoNYQAz4uqCJ5Z6v4oIBOsC5GozX+I5SorIKiTcQA== + dependencies: + "@types/glob" "*" + "@types/node" "*" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -2690,6 +2728,13 @@ "@types/testing-library__dom" "*" pretty-format "^25.1.0" +"@types/through@*": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" + integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg== + dependencies: + "@types/node" "*" + "@types/tinycolor2@1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf" @@ -4168,6 +4213,21 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== +clear@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" + integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== + +cli-color@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-0.3.2.tgz#75fa5f728c308cc4ac594b05e06cc5d80daccd86" + integrity sha1-dfpfcowwjMSsWUsF4GzF2A2szYY= + dependencies: + d "~0.1.1" + es5-ext "~0.10.2" + memoizee "0.3.x" + timers-ext "0.1.x" + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -4223,6 +4283,13 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clui@0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/clui/-/clui-0.3.6.tgz#8e1e5cea7332a6e54083f59da0ccbe1d6f2fa787" + integrity sha512-Z4UbgZILlIAjkEkZiDOa2aoYjohKx7fa6DxIh6cE9A6WNWZ61iXfQc6CmdC9SKdS5nO0P0UyQ+WfoXfB65e3HQ== + dependencies: + cli-color "0.3.2" + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -4872,6 +4939,13 @@ d@1, d@^1.0.1: es5-ext "^0.10.50" type "^1.0.1" +d@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" + integrity sha1-2hhMU10Y2O57oqoim5FACfrhEwk= + dependencies: + es5-ext "~0.10.2" + damerau-levenshtein@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" @@ -5391,7 +5465,7 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.50: +es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.11, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46, es5-ext@~0.10.5, es5-ext@~0.10.6: version "0.10.53" resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== @@ -5409,6 +5483,15 @@ es6-iterator@2.0.3, es6-iterator@~2.0.3: es5-ext "^0.10.35" es6-symbol "^3.1.1" +es6-iterator@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-0.1.3.tgz#d6f58b8c4fc413c249b4baa19768f8e4d7c8944e" + integrity sha1-1vWLjE/EE8JJtLqhl2j45NfIlE4= + dependencies: + d "~0.1.1" + es5-ext "~0.10.5" + es6-symbol "~2.0.1" + es6-symbol@^3.1.1, es6-symbol@~3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" @@ -5417,6 +5500,24 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3: d "^1.0.1" ext "^1.1.2" +es6-symbol@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-2.0.1.tgz#761b5c67cfd4f1d18afb234f691d678682cb3bf3" + integrity sha1-dhtcZ8/U8dGK+yNPaR1nhoLLO/M= + dependencies: + d "~0.1.1" + es5-ext "~0.10.5" + +es6-weak-map@~0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-0.1.4.tgz#706cef9e99aa236ba7766c239c8b9e286ea7d228" + integrity sha1-cGzvnpmqI2undmwjnIueKG6n0ig= + dependencies: + d "~0.1.1" + es5-ext "~0.10.6" + es6-iterator "~0.1.3" + es6-symbol "~2.0.1" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -5682,6 +5783,14 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-emitter@~0.3.4: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -5935,6 +6044,11 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== +figlet@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.5.0.tgz#2db4d00a584e5155a96080632db919213c3e003c" + integrity sha512-ZQJM4aifMpz6H19AW1VqvZ7l4pOE9p7i/3LyxgO2kp+PO/VcDYNqIHEMtkccqIhTXMKci4kjueJr/iCQEaT/Ww== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -6153,6 +6267,11 @@ forwarded@~0.1.2: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= +fp-ts@2.10.3: + version "2.10.3" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.10.3.tgz#79fe4c7b876d0137a98e287737c3faf63bdcd1dd" + integrity sha512-Lq9XweGms3tAmCh1AAxBG+1PfBY1zKQ3kD52q3Db6SgoA4xIUKLFZQBhmuZ7fCGmhUPZF32rlSX2/QBP0VMdjg== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -6341,7 +6460,7 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: +glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -6927,6 +7046,11 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -6939,6 +7063,11 @@ invert-kv@^2.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== +io-ts@2.2.16: + version "2.2.16" + resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.16.tgz#597dffa03db1913fc318c9c6df6931cb4ed808b2" + integrity sha512-y5TTSa6VP6le0hhmIyN0dqEXkrZeJLeC5KApJq6VLci3UEKF80lZ+KuoUs02RhBxNWlrqSNxzfI7otLX1Euv8Q== + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -7045,6 +7174,13 @@ is-core-module@^2.1.0: dependencies: has "^1.0.3" +is-core-module@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.3.0.tgz#d341652e3408bca69c4671b79a0954a3d349f887" + integrity sha512-xSphU2KG9867tsYdLD4RWQ1VqdFl4HTO9Thf3I/3dLEfr0dbPTWKsuCKrgqMljg4nPE+Gq0VCnzT3gr0CyBmsw== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -8246,6 +8382,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-queue@0.1: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + dependencies: + es5-ext "~0.10.2" + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -8335,6 +8478,19 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" +memoizee@0.3.x: + version "0.3.10" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.3.10.tgz#4eca0d8aed39ec9d017f4c5c2f2f6432f42e5c8f" + integrity sha1-TsoNiu057J0Bf0xcLy9kMvQuXI8= + dependencies: + d "~0.1.1" + es5-ext "~0.10.11" + es6-weak-map "~0.1.4" + event-emitter "~0.3.4" + lru-queue "0.1" + next-tick "~0.2.2" + timers-ext "0.1" + memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" @@ -8643,6 +8799,16 @@ neo-async@^2.5.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next-tick@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + +next-tick@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-0.2.2.tgz#75da4a927ee5887e39065880065b7336413b310d" + integrity sha1-ddpKkn7liH45BliABltzNkE7MQ0= + next-tick@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -10593,6 +10759,13 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= + dependencies: + resolve "^1.1.6" + recursive-readdir@2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" @@ -10853,6 +11026,14 @@ resolve@1.15.0: dependencies: path-parse "^1.0.6" +resolve@^1.1.6: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.15.1, resolve@^1.3.2, resolve@^1.8.1: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" @@ -10959,6 +11140,13 @@ rx-sandbox@^1.0.3: expect "^26.6.1" jest-matcher-utils "^26.6.1" +rxjs@^6.4.0: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + rxjs@^6.5.3, rxjs@^6.6.0, rxjs@^6.6.3: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" @@ -11229,6 +11417,15 @@ shell-quote@1.7.2: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== +shelljs@0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" + integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" @@ -11913,6 +12110,14 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +timers-ext@0.1, timers-ext@0.1.x: + version "0.1.7" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" + integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== + dependencies: + es5-ext "~0.10.46" + next-tick "1" + timsort@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"