/* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable no-nested-ternary */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { toUtf8 } from '@cosmjs/encoding' import { coin } from '@cosmjs/proto-signing' import clsx from 'clsx' import { Button } from 'components/Button' import type { MinterType } from 'components/collections/actions/Combobox' import { Conditional } from 'components/Conditional' import { ConfirmationModal } from 'components/ConfirmationModal' import { LoadingModal } from 'components/LoadingModal' import { useContracts } from 'contexts/contracts' import { addLogItem } from 'contexts/log' import { useWallet } from 'contexts/wallet' import type { DispatchExecuteArgs as OpenEditionFactoryDispatchExecuteArgs } from 'contracts/openEditionFactory/messages/execute' import { dispatchExecute as openEditionFactoryDispatchExecute } from 'contracts/openEditionFactory/messages/execute' import React, { useEffect, useMemo, useState } from 'react' import { toast } from 'react-hot-toast' import { upload } from 'services/upload' import { OPEN_EDITION_FACTORY_ADDRESS, OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS, SG721_CODE_ID, SG721_UPDATABLE_CODE_ID, } from 'utils/constants' import { getAssetType } from 'utils/getAssetType' import { isValidAddress } from 'utils/isValidAddress' import { uid } from 'utils/random' import { type CollectionDetailsDataProps, CollectionDetails } from './CollectionDetails' import type { ImageUploadDetailsDataProps } from './ImageUploadDetails' import { ImageUploadDetails } from './ImageUploadDetails' import type { MintingDetailsDataProps } from './MintingDetails' import { MintingDetails } from './MintingDetails' import type { UploadMethod } from './OffChainMetadataUploadDetails' import { type OffChainMetadataUploadDetailsDataProps, OffChainMetadataUploadDetails, } from './OffChainMetadataUploadDetails' import type { OnChainMetadataInputDetailsDataProps } from './OnChainMetadataInputDetails' import { OnChainMetadataInputDetails } from './OnChainMetadataInputDetails' import { type RoyaltyDetailsDataProps, RoyaltyDetails } from './RoyaltyDetails' export type MetadataStorageMethod = 'off-chain' | 'on-chain' interface OpenEditionMinterCreatorProps { onChange: (data: OpenEditionMinterCreatorDataProps) => void openEditionMinterUpdatableCreationFee?: string openEditionMinterCreationFee?: string minimumMintPrice?: string minimumUpdatableMintPrice?: string minterType?: MinterType } export interface OpenEditionMinterCreatorDataProps { metadataStorageMethod: MetadataStorageMethod openEditionMinterContractAddress: string | null sg721ContractAddress: string | null transactionHash: string | null } export const OpenEditionMinterCreator = ({ onChange, openEditionMinterCreationFee, openEditionMinterUpdatableCreationFee, minimumMintPrice, minimumUpdatableMintPrice, minterType, }: OpenEditionMinterCreatorProps) => { const wallet = useWallet() const { openEditionMinter: openEditionMinterContract, openEditionFactory: openEditionFactoryContract } = useContracts() const openEditionFactoryMessages = useMemo( () => openEditionFactoryContract?.use(OPEN_EDITION_FACTORY_ADDRESS), [openEditionFactoryContract, wallet.address], ) const [metadataStorageMethod, setMetadataStorageMethod] = useState('off-chain') const [imageUploadDetails, setImageUploadDetails] = useState(null) const [collectionDetails, setCollectionDetails] = useState(null) const [royaltyDetails, setRoyaltyDetails] = useState(null) const [onChainMetadataInputDetails, setOnChainMetadataInputDetails] = useState(null) const [offChainMetadataUploadDetails, setOffChainMetadataUploadDetails] = useState(null) const [mintingDetails, setMintingDetails] = useState(null) const [creationInProgress, setCreationInProgress] = useState(false) const [readyToCreate, setReadyToCreate] = useState(false) const [uploading, setUploading] = useState(false) const [tokenUri, setTokenUri] = useState(null) const [tokenImageUri, setTokenImageUri] = useState(null) const [coverImageUrl, setCoverImageUrl] = useState(null) const [openEditionMinterContractAddress, setOpenEditionMinterContractAddress] = useState(null) const [sg721ContractAddress, setSg721ContractAddress] = useState(null) const [transactionHash, setTransactionHash] = useState(null) const performOpenEditionMinterChecks = () => { try { setReadyToCreate(false) checkUploadDetails() checkCollectionDetails() checkMintingDetails() void checkRoyaltyDetails() .then(() => { void checkwalletBalance() .then(() => { setReadyToCreate(true) }) .catch((error: any) => { toast.error(`Error in Wallet Balance: ${error.message}`, { style: { maxWidth: 'none' } }) addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() }) setReadyToCreate(false) }) }) .catch((error: any) => { toast.error(`Error in Royalty Details: ${error.message}`, { style: { maxWidth: 'none' } }) addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() }) setReadyToCreate(false) }) } catch (error: any) { toast.error(error.message, { style: { maxWidth: 'none' } }) addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() }) setUploading(false) setReadyToCreate(false) } } const checkUploadDetails = () => { if (!wallet.initialized) throw new Error('Wallet not connected.') if ( (metadataStorageMethod === 'off-chain' && !offChainMetadataUploadDetails) || (metadataStorageMethod === 'on-chain' && !imageUploadDetails) ) { throw new Error('Please select assets and metadata') } if ( metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'new' && offChainMetadataUploadDetails.assetFiles.length === 0 ) { throw new Error('Please select the asset file') } if ( metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'new' && imageUploadDetails.assetFile === undefined ) { throw new Error('Please select the asset file') } if (metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'new') { if ( offChainMetadataUploadDetails.uploadService === 'nft-storage' && offChainMetadataUploadDetails.nftStorageApiKey === '' ) { throw new Error('Please enter a valid NFT.Storage API key') } else if ( offChainMetadataUploadDetails.uploadService === 'pinata' && (offChainMetadataUploadDetails.pinataApiKey === '' || offChainMetadataUploadDetails.pinataSecretKey === '') ) { throw new Error('Please enter valid Pinata API and secret keys') } } if (metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'new') { if (imageUploadDetails.uploadService === 'nft-storage' && imageUploadDetails.nftStorageApiKey === '') { throw new Error('Please enter a valid NFT.Storage API key') } else if ( imageUploadDetails.uploadService === 'pinata' && (imageUploadDetails.pinataApiKey === '' || imageUploadDetails.pinataSecretKey === '') ) { throw new Error('Please enter valid Pinata API and secret keys') } } if (metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'existing') { if ( offChainMetadataUploadDetails.tokenURI === '' || !(offChainMetadataUploadDetails.tokenURI as string).includes('ipfs://') ) { throw new Error('Please enter a valid token URI') } if ( offChainMetadataUploadDetails.imageUrl === '' || !(offChainMetadataUploadDetails.imageUrl as string).includes('ipfs://') ) { throw new Error('Please enter a valid image URI') } } if (metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'existing') { if (imageUploadDetails.imageUrl === '' || !(imageUploadDetails.imageUrl as string).includes('ipfs://')) { throw new Error('Please enter a valid asset URI') } if ( imageUploadDetails.coverImageUrl === '' || !(imageUploadDetails.coverImageUrl as string).includes('ipfs://') ) { throw new Error('Please enter a valid cover image URL') } } } const checkCollectionDetails = () => { if (!collectionDetails) throw new Error('Please fill out the collection details') if (collectionDetails.name === '') throw new Error('Collection name is required') if (collectionDetails.description === '') throw new Error('Collection description is required') if (collectionDetails.symbol === '') throw new Error('Collection symbol is required') if (collectionDetails.description.length > 512) throw new Error('Collection description cannot exceed 512 characters') if ( metadataStorageMethod === 'off-chain' && offChainMetadataUploadDetails?.uploadMethod === 'new' && collectionDetails.imageFile.length === 0 ) throw new Error('Collection cover image is required') if ( metadataStorageMethod === 'on-chain' && imageUploadDetails?.uploadMethod === 'new' && collectionDetails.imageFile.length === 0 ) throw new Error('Collection cover image is required') if ( collectionDetails.startTradingTime && Number(collectionDetails.startTradingTime) < new Date().getTime() * 1000000 ) throw new Error('Invalid trading start time') if ( collectionDetails.startTradingTime && Number(collectionDetails.startTradingTime) < Number(mintingDetails?.startTime) ) throw new Error('Trading start time must be after minting start time') if (collectionDetails.externalLink) { try { const url = new URL(collectionDetails.externalLink) } catch (e: any) { throw new Error(`Invalid external link: Make sure to include the protocol (e.g. https://)`) } } } const checkMintingDetails = () => { if (!mintingDetails) throw new Error('Please fill out the minting details') if (mintingDetails.unitPrice === '') throw new Error('Mint price is required') if (collectionDetails?.updatable) { if (Number(mintingDetails.unitPrice) < Number(minimumUpdatableMintPrice)) throw new Error( `Invalid mint price: The minimum mint price is ${Number(minimumUpdatableMintPrice) / 1000000} STARS`, ) } else if (Number(mintingDetails.unitPrice) < Number(minimumMintPrice)) throw new Error(`Invalid mint price: The minimum mint price is ${Number(minimumMintPrice) / 1000000} STARS`) if (!mintingDetails.perAddressLimit || mintingDetails.perAddressLimit < 1 || mintingDetails.perAddressLimit > 50) throw new Error('Invalid limit for tokens per address') if (mintingDetails.startTime === '') throw new Error('Start time is required') if (Number(mintingDetails.startTime) < new Date().getTime() * 1000000) throw new Error('Invalid start time') if ( mintingDetails.paymentAddress && (!isValidAddress(mintingDetails.paymentAddress) || !mintingDetails.paymentAddress.startsWith('stars1')) ) throw new Error('Invalid payment address') } const checkRoyaltyDetails = async () => { if (!royaltyDetails) throw new Error('Please fill out the royalty details') if (royaltyDetails.royaltyType === 'new') { if (royaltyDetails.share === 0) throw new Error('Royalty share percentage is required') if (royaltyDetails.share > 100 || royaltyDetails.share < 0) throw new Error('Invalid royalty share percentage') if (royaltyDetails.paymentAddress === '') throw new Error('Royalty payment address is required') if (!isValidAddress(royaltyDetails.paymentAddress.trim())) { if (royaltyDetails.paymentAddress.trim().endsWith('.stars')) { throw new Error('Royalty payment address could not be resolved') } throw new Error('Invalid royalty payment address') } const contractInfoResponse = await wallet.client ?.queryContractRaw( royaltyDetails.paymentAddress.trim(), toUtf8(Buffer.from(Buffer.from('contract_info').toString('hex'), 'hex').toString()), ) .catch((e) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (e.message.includes('bech32')) throw new Error('Invalid royalty payment address.') console.log(e.message) }) if (contractInfoResponse !== undefined) { const contractInfo = JSON.parse(new TextDecoder().decode(contractInfoResponse as Uint8Array)) // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (contractInfo && !contractInfo.contract.includes('splits')) throw new Error('The provided royalty payment address does not belong to a splits contract.') else console.log(contractInfo) } } } const checkwalletBalance = async () => { if (!wallet.initialized) throw new Error('Wallet not connected.') const amountNeeded = collectionDetails?.updatable ? Number(openEditionMinterUpdatableCreationFee) : Number(openEditionMinterCreationFee) await wallet.client?.getBalance(wallet.address, 'ustars').then((balance) => { if (amountNeeded >= Number(balance.amount)) throw new Error( `Insufficient wallet balance to instantiate the required contracts. Needed amount: ${( amountNeeded / 1000000 ).toString()} STARS`, ) }) } const createOpenEditionMinter = async () => { try { setCreationInProgress(true) setTokenUri(null) setCoverImageUrl(null) setTokenImageUri(null) setOpenEditionMinterContractAddress(null) setSg721ContractAddress(null) setTransactionHash(null) if (metadataStorageMethod === 'off-chain') { if (offChainMetadataUploadDetails?.uploadMethod === 'new') { setUploading(true) const metadataUri = await uploadForOffChainStorage() const coverImageUri = await upload( collectionDetails?.imageFile as File[], offChainMetadataUploadDetails.uploadService, 'cover', offChainMetadataUploadDetails.nftStorageApiKey as string, offChainMetadataUploadDetails.pinataApiKey as string, offChainMetadataUploadDetails.pinataSecretKey as string, ) const metadataUriWithBase = `ipfs://${metadataUri}/${( offChainMetadataUploadDetails.openEditionMinterMetadataFile as File ).name.substring( 0, (offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.lastIndexOf('.'), )}` const coverImageUriWithBase = `ipfs://${coverImageUri}/${(collectionDetails?.imageFile as File[])[0].name}` setTokenUri(metadataUriWithBase) setCoverImageUrl(coverImageUriWithBase) setUploading(false) await instantiateOpenEditionMinter(metadataUriWithBase, coverImageUriWithBase) } else { setTokenUri(offChainMetadataUploadDetails?.tokenURI as string) setCoverImageUrl(offChainMetadataUploadDetails?.imageUrl as string) await instantiateOpenEditionMinter( offChainMetadataUploadDetails?.tokenURI as string, offChainMetadataUploadDetails?.imageUrl as string, ) } } else if (metadataStorageMethod === 'on-chain') { if (imageUploadDetails?.uploadMethod === 'new') { setUploading(true) const imageUri = await upload( [imageUploadDetails.assetFile as File], imageUploadDetails.uploadService, 'cover', imageUploadDetails.nftStorageApiKey as string, imageUploadDetails.pinataApiKey as string, imageUploadDetails.pinataSecretKey as string, ) const imageUriWithBase = `ipfs://${imageUri}/${(imageUploadDetails.assetFile as File).name}` setTokenImageUri(imageUriWithBase) const coverImageUri = await upload( collectionDetails?.imageFile as File[], imageUploadDetails.uploadService, 'cover', imageUploadDetails.nftStorageApiKey as string, imageUploadDetails.pinataApiKey as string, imageUploadDetails.pinataSecretKey as string, ) const coverImageUriWithBase = `ipfs://${coverImageUri}/${(collectionDetails?.imageFile as File[])[0].name}` setCoverImageUrl(coverImageUriWithBase) setUploading(false) await instantiateOpenEditionMinter(imageUriWithBase, coverImageUriWithBase) } else if (imageUploadDetails?.uploadMethod === 'existing') { setTokenImageUri(imageUploadDetails.imageUrl as string) setCoverImageUrl(imageUploadDetails.coverImageUrl as string) await instantiateOpenEditionMinter( imageUploadDetails.imageUrl as string, imageUploadDetails.coverImageUrl as string, ) } } setCreationInProgress(false) setReadyToCreate(false) } catch (error: any) { toast.error(error.message, { style: { maxWidth: 'none' }, duration: 10000 }) addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() }) setReadyToCreate(false) setCreationInProgress(false) setUploading(false) } } const uploadForOffChainStorage = async (): Promise => { if (!offChainMetadataUploadDetails) throw new Error('Please select the asset and fill in the metadata') return new Promise((resolve, reject) => { upload( offChainMetadataUploadDetails.assetFiles, offChainMetadataUploadDetails.uploadService, 'assets', offChainMetadataUploadDetails.nftStorageApiKey as string, offChainMetadataUploadDetails.pinataApiKey as string, offChainMetadataUploadDetails.pinataSecretKey as string, ) .then((assetUri: string) => { const fileArray: File[] = [] const reader: FileReader = new FileReader() reader.onload = (e) => { const data: any = JSON.parse(e.target?.result as string) if ( getAssetType(offChainMetadataUploadDetails.assetFiles[0].name) === 'audio' || getAssetType(offChainMetadataUploadDetails.assetFiles[0].name) === 'video' ) { data.animation_url = `ipfs://${assetUri}/${offChainMetadataUploadDetails.assetFiles[0].name}` } data.image = `ipfs://${assetUri}/${offChainMetadataUploadDetails.assetFiles[0].name}` const metadataFileBlob = new Blob([JSON.stringify(data)], { type: 'application/json', }) console.log('Name: ', (offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name) const updatedMetadataFile = new File( [metadataFileBlob], (offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.substring( 0, (offChainMetadataUploadDetails.openEditionMinterMetadataFile as File).name.lastIndexOf('.'), ), { type: 'application/json', }, ) fileArray.push(updatedMetadataFile) } reader.onloadend = () => { upload( fileArray, offChainMetadataUploadDetails.uploadService, 'metadata', offChainMetadataUploadDetails.nftStorageApiKey as string, offChainMetadataUploadDetails.pinataApiKey as string, offChainMetadataUploadDetails.pinataSecretKey as string, ) .then(resolve) .catch(reject) } console.log('File: ', offChainMetadataUploadDetails.openEditionMinterMetadataFile) reader.readAsText(offChainMetadataUploadDetails.openEditionMinterMetadataFile as File, 'utf8') }) .catch(reject) }) } const instantiateOpenEditionMinter = async (uri: string, coverImageUri: string) => { if (!wallet.initialized) throw new Error('Wallet not connected') if (!openEditionFactoryContract) throw new Error('Contract not found') if (!openEditionMinterContract) throw new Error('Contract not found') let royaltyInfo = null if (royaltyDetails?.royaltyType === 'new') { royaltyInfo = { payment_address: royaltyDetails.paymentAddress.trim(), share: (Number(royaltyDetails.share) / 100).toString(), } } const msg = { create_minter: { init_msg: { nft_data: { nft_data_type: metadataStorageMethod === 'off-chain' ? 'off_chain_metadata' : 'on_chain_metadata', token_uri: metadataStorageMethod === 'off-chain' ? uri : null, extension: metadataStorageMethod === 'on-chain' ? { image: uri, name: onChainMetadataInputDetails?.name, description: onChainMetadataInputDetails?.description, attributes: onChainMetadataInputDetails?.attributes, external_url: onChainMetadataInputDetails?.external_url, animation_url: imageUploadDetails?.uploadMethod === 'existing' ? onChainMetadataInputDetails?.animation_url : getAssetType(imageUploadDetails?.assetFile?.name as string) === 'video' ? uri : undefined, youtube_url: onChainMetadataInputDetails?.youtube_url, } : null, }, start_time: mintingDetails?.startTime, end_time: mintingDetails?.endTime, mint_price: { amount: (Number(mintingDetails?.unitPrice) * 1000000).toString(), denom: 'ustars', }, per_address_limit: mintingDetails?.perAddressLimit, payment_address: mintingDetails?.paymentAddress || null, }, collection_params: { code_id: collectionDetails?.updatable ? SG721_UPDATABLE_CODE_ID : SG721_CODE_ID, name: collectionDetails?.name, symbol: collectionDetails?.symbol, info: { creator: wallet.address, description: collectionDetails?.description, image: coverImageUri, explicit_content: collectionDetails?.explicit || false, royalty_info: royaltyInfo, start_trading_time: collectionDetails?.startTradingTime || null, }, }, }, } console.log('msg: ', msg) const payload: OpenEditionFactoryDispatchExecuteArgs = { contract: collectionDetails?.updatable ? OPEN_EDITION_UPDATABLE_FACTORY_ADDRESS : OPEN_EDITION_FACTORY_ADDRESS, messages: openEditionFactoryMessages, txSigner: wallet.address, msg, funds: [ coin( collectionDetails?.updatable ? (openEditionMinterUpdatableCreationFee as string) : (openEditionMinterCreationFee as string), 'ustars', ), ], updatable: collectionDetails?.updatable, } await openEditionFactoryDispatchExecute(payload) .then((data) => { setTransactionHash(data.transactionHash) setOpenEditionMinterContractAddress(data.openEditionMinterAddress) setSg721ContractAddress(data.sg721Address) }) .catch((error) => { toast.error(error.message, { style: { maxWidth: 'none' } }) addLogItem({ id: uid(), message: error.message, type: 'Error', timestamp: new Date() }) setUploading(false) setCreationInProgress(false) }) } useEffect(() => { if (minterType !== 'openEdition') { setTransactionHash(null) setOpenEditionMinterContractAddress(null) setSg721ContractAddress(null) setCreationInProgress(false) setUploading(false) } }, [minterType]) useEffect(() => { const data: OpenEditionMinterCreatorDataProps = { metadataStorageMethod, openEditionMinterContractAddress, sg721ContractAddress, transactionHash, } onChange(data) // eslint-disable-next-line react-hooks/exhaustive-deps }, [metadataStorageMethod, openEditionMinterContractAddress, sg721ContractAddress, transactionHash]) return (
{/* TODO: Cancel once we're able to index on-chain metadata */}
{ setMetadataStorageMethod('off-chain') }} type="radio" value="Off Chain" />
{ setMetadataStorageMethod('on-chain') }} type="radio" value="On Chain" />
) }