diff --git a/bin/infura_test.sh b/bin/infura_test.sh new file mode 100755 index 00000000..76a45001 --- /dev/null +++ b/bin/infura_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +HOST='mimis.infura-ipfs.io' +CID='QmPY47Dp1PcD7x1xdK7kxypUyRJfHKkPh4eVtNYBQPk5gj' +FILE='metadata.2022-05-11T19:13:08.420Z.json' +CIDV1="$(ipfs cid format "$CID" -v=1 -b=base32)" +URL="https://$HOST/ipfs/$CID/$(urlencode "$FILE")" +ROOT="$(realpath --relative-to=. "${0%/*}/../")" +ENV="$ROOT/packages/ui/.env.local" +USER="$(grep IPFS_AUTH_USERNAME "$ENV" | sed -e 's/.*=//' | tr -d [:space:])" +PASS="$(grep IPFS_AUTH_PASSWORD "$ENV" | sed -e 's/.*=//' | tr -d [:space:])" +AUTH="Basic $(echo -n "$USER:$PASS" | base64 -w100)" +APIURL="https://ipfs.infura.io:5001/api/v0/cat?arg=$CID/$(urlencode "$FILE")" +DWEBURL="https://$CIDV1.ipfs.dweb.link/$(urlencode "$FILE")" + +echo "Read \$USER = \"$USER\" & \$PASS = \"$PASS\" from $ENV" +echo "Retrieving: $URL" +curl -D - "$URL" +echo ' ————————————————————————————————————————————————————————————————————' +echo "Retrieving: $URL with Authorization" +curl -D - -H "Authorization: $AUTH" "$URL" +echo ' ————————————————————————————————————————————————————————————————————' +echo "Retrieving: $APIURL with \`curl\` \`-u\`" +curl -D - -X POST -u "$USER:$PASS" "$APIURL" +echo ' ————————————————————————————————————————————————————————————————————' +echo "Retrieving: $DWEBURL" +curl -D - "$DWEBURL" diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4eeea166..30f0343b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,7 +10,7 @@ import { InMemoryCache, ApolloProvider, } from '@apollo/client' -import { CONFIG } from '@/config' +import { nftGraph } from '@/config' import { // BrowserRouter as Router, HashRouter as Router, @@ -33,7 +33,7 @@ const themeConfig: ThemeConfig = { const theme = extendTheme({ config: themeConfig }) const client = new ApolloClient({ - uri: CONFIG.nftGraph, + uri: nftGraph, cache: new InMemoryCache(), }) diff --git a/packages/ui/src/components/NFTForm.tsx b/packages/ui/src/components/NFTForm.tsx index 52fc88b9..e79c553f 100644 --- a/packages/ui/src/components/NFTForm.tsx +++ b/packages/ui/src/components/NFTForm.tsx @@ -10,7 +10,7 @@ import { Table, Thead, Th, Tbody, Radio, RadioGroup, SimpleGrid, Stack, Center, } from '@chakra-ui/react' -import { NFT_HOMEPAGE_BASE } from '@/lib/constants' +import { nftHomepageBase } from '@/config' import { httpURL, isEmpty, regexify } from '@/lib/helpers' import { Attribute, ERC1155Metadata, Maybe, OpenSeaAttribute, @@ -213,7 +213,7 @@ export const NFTForm: React.FC<{ if(!homepage || isEmpty(homepage) || homepage.endsWith('𝘜𝘯𝘬𝘯𝘰𝘸𝘯')) { setValue( 'homepage', - `${NFT_HOMEPAGE_BASE}/${regexify(tokenId)}` + `${nftHomepageBase}/${regexify(tokenId)}` ) } }, [homepage, setValue, tokenId]) diff --git a/packages/ui/src/components/TokensTable.tsx b/packages/ui/src/components/TokensTable.tsx index 40cfaab8..a07bb38f 100644 --- a/packages/ui/src/components/TokensTable.tsx +++ b/packages/ui/src/components/TokensTable.tsx @@ -2,7 +2,7 @@ import { Box, Flex, Spinner, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, Link as ChakraLink, Tooltip, chakra, } from '@chakra-ui/react' -import { httpURL, regexify } from '@/lib/helpers' +import { extractMessage, httpURL, regexify } from '@/lib/helpers' import { TokenState } from '@/lib/types' import Markdown from 'react-markdown' import React from 'react' @@ -13,7 +13,7 @@ const RouterLink = chakra(ReactRouterLink) type IndexedToken = { token: TokenState, index: number } type Token = { token: TokenState } -const IdTd:React.FC = ({ token, index }) => ( +const IdTd:React.FC = ({ token }) => ( = ({ token }) => ( - {token.error} + <> + {console.info({ err: token.error, ext: extractMessage(token.error) })} + {extractMessage(token.error)} + @@ -119,8 +122,12 @@ const URITd:React.FC = ({ token }) => ( { - if(token.uri) { - navigator.clipboard.writeText(token.uri) + if( + token.uri + && typeof navigator !== 'undefined' + && window.isSecureContext + ) { + navigator.clipboard?.writeText(token.uri) } }} > diff --git a/packages/ui/src/config.ts b/packages/ui/src/config.ts index dbd7c5a5..29e18b6e 100644 --- a/packages/ui/src/config.ts +++ b/packages/ui/src/config.ts @@ -1,42 +1,95 @@ import { create as ipfsHTTPClient } from 'ipfs-http-client' +import { Buffer } from 'buffer' -export const CONFIG = { - infuraId: ( - process.env.NEXT_PUBLIC_INFURA_ID - ?? '781d8466252d47508e177b8637b1c2fd' - ), - ceramicURL: ( - process.env.NEXT_PUBLIC_CERAMIC_URL - ?? 'https://ceramic.metagame.wtf' // mainnet - ?? 'https://ceramic-clay.3boxlabs.com' // testnet - ), - ceramicNetwork: ( - process.env.NEXT_PUBLIC_CERAMIC_NETWORK - ?? 'mainnet' ?? 'testnet-clay' - ), - contractNetwork: ( - process.env.NEXT_PUBLIC_CHAIN_NAME || 'polygon' +export const infuraId = ( + process.env.INFURA_ID + ?? import.meta.env.VITE_INFURA_ID + ?? '12345678900987654321' +) + +export const ceramicURL = ( + process.env.CERAMIC_URL + ?? import.meta.env.CERAMIC_URL + ?? 'https://ceramic.metagame.wtf' // mainnet + ?? 'https://ceramic-clay.3boxlabs.com' // testnet +) + +export const ceramicNetwork = ( + process.env.CERAMIC_NETWORK + ?? import.meta.env.CERAMIC_NETWORK + ?? 'mainnet' ?? 'testnet-clay' +) + +export const contractNetwork = ( + process.env.CHAIN_NAME + ?? import.meta.env.VITE_CHAIN_NAME + ?? 'polygon' +) + +export const ipfsLinkPattern = ( + process.env.IPFS_LINK_PATTERN + ?? import.meta.env.IPFS_LINK_PATTERN + ?? 'https://{v1cid}.ipfs.dweb.link/{path}' + ?? 'https://mimis.infura-ipfs.io/ipfs/{cid}/{path}' +) + +export const ipfsAuth = { + username: ( + process.env.IPFS_AUTH_USERNAME + ?? import.meta.env.VITE_IPFS_AUTH_USERNAME ), - ipfs: ipfsHTTPClient({ - host: 'ipfs.infura.io', port: 5001, protocol: 'https' - }), - nftGraph: ( - process.env.NEXT_PUBLIC_NFT_GRAPH - ?? 'https://api.thegraph.com/subgraphs/name/alberthaotan/nft-matic' + password: ( + process.env.IPFS_AUTH_PASSWORD + ?? import.meta.env.VITE_IPFS_AUTH_PASSWORD ), - rolePermissions: { - Superuser: 'Can perform all actions on the token.', - Minter: 'Can mint new instances of the token.', - Caster: 'Can assign roles for the token.', - Transferer: 'Can transfer the token to another account.', - Configurer: 'Can change the token’s metadata URI.', - Maintainer: 'Can update the token contract.', - Creator: 'Can create new token types.', - Limiter: 'Can set the maximum mintable allowance for a token.', - Burner: 'Can destroy an instance of a token.', - Destroyer: 'Can destroy a token type.', - Oracle: 'Provides information about the off-chain world.', - } } -export default CONFIG \ No newline at end of file +export const Authorization = ( + (ipfsAuth.username && ipfsAuth.password) ? ( + `Basic ${Buffer.from(`${ipfsAuth.username}:${ipfsAuth.password}`).toString('base64')}` + ) : ( + null + ) +) + +const ipfsAPIHost = ( + process.env.IPFS_API_HOST + ?? import.meta.env.IPFS_API_HOST + ?? 'ipfs.infura.io' +) + +const ipfsAPIPort = ( + process.env.IPFS_API_PORT + ?? import.meta.env.IPFS_API_PORT + ?? 5001 +) + +export const ipfs = ipfsHTTPClient({ + host: ipfsAPIHost, + port: ipfsAPIPort, + protocol: 'https', + headers: Authorization ? { Authorization } : {}, +}) + +export const nftGraph = ( + process.env.NFT_GRAPH + ?? 'https://api.thegraph.com/subgraphs/name/alberthaotan/nft-matic' +) + +export const rolePermissions = { + Superuser: 'Can perform all actions on the token.', + Minter: 'Can mint new instances of the token.', + Caster: 'Can assign roles for the token.', + Transferer: 'Can transfer the token to another account.', + Configurer: 'Can change the token’s metadata URI.', + Maintainer: 'Can update the token contract.', + Creator: 'Can create new token types.', + Limiter: 'Can set the maximum mintable allowance for a token.', + Burner: 'Can destroy an instance of a token.', + Destroyer: 'Can destroy a token type.', + Oracle: 'Provides information about the off-chain world.', +} + +export const nftHomepageBase = ( + 'https://chiev.es/#/view' +) diff --git a/packages/ui/src/lib/helpers.ts b/packages/ui/src/lib/helpers.ts index 694fb662..e8546b74 100644 --- a/packages/ui/src/lib/helpers.ts +++ b/packages/ui/src/lib/helpers.ts @@ -2,10 +2,11 @@ import { CodedError, FileListish, Limits, Maybe, MetaMaskError, NamedString, NestedError } from '@/lib/types' import { CID } from 'multiformats/cid' -import { IPFS_LINK_PATTERN } from '@/lib/constants' +import { ipfsLinkPattern } from '@/config' import { NETWORKS } from '@/lib/networks' -import CONFIG from '@/config' +import { ipfs } from '@/config' import all from 'it-all' +import JSON5 from 'json5' export const httpURL = (uri?: Maybe) => { const [, origCID, path] = ( @@ -16,7 +17,7 @@ export const httpURL = (uri?: Maybe) => { const cid = CID.parse(origCID) const v0CID = cid.toV0().toString() const v1CID = cid.toV1().toString() - const pattern = IPFS_LINK_PATTERN + const pattern = ipfsLinkPattern return ( encodeURI( pattern @@ -116,7 +117,7 @@ export const ipfsify = async (filesOrURL: FileListish) => { ) ) - const result = await all(CONFIG.ipfs.addAll( + const result = await all(ipfs.addAll( list.map((entry) => ({ path: entry.name, content: (entry as NamedString).content ?? entry @@ -181,7 +182,7 @@ export const extractMessage = (error: unknown): string => ( (error as NestedError)?.error?.message ?? (error as MetaMaskError)?.data?.message ?? (error as Error)?.message - ?? error + ?? (typeof error === 'string' ? error : `𝑼𝒏𝒌𝒏𝒐𝒘𝒏 𝑬𝒓𝒓𝒐𝒓: ${JSON5.stringify(error, null, 2)}`) ) as string ) diff --git a/packages/ui/src/lib/hooks.tsx b/packages/ui/src/lib/hooks.tsx index 45785281..77873d47 100644 --- a/packages/ui/src/lib/hooks.tsx +++ b/packages/ui/src/lib/hooks.tsx @@ -14,7 +14,7 @@ import React, { } from 'react' import providerOptions from '@/lib/walletConnect' import { NETWORKS } from '@/lib/networks' -import CONFIG from '@/config' +import { contractNetwork } from '@/config' export type Web3ContextType = { userProvider?: Web3Provider @@ -78,8 +78,8 @@ export const Web3ContextProvider: React.FC<{ children: ReactNode }> = ( const { default: Web3Modal } = await import('web3modal') setWeb3Modal(new Web3Modal({ network: ( - CONFIG.contractNetwork === 'polygon' - ? 'matic' : CONFIG.contractNetwork + contractNetwork === 'polygon' + ? 'matic' : contractNetwork ), cacheProvider: true, providerOptions, @@ -202,7 +202,7 @@ export const Web3ContextProvider: React.FC<{ children: ReactNode }> = ( useEffect(() => { const libs = async () => { - const { contractNetwork: chain } = CONFIG + const chain = contractNetwork if(!contractAddress) { import( `../contracts/${chain}/BulkDisbursableNFTs.address.ts` @@ -222,7 +222,7 @@ export const Web3ContextProvider: React.FC<{ children: ReactNode }> = ( useEffect(() => { const libs = async () => { - const { contractNetwork: chain } = CONFIG + const chain = contractNetwork import( `../contracts/${chain}/Bits.address.ts` ) diff --git a/packages/ui/src/lib/networks.ts b/packages/ui/src/lib/networks.ts index d008ba16..5e6999f8 100644 --- a/packages/ui/src/lib/networks.ts +++ b/packages/ui/src/lib/networks.ts @@ -1,4 +1,4 @@ -import CONFIG from '@/config' +import { infuraId, contractNetwork } from '@/config' import { Maybe } from './types' export type NetworkInfo = { @@ -19,7 +19,7 @@ export const NETWORKS: NetworkInfo = { label: 'Ethereum', symbol: 'ETH', explorer: 'https://etherscan.io', - rpc: `https://mainnet.infura.io/v3/${CONFIG.infuraId}`, + rpc: `https://mainnet.infura.io/v3/${infuraId}`, }, rinkeby: { chainId: '0x4', @@ -27,7 +27,7 @@ export const NETWORKS: NetworkInfo = { label: 'Rinkeby', symbol: 'ETH', explorer: 'https://rinkeby.etherscan.io', - rpc: `https://rinkeby.infura.io/v3/${CONFIG.infuraId}`, + rpc: `https://rinkeby.infura.io/v3/${infuraId}`, }, gnosis: { chainId: '0x64', @@ -65,6 +65,6 @@ export const NETWORKS: NetworkInfo = { rpc: 'http://127.0.0.1:8545', }, get contract() { - return this[CONFIG.contractNetwork] + return this[contractNetwork] }, } diff --git a/packages/ui/src/pages/disburse.tsx b/packages/ui/src/pages/disburse.tsx index cd6f7258..7eea6286 100644 --- a/packages/ui/src/pages/disburse.tsx +++ b/packages/ui/src/pages/disburse.tsx @@ -118,7 +118,6 @@ const Disburse = () => { if(!meta) { setMetadata(null) } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const response = await fetch(httpURL(meta)!) setMetadata(await response.json()) } diff --git a/packages/ui/src/pages/edit.tsx b/packages/ui/src/pages/edit.tsx index 16d62b7f..a6ecde4d 100644 --- a/packages/ui/src/pages/edit.tsx +++ b/packages/ui/src/pages/edit.tsx @@ -10,7 +10,6 @@ import { HomeLink, OptionsForm } from '@/components' import { Alert, AlertDescription, AlertIcon, AlertTitle, Box } from '@chakra-ui/react' import { useParams } from 'react-router-dom' import { Helmet } from 'react-helmet' - export const Edit = () => { const { nftId } = useParams() const tokenId = useMemo(() => deregexify(nftId), [nftId]) @@ -26,7 +25,6 @@ export const Edit = () => { if(!meta) { setMetadata(null) } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const response = await fetch(httpURL(meta)!) setMetadata(await response.json()) } diff --git a/packages/ui/src/pages/home.tsx b/packages/ui/src/pages/home.tsx index ea053de1..4468cfbc 100644 --- a/packages/ui/src/pages/home.tsx +++ b/packages/ui/src/pages/home.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { - Container, Flex, chakra, + Container, Flex, chakra, Button, } from '@chakra-ui/react' import { extractMessage, httpURL, toNumList } from '@/lib/helpers' import type { Limits, Maybe, TokenState } from '@/lib/types' @@ -20,9 +20,9 @@ const Home = () => { const [gatingVisible, setGatingVisible] = ( useState(!!query.get('gating') ?? false) ) - const visible = query.get('visible') ?? '' + const visibleListParam = query.get('visible') ?? '' const [visibleList, setVisibleList] = ( - useState>(toNumList(visible)) + useState>(toNumList(visibleListParam)) ) const { roContract, constsContract } = useWeb3() const setToken = (index: number, info: Record) => { @@ -57,31 +57,77 @@ const Home = () => { }, [roContract, constsContract]) useEffect(() => { - setVisibleList(toNumList(visible)) - }, [visible]) + setVisibleList(toNumList(visibleListParam)) + }, [visibleListParam]) - const tokenForIndex = async (index: number, hideable: boolean = true) => { - try { - const id: bigint = ( - (await roContract.tokenByIndex(index)).toBigInt() - ) - const is: { [key: string]: unknown } = {} - is.gating = ( - ( - id - & ( - 2n**BigInt(TYPE_WIDTH) - 1n - << BigInt(TYPE_BOUNDARY) + const tokenForIndex = useCallback( + async (index: number, hideable = true) => { + try { + const id: bigint = ( + (await roContract.tokenByIndex(index)).toBigInt() + ) + const is: { [key: string]: unknown } = {} + is.gating = ( + ( + id + & ( + 2n**BigInt(TYPE_WIDTH) - 1n + << BigInt(TYPE_BOUNDARY) + ) ) + === GATING_TYPE ) - === GATING_TYPE + is.hidden = hideable && is.gating && !gatingVisible + return { id: `0x${id.toString(16)}`, is, index } + } catch(error) { + return error + } + }, + [GATING_TYPE, TYPE_BOUNDARY, TYPE_WIDTH, gatingVisible, roContract], + ) + + const retrieve = useCallback( + async (tokens: Array) => { + await Promise.all( + tokens.map(async (token, index) => { + if(!(token instanceof Error)) { + if(token.is?.hidden) { + return null + } + + try { + const uri = await roContract.uri(token.id) + if(uri === '') throw new Error('No URI… Waiting for configuration…') + setToken(index, { uri }) + const response = await fetch(httpURL(uri)!) + const data = await response.text() + if(!data || data === '') { + throw new Error('No Data') + } + try { + const metadata = JSON5.parse(data) + setToken(index, { metadata }) + } catch(error) { + console.error('JSON Error', { error, data }) + throw error + } + } catch(error) { + setToken(index, { + error: extractMessage(error), + }) + } + + const total = await roContract.totalSupply(token.id) + setToken(index, { total }) + + const max = await roContract.getMax(token.id) + setToken(index, { max }) + } + }).filter((tkn) => !!tkn) ) - is.hidden = hideable && is.gating && !gatingVisible - return { id: `0x${id.toString(16)}`, is, index } - } catch(error) { - return error - } - } + }, + [roContract], + ) useEffect(() => { const load = async () => { @@ -119,53 +165,13 @@ const Home = () => { ) } const tokens = (await Promise.all(generators)).flat() - retrieve(tokens) - setTokens((await Promise.all(generators)).flat()) + console.info({ generators, tokens }) + await retrieve(tokens) + setTokens(tokens) } } load() - }, [visibleList]) - - const retrieve = useCallback(async (tokens: Array) => { - await Promise.all( - tokens.map(async (token, index) => { - if(!(token instanceof Error)) { - if(token.is?.hidden) { - return null - } - let metadata = null - try { - const uri = await roContract.uri(token.id) - if(uri === '') throw new Error('No URI… Waiting for data…') - setToken(index, { uri }) - const url = httpURL(uri)! - const response = await fetch(url) - const data = await response.text() - if(!data || data === '') { - throw new Error('No Data') - } - try { - metadata = JSON5.parse(data) - } catch(error) { - console.error('JSON Error', { error, data }) - } - } catch(error) { - setToken(index, { - error: extractMessage(error), - }) - } finally { - setToken(index, { metadata }) - } - - const total = await roContract.totalSupply(token.id) - setToken(index, { total }) - - const max = await roContract.getMax(token.id) - setToken(index, { max }) - } - }) - ) - }, []) + }, [visibleList, retrieve, roContract, constsContract, limit, offset, tokenForIndex, typeCount]) return ( @@ -193,6 +199,19 @@ const Home = () => { }} /> + + + + ) diff --git a/packages/ui/src/pages/new.tsx b/packages/ui/src/pages/new.tsx index 54fb09f0..e62ec803 100644 --- a/packages/ui/src/pages/new.tsx +++ b/packages/ui/src/pages/new.tsx @@ -9,7 +9,7 @@ import { NETWORKS } from '@/lib/networks' import { OptionsForm, Header, SubmitButton } from '@/components' import { Event } from 'ethers' import { useForm } from 'react-hook-form' -import { CONFIG } from '@/config' +import { rolePermissions } from '@/config' import { switchTo, extractMessage } from '@/lib/helpers' import { Helmet } from 'react-helmet' import { useSearchParams } from 'react-router-dom' @@ -43,7 +43,6 @@ const Content: React.FC = () => { const [working, setWorking] = useState(false) const { register, handleSubmit } = useForm() const toast = useToast() - const { rolePermissions } = CONFIG useEffect(() => { if(typeof id === 'string') {