diff --git a/.env.example b/.env.example index dfc15fa..238f510 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -APP_VERSION=0.4.5 +APP_VERSION=0.4.8 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS -NEXT_PUBLIC_SG721_CODE_ID=793 -NEXT_PUBLIC_VENDING_MINTER_CODE_ID=275 -NEXT_PUBLIC_VENDING_FACTORY_ADDRESS="stars1s48apjumprma0d64ge88dmu7lr8exu02tlw90xxupgds0s25gfasx447dn" +NEXT_PUBLIC_SG721_CODE_ID=1702 +NEXT_PUBLIC_VENDING_MINTER_CODE_ID=1701 +NEXT_PUBLIC_VENDING_FACTORY_ADDRESS="stars1xz4d6wzxqn3udgsm5qnr78y032xng4r2ycv7aw6mjtsuw59s2n9s93ec0v" NEXT_PUBLIC_BASE_FACTORY_ADDRESS="stars1c6juqgd7cm80afpmuszun66rl9zdc4kgfht8fk34tfq3zk87l78sdxngzv" NEXT_PUBLIC_SG721_NAME_ADDRESS="stars1fx74nkqkw2748av8j7ew7r3xt9cgjqduwn8m0ur5lhe49uhlsasszc5fhr" NEXT_PUBLIC_BASE_MINTER_CODE_ID=613 diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 2b2e552..b243e18 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -7,6 +7,7 @@ export interface TooltipProps extends ComponentProps<'div'> { label: ReactNode children: ReactElement placement?: 'top' | 'bottom' | 'left' | 'right' + backgroundColor?: string } export const Tooltip = ({ label, children, ...props }: TooltipProps) => { @@ -33,7 +34,11 @@ export const Tooltip = ({ label, children, ...props }: TooltipProps) => {
diff --git a/components/badges/actions/Action.tsx b/components/badges/actions/Action.tsx index 0d8b69f..cd211a3 100644 --- a/components/badges/actions/Action.tsx +++ b/components/badges/actions/Action.tsx @@ -3,21 +3,25 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ // import { AirdropUpload } from 'components/AirdropUpload' import { toUtf8 } from '@cosmjs/encoding' +import { Alert } from 'components/Alert' import type { DispatchExecuteArgs } from 'components/badges/actions/actions' import { dispatchExecute, isEitherType, previewExecutePayload } from 'components/badges/actions/actions' import { ActionsCombobox } from 'components/badges/actions/Combobox' import { useActionsComboboxState } from 'components/badges/actions/Combobox.hooks' import { Button } from 'components/Button' +import { Conditional } from 'components/Conditional' import { FormControl } from 'components/FormControl' import { FormGroup } from 'components/FormGroup' +import { AddressList } from 'components/forms/AddressList' +import { useAddressListState } from 'components/forms/AddressList.hooks' import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' import { MetadataAttributes } from 'components/forms/MetadataAttributes' import { useMetadataAttributesState } from 'components/forms/MetadataAttributes.hooks' import { JsonPreview } from 'components/JsonPreview' import { TransactionHash } from 'components/TransactionHash' +import { WhitelistUpload } from 'components/WhitelistUpload' import { useWallet } from 'contexts/wallet' import type { Badge, BadgeHubInstance } from 'contracts/badgeHub' -import * as crypto from 'crypto' import sizeof from 'object-sizeof' import type { FormEvent } from 'react' import { useEffect, useState } from 'react' @@ -25,11 +29,12 @@ import { toast } from 'react-hot-toast' import { FaArrowRight } from 'react-icons/fa' import { useMutation } from 'react-query' import * as secp256k1 from 'secp256k1' -import { sha256 } from 'utils/hash' +import { generateKeyPairs, sha256 } from 'utils/hash' +import { isValidAddress } from 'utils/isValidAddress' import { resolveAddress } from 'utils/resolveAddress' import { BadgeAirdropListUpload } from '../../BadgeAirdropListUpload' -import { AddressInput, TextInput } from '../../forms/FormInput' +import { AddressInput, NumberInput, TextInput } from '../../forms/FormInput' import type { MintRule } from '../creation/ImageUploadDetails' interface BadgeActionsProps { @@ -52,8 +57,10 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage const [resolvedOwnerAddress, setResolvedOwnerAddress] = useState('') const [editFee, setEditFee] = useState(undefined) const [triggerDispatch, setTriggerDispatch] = useState(false) - const [keyPairs, setKeyPairs] = useState([]) + const [keyPairs, setKeyPairs] = useState<{ publicKey: string; privateKey: string }[]>([]) const [signature, setSignature] = useState('') + const [ownerList, setOwnerList] = useState([]) + const [numberOfKeys, setNumberOfKeys] = useState(0) const actionComboboxState = useActionsComboboxState() const type = actionComboboxState.value?.id @@ -147,18 +154,26 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage defaultValue: wallet.address, }) - const pubkeyState = useInputState({ - id: 'pubkey', - name: 'pubkey', - title: 'Pubkey', - subtitle: 'The public key to check whether it can be used to mint a badge', + const ownerListState = useAddressListState() + + const pubKeyState = useInputState({ + id: 'pubKey', + name: 'pubKey', + title: 'Public Key', + subtitle: + type === 'mint_by_keys' + ? 'The whitelisted public key authorized to mint a badge' + : 'The public key to check whether it can be used to mint a badge', }) const privateKeyState = useInputState({ id: 'privateKey', name: 'privateKey', title: 'Private Key', - subtitle: 'The private key that was generated during badge creation', + subtitle: + type === 'mint_by_keys' + ? 'The corresponding private key for the whitelisted public key' + : 'The private key that was generated during badge creation', }) const nftState = useInputState({ @@ -172,13 +187,16 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage id: 'limit', name: 'limit', title: 'Limit', - subtitle: 'Number of keys/owners to execute the action for', + subtitle: 'Number of keys/owners to execute the action for (0 for all)', }) const showMetadataField = isEitherType(type, ['edit_badge']) - const showOwnerField = isEitherType(type, ['mint_by_key', 'mint_by_keys']) + const showOwnerField = isEitherType(type, ['mint_by_key', 'mint_by_keys', 'mint_by_minter']) const showPrivateKeyField = isEitherType(type, ['mint_by_key', 'mint_by_keys', 'airdrop_by_key']) const showAirdropFileField = isEitherType(type, ['airdrop_by_key']) + const showOwnerList = isEitherType(type, ['mint_by_minter']) + const showPubKeyField = isEitherType(type, ['mint_by_keys']) + const showLimitState = isEitherType(type, ['purge_keys', 'purge_owners']) const payload: DispatchExecuteArgs = { badge: { @@ -231,11 +249,18 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage id: badgeId, editFee, owner: resolvedOwnerAddress, - pubkey: pubkeyState.value, + pubkey: pubKeyState.value, signature, - keys: [], - limit: limitState.value, - owners: [], + keys: keyPairs.map((keyPair) => keyPair.publicKey), + limit: limitState.value || undefined, + owners: [ + ...new Set( + ownerListState.values + .map((a) => a.address.trim()) + .filter((address) => address !== '' && isValidAddress(address.trim()) && address.startsWith('stars')) + .concat(ownerList), + ), + ], recipients: airdropAllocationArray, privateKey: privateKeyState.value, nft: nftState.value, @@ -355,6 +380,21 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage handleGenerateSignature(badgeId, resolvedOwnerAddress, privateKeyState.value) }, [privateKeyState.value, resolvedOwnerAddress]) + useEffect(() => { + if (numberOfKeys > 0) { + setKeyPairs(generateKeyPairs(numberOfKeys)) + } + }, [numberOfKeys]) + + const handleDownloadKeys = () => { + const element = document.createElement('a') + const file = new Blob([JSON.stringify(keyPairs)], { type: 'text/plain' }) + element.href = URL.createObjectURL(file) + element.download = `badge-${badgeId.toString()}-keys.json` + document.body.appendChild(element) + element.click() + } + const { isLoading, mutate } = useMutation( async (event: FormEvent) => { if (!wallet.client) { @@ -467,19 +507,6 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage } } - const handleGenerateKeys = (amount: number) => { - for (let i = 0; i < amount; i++) { - let privKey: Buffer - do { - privKey = crypto.randomBytes(32) - } while (!secp256k1.privateKeyVerify(privKey)) - - const privateKey = privKey.toString('hex') - const publicKey = Buffer.from(secp256k1.publicKeyCreate(privKey)).toString('hex') - keyPairs.push(publicKey.concat(',', privateKey)) - } - } - return (
@@ -515,7 +542,65 @@ export const BadgeActions = ({ badgeHubContractAddress, badgeId, badgeHubMessage title="Owner" /> )} + {showPubKeyField && } {showPrivateKeyField && } + {showLimitState && } + + + This action is only available if the badge with the specified id is either minted out or expired. + + + + +
+
+
+ Number of Keys + + The number of public keys to be whitelisted for minting badges + +
+ setNumberOfKeys(Number(e.target.value))} + required + type="number" + value={numberOfKeys} + /> +
+
+
+ + 0 && type === 'add_keys'}> + +
+ + Make sure to download the whitelisted public keys together with their private key counterparts. + + +
+
+
+ + +
+ + + You may optionally choose a text file of additional owner addresses. + + +
+
{showAirdropFileField && ( void uploadMethod: UploadMethod | undefined mintRule: MintRule + metadataSize: number } export interface BadgeDetailsDataProps { @@ -38,10 +44,14 @@ export interface BadgeDetailsDataProps { youtube_url?: string } -export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { +export const BadgeDetails = ({ metadataSize, onChange }: BadgeDetailsProps) => { const wallet = useWallet() const [timestamp, setTimestamp] = useState(undefined) const [transferrable, setTransferrable] = useState(false) + const [metadataFile, setMetadataFile] = useState() + const [metadataFeeRate, setMetadataFeeRate] = useState(0) + + const metadataFileRef = useRef(null) const managerState = useInputState({ id: 'manager-address', @@ -109,6 +119,79 @@ export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { subtitle: 'YouTube URL for the badge', }) + const parseMetadata = async () => { + try { + let parsedMetadata: any + if (metadataFile) { + attributesState.reset() + parsedMetadata = JSON.parse(await metadataFile.text()) + + if (!parsedMetadata.attributes || parsedMetadata.attributes.length === 0) { + attributesState.add({ + trait_type: '', + value: '', + }) + } else { + for (let i = 0; i < parsedMetadata.attributes.length; i++) { + attributesState.add({ + trait_type: parsedMetadata.attributes[i].trait_type, + value: parsedMetadata.attributes[i].value, + }) + } + } + nameState.onChange(parsedMetadata.name ? parsedMetadata.name : '') + descriptionState.onChange(parsedMetadata.description ? parsedMetadata.description : '') + externalUrlState.onChange(parsedMetadata.external_url ? parsedMetadata.external_url : '') + youtubeUrlState.onChange(parsedMetadata.youtube_url ? parsedMetadata.youtube_url : '') + animationUrlState.onChange(parsedMetadata.animation_url ? parsedMetadata.animation_url : '') + backgroundColorState.onChange(parsedMetadata.background_color ? parsedMetadata.background_color : '') + imageDataState.onChange(parsedMetadata.image_data ? parsedMetadata.image_data : '') + } else { + attributesState.reset() + nameState.onChange('') + descriptionState.onChange('') + externalUrlState.onChange('') + youtubeUrlState.onChange('') + animationUrlState.onChange('') + backgroundColorState.onChange('') + imageDataState.onChange('') + } + } catch (error) { + toast.error('Error parsing metadata file: Invalid JSON format.') + if (metadataFileRef.current) metadataFileRef.current.value = '' + setMetadataFile(undefined) + } + } + + const selectMetadata = (event: ChangeEvent) => { + setMetadataFile(undefined) + if (event.target.files === null) return + + let selectedFile: File + const reader = new FileReader() + reader.onload = (e) => { + if (!event.target.files) return toast.error('No file selected.') + if (!e.target?.result) return toast.error('Error parsing file.') + selectedFile = new File([e.target.result], event.target.files[0].name, { type: 'application/json' }) + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (event.target.files[0]) reader.readAsArrayBuffer(event.target.files[0]) + else return toast.error('No file selected.') + reader.onloadend = () => { + if (!event.target.files) return toast.error('No file selected.') + setMetadataFile(selectedFile) + } + } + + useEffect(() => { + void parseMetadata() + if (!metadataFile) + attributesState.add({ + trait_type: '', + value: '', + }) + }, [metadataFile]) + useEffect(() => { try { const data: BadgeDetailsDataProps = { @@ -155,13 +238,25 @@ export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { ]) useEffect(() => { - if (attributesState.values.length === 0) - attributesState.add({ - trait_type: '', - value: '', - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const retrieveFeeRate = async () => { + try { + if (wallet.client) { + const feeRateRaw = await wallet.client.queryContractRaw( + BADGE_HUB_ADDRESS, + toUtf8(Buffer.from(Buffer.from('fee_rate').toString('hex'), 'hex').toString()), + ) + console.log('Fee Rate Raw: ', feeRateRaw) + const feeRate = JSON.parse(new TextDecoder().decode(feeRateRaw as Uint8Array)) + setMetadataFeeRate(Number(feeRate.metadata)) + } + } catch (error) { + toast.error('Error retrieving metadata fee rate.') + setMetadataFeeRate(0) + console.log('Error retrieving fee rate: ', error) + } + } + void retrieveFeeRate() + }, [wallet.client]) return (
@@ -172,19 +267,35 @@ export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { + setTimestamp(date)} value={timestamp} /> -
- +
+
+ +
+ + +
+
Fee Estimate:
+ {(metadataSize * Number(metadataFeeRate)) / 1000000} stars +
+
+
@@ -197,6 +308,40 @@ export const BadgeDetails = ({ onChange }: BadgeDetailsProps) => { title="Traits" />
+
+ +
+ +
+ +
+
+
+
diff --git a/components/badges/creation/ImageUploadDetails.tsx b/components/badges/creation/ImageUploadDetails.tsx index fb8d725..397f541 100644 --- a/components/badges/creation/ImageUploadDetails.tsx +++ b/components/badges/creation/ImageUploadDetails.tsx @@ -172,7 +172,7 @@ export const ImageUploadDetails = ({ onChange, mintRule }: ImageUploadDetailsPro -
+

@@ -187,8 +187,17 @@ export const ImageUploadDetails = ({ onChange, mintRule }: ImageUploadDetailsPro {' '} and upload your image manually to get an image URL for your badge.

-
- +
+ + +
+ badge-preview +
+
@@ -252,31 +261,33 @@ export const ImageUploadDetails = ({ onChange, mintRule }: ImageUploadDetailsPro
-
-
- -
- +
+
+ +
+ > + +
diff --git a/components/collections/actions/Action.tsx b/components/collections/actions/Action.tsx index f25590a..9cdfad6 100644 --- a/components/collections/actions/Action.tsx +++ b/components/collections/actions/Action.tsx @@ -124,8 +124,8 @@ export const CollectionActions = ({ const priceState = useNumberInputState({ id: 'update-mint-price', name: 'updateMintPrice', - title: 'Update Mint Price', - subtitle: 'New minting price in STARS', + title: type === 'update_discount_price' ? 'Discount Price' : 'Update Mint Price', + subtitle: type === 'update_discount_price' ? 'New discount price in STARS' : 'New minting price in STARS', }) const descriptionState = useInputState({ @@ -161,7 +161,7 @@ export const CollectionActions = ({ name: 'royaltyShare', title: 'Share Percentage', subtitle: 'Percentage of royalties to be paid', - placeholder: '8%', + placeholder: '5%', }) const showTokenUriField = isEitherType(type, ['mint_token_uri', 'update_token_metadata']) @@ -185,7 +185,7 @@ export const CollectionActions = ({ 'batch_mint_for', ]) const showAirdropFileField = type === 'airdrop' - const showPriceField = type === 'update_mint_price' + const showPriceField = isEitherType(type, ['update_mint_price', 'update_discount_price']) const showDescriptionField = type === 'update_collection_info' const showImageField = type === 'update_collection_info' const showExternalLinkField = type === 'update_collection_info' @@ -379,8 +379,8 @@ export const CollectionActions = ({ {showTokenIdField && } {showTokenIdListField && } {showBaseUriField && } - {showNumberOfTokensField && } - {showPriceField && } + {showNumberOfTokensField && } + {showPriceField && } {showDescriptionField && } {showImageField && } {showExternalLinkField && } diff --git a/components/collections/actions/actions.ts b/components/collections/actions/actions.ts index 5132da4..640861c 100644 --- a/components/collections/actions/actions.ts +++ b/components/collections/actions/actions.ts @@ -13,6 +13,8 @@ export const ACTION_TYPES = [ 'mint_token_uri', 'purge', 'update_mint_price', + 'update_discount_price', + 'remove_discount_price', 'mint_to', 'mint_for', 'batch_mint', @@ -90,16 +92,21 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [ name: 'Mint', description: `Mint a token`, }, - { - id: 'purge', - name: 'Purge', - description: `Purge`, - }, { id: 'update_mint_price', name: 'Update Mint Price', description: `Update mint price`, }, + { + id: 'update_discount_price', + name: 'Update Discount Price', + description: `Update discount price`, + }, + { + id: 'remove_discount_price', + name: 'Remove Discount Price', + description: `Remove discount price`, + }, { id: 'mint_to', name: 'Mint To', @@ -185,6 +192,11 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [ name: 'Burn Remaining Tokens', description: 'Burn remaining tokens', }, + { + id: 'purge', + name: 'Purge', + description: `Purge`, + }, ] export const SG721_UPDATABLE_ACTION_LIST: ActionListItem[] = [ @@ -226,6 +238,8 @@ export type DispatchExecuteArgs = { | { type: Select<'mint_token_uri'>; tokenUri: string } | { type: Select<'purge'> } | { type: Select<'update_mint_price'>; price: string } + | { type: Select<'update_discount_price'>; price: string } + | { type: Select<'remove_discount_price'> } | { type: Select<'mint_to'>; recipient: string } | { type: Select<'mint_for'>; recipient: string; tokenId: number } | { type: Select<'batch_mint'>; recipient: string; batchNumber: number } @@ -266,6 +280,12 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { case 'update_mint_price': { return vendingMinterMessages.updateMintPrice(txSigner, args.price) } + case 'update_discount_price': { + return vendingMinterMessages.updateDiscountPrice(txSigner, args.price) + } + case 'remove_discount_price': { + return vendingMinterMessages.removeDiscountPrice(txSigner) + } case 'mint_to': { return vendingMinterMessages.mintTo(txSigner, args.recipient) } @@ -353,6 +373,12 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'update_mint_price': { return vendingMinterMessages(minterContract)?.updateMintPrice(args.price) } + case 'update_discount_price': { + return vendingMinterMessages(minterContract)?.updateDiscountPrice(args.price) + } + case 'remove_discount_price': { + return vendingMinterMessages(minterContract)?.removeDiscountPrice() + } case 'mint_to': { return vendingMinterMessages(minterContract)?.mintTo(args.recipient) } diff --git a/components/collections/creation/RoyaltyDetails.tsx b/components/collections/creation/RoyaltyDetails.tsx index 5ed298e..0d873e2 100644 --- a/components/collections/creation/RoyaltyDetails.tsx +++ b/components/collections/creation/RoyaltyDetails.tsx @@ -36,7 +36,7 @@ export const RoyaltyDetails = ({ onChange }: RoyaltyDetailsProps) => { name: 'royaltyShare', title: 'Share Percentage', subtitle: 'Percentage of royalties to be paid', - placeholder: '8%', + placeholder: '5%', }) useEffect(() => { diff --git a/components/forms/MetadataAttributes.tsx b/components/forms/MetadataAttributes.tsx index acfac6e..d911d23 100644 --- a/components/forms/MetadataAttributes.tsx +++ b/components/forms/MetadataAttributes.tsx @@ -73,9 +73,10 @@ export function MetadataAttribute({ id, isLast, onAdd, onChange, onRemove, defau }, [traitTypeState.value, traitValueState.value, id]) return ( -
+
+
+ +
-
- - +
+ Badge ID:{` ${badgeId as string}`} +
+ Private Key: + + + +
+ Transaction Hash: {' '} + + + {transactionHash} + + + + + {transactionHash} + + +
+
+
+ + You may click{' '} + + here + {' '} + or scan the QR code to claim a badge. + +
+
+ + You may download the QR code or copy the claim URL to share with others. + +
+
+ + + + +
Badge ID:{` ${badgeId as string}`}
- Private Key: - - - + {transactionHash} + + + + + {transactionHash} + + +
+ +
+ + Make sure to download the whitelisted keys added during badge creation. + + +
+
+
+
+ + You may click{' '} + + here + {' '} + and select Actions {'>'} Add Keys to add (additional) whitelisted keys or select Actions {'>'}{' '} + Mint by Keys to use one of the keys to mint a badge. + +
+
+
+
+
+ + + +
+ Badge ID:{` ${badgeId as string}`} +
+ Designated Minter Address: {` ${resolvedMinterAddress}`}
Transaction Hash: {' '} @@ -339,22 +571,19 @@ const BadgeCreationPage: NextPage = () => { here {' '} - or scan the QR code to claim a badge. + and select Actions {'>'} Mint By Minter to mint a badge.
-
- You may download the QR code or copy the claim URL to share with others.
-
-
- + +
@@ -374,81 +603,136 @@ const BadgeCreationPage: NextPage = () => { mintRule !== 'by_key' ? 'bg-stargaze/5 hover:bg-stargaze/80' : 'hover:bg-white/5', )} > - + +
- + +
- + +
+ +
+ + +
+
-
- - -
+ +
+
+
+ Number of Keys + + The number of key pairs to be whitelisted for post-creation access control + +
+ setNumberOfKeys(Number(e.target.value))} + required + type="number" + value={numberOfKeys} + /> +
+
+
+ + +
+ +
+
{ {myBadges.map((badge: any, index: any) => { return ( - +
Cover {
-

{badge.name}

+

+ {badge.name ? badge.name : 'No name provided.'} +

Badge ID: {badge.tokenId}

- - {badge.description} + + {badge.description ? badge.description : 'No description provided.'} {/*
*/} {/* */} diff --git a/pages/collections/create.tsx b/pages/collections/create.tsx index d200120..902e7ea 100644 --- a/pages/collections/create.tsx +++ b/pages/collections/create.tsx @@ -754,12 +754,17 @@ const CollectionCreationPage: NextPage = () => { mintingDetails.perAddressLimit > mintingDetails.numTokens ) throw new Error('Invalid limit for tokens per address') + if (mintingDetails.numTokens < 100 && mintingDetails.perAddressLimit > 3) + throw new Error( + 'Invalid limit for tokens per address. Tokens per address limit cannot exceed 3 for collections with less than 100 tokens in total.', + ) if ( - mintingDetails.numTokens > 100 && - mintingDetails.numTokens < 100 * mintingDetails.perAddressLimit && - mintingDetails.perAddressLimit > mintingDetails.numTokens / 100 + mintingDetails.numTokens >= 100 && + mintingDetails.perAddressLimit > Math.ceil((mintingDetails.numTokens / 100) * 3) ) - throw new Error('Invalid limit for tokens per address. The limit cannot exceed 1% of the total number of tokens.') + throw new Error( + 'Invalid limit for tokens per address. Tokens per address limit cannot exceed 3% of the total number of tokens in the collection.', + ) if (mintingDetails.startTime === '') throw new Error('Start time is required') if (Number(mintingDetails.startTime) < new Date().getTime() * 1000000) throw new Error('Invalid start time') } @@ -776,15 +781,25 @@ const CollectionCreationPage: NextPage = () => { const whitelistStartDate = new Date(Number(config?.start_time) / 1000000) throw Error(`Whitelist start time (${whitelistStartDate.toLocaleString()}) does not match minting start time`) } - if ( - mintingDetails?.numTokens && - config?.per_address_limit && - mintingDetails.numTokens > 100 && - Number(config.per_address_limit) > mintingDetails.numTokens / 100 - ) - throw Error( - `Invalid limit for tokens per address (${config.per_address_limit} tokens). The limit cannot exceed 1% of the total number of tokens.`, - ) + + if (mintingDetails?.numTokens && config?.per_address_limit) { + if (mintingDetails.numTokens >= 100 && Number(config.per_address_limit) > 50) { + throw Error( + `Invalid limit for tokens per address (${config.per_address_limit} tokens). Tokens per address limit cannot exceed 50 regardless of the total number of tokens.`, + ) + } else if ( + mintingDetails.numTokens >= 100 && + Number(config.per_address_limit) > Math.ceil((mintingDetails.numTokens / 100) * 3) + ) { + throw Error( + `Invalid limit for tokens per address (${config.per_address_limit} tokens). Tokens per address limit cannot exceed 3% of the total number of tokens in the collection.`, + ) + } else if (mintingDetails.numTokens < 100 && Number(config.per_address_limit) > 3) { + throw Error( + `Invalid limit for tokens per address (${config.per_address_limit} tokens). Tokens per address limit cannot exceed 3 for collections with less than 100 tokens in total.`, + ) + } + } } } else if (whitelistDetails.whitelistType === 'new') { if (whitelistDetails.members?.length === 0) throw new Error('Whitelist member list cannot be empty') @@ -801,15 +816,24 @@ const CollectionCreationPage: NextPage = () => { throw new Error('Whitelist start time cannot be later than whitelist end time') if (Number(whitelistDetails.startTime) !== Number(mintingDetails?.startTime)) throw new Error('Whitelist start time must be the same as the minting start time') - if ( - mintingDetails?.numTokens && - whitelistDetails.perAddressLimit && - mintingDetails.numTokens > 100 && - whitelistDetails.perAddressLimit > mintingDetails.numTokens / 100 - ) - throw Error( - `Invalid limit for tokens per address (${whitelistDetails.perAddressLimit} tokens). The limit cannot exceed 1% of the total number of tokens.`, - ) + if (whitelistDetails.perAddressLimit && mintingDetails?.numTokens) { + if (mintingDetails.numTokens >= 100 && whitelistDetails.perAddressLimit > 50) { + throw Error( + `Invalid limit for tokens per address. Tokens per address limit cannot exceed 50 regardless of the total number of tokens.`, + ) + } else if ( + mintingDetails.numTokens >= 100 && + whitelistDetails.perAddressLimit > Math.ceil((mintingDetails.numTokens / 100) * 3) + ) { + throw Error( + `Invalid limit for tokens per address. Tokens per address limit cannot exceed 3% of the total number of tokens in the collection.`, + ) + } else if (mintingDetails.numTokens < 100 && whitelistDetails.perAddressLimit > 3) { + throw Error( + `Invalid limit for tokens per address. Tokens per address limit cannot exceed 3 for collections with less than 100 tokens in total.`, + ) + } + } } } diff --git a/pages/contracts/badgeHub/execute.tsx b/pages/contracts/badgeHub/execute.tsx index 4980e61..e078be8 100644 --- a/pages/contracts/badgeHub/execute.tsx +++ b/pages/contracts/badgeHub/execute.tsx @@ -1,17 +1,22 @@ /* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-nested-ternary */ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { toUtf8 } from '@cosmjs/encoding' +import clsx from 'clsx' import { Alert } from 'components/Alert' +import type { MintRule } from 'components/badges/creation/ImageUploadDetails' import { Button } from 'components/Button' import { Conditional } from 'components/Conditional' import { ContractPageHeader } from 'components/ContractPageHeader' import { ExecuteCombobox } from 'components/contracts/badgeHub/ExecuteCombobox' import { useExecuteComboboxState } from 'components/contracts/badgeHub/ExecuteCombobox.hooks' import { FormControl } from 'components/FormControl' +import { AddressList } from 'components/forms/AddressList' +import { useAddressListState } from 'components/forms/AddressList.hooks' import { AddressInput, NumberInput } from 'components/forms/FormInput' import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' import { InputDateTime } from 'components/InputDateTime' @@ -20,6 +25,7 @@ import { LinkTabs } from 'components/LinkTabs' import { badgeHubLinkTabs } from 'components/LinkTabs.data' import { Tooltip } from 'components/Tooltip' import { TransactionHash } from 'components/TransactionHash' +import { WhitelistUpload } from 'components/WhitelistUpload' import { useContracts } from 'contexts/contracts' import { useWallet } from 'contexts/wallet' import type { Badge } from 'contracts/badgeHub' @@ -40,7 +46,8 @@ import { useMutation } from 'react-query' import * as secp256k1 from 'secp256k1' import { copy } from 'utils/clipboard' import { NETWORK } from 'utils/constants' -import { sha256 } from 'utils/hash' +import { generateKeyPairs, sha256 } from 'utils/hash' +import { isValidAddress } from 'utils/isValidAddress' import { withMetadata } from 'utils/layout' import { links } from 'utils/links' import { resolveAddress } from 'utils/resolveAddress' @@ -62,10 +69,15 @@ const BadgeHubExecutePage: NextPage = () => { const [createdBadgeId, setCreatedBadgeId] = useState(null) const [createdBadgeKey, setCreatedBadgeKey] = useState(undefined) const [resolvedOwnerAddress, setResolvedOwnerAddress] = useState('') + const [resolvedMinterAddress, setResolvedMinterAddress] = useState('') const [signature, setSignature] = useState('') + const [ownerList, setOwnerList] = useState([]) const [editFee, setEditFee] = useState(undefined) const [triggerDispatch, setTriggerDispatch] = useState(false) const qrRef = useRef(null) + const [numberOfKeys, setNumberOfKeys] = useState(0) + const [keyPairs, setKeyPairs] = useState<{ publicKey: string; privateKey: string }[]>([]) + const [mintRule, setMintRule] = useState('by_key') const comboboxState = useExecuteComboboxState() const type = comboboxState.value?.id @@ -173,20 +185,26 @@ const BadgeHubExecutePage: NextPage = () => { name: 'owner', title: 'Owner', subtitle: 'The owner of the badge', + defaultValue: wallet.address, }) + const ownerListState = useAddressListState() + const pubkeyState = useInputState({ id: 'pubkey', name: 'pubkey', title: 'Pubkey', - subtitle: 'The public key for the badge', + subtitle: 'The whitelisted public key authorized to mint a badge', }) const privateKeyState = useInputState({ id: 'privateKey', name: 'privateKey', title: 'Private Key', - subtitle: 'The private key generated during badge creation', + subtitle: + type === 'mint_by_keys' + ? 'The corresponding private key for the whitelisted public key' + : 'The private key that was generated during badge creation', }) const nftState = useInputState({ @@ -200,15 +218,34 @@ const BadgeHubExecutePage: NextPage = () => { id: 'limit', name: 'limit', title: 'Limit', - subtitle: 'Number of keys/owners to execute the action for', + subtitle: 'Number of keys/owners to execute the action for (0 for all)', + }) + + const designatedMinterState = useInputState({ + id: 'designatedMinter', + name: 'designatedMinter', + title: 'Minter Address', + subtitle: 'The address of the designated minter for this badge', + defaultValue: wallet.address, }) const showBadgeField = type === 'create_badge' const showMetadataField = isEitherType(type, ['create_badge', 'edit_badge']) - const showIdField = isEitherType(type, ['edit_badge', 'mint_by_key']) + const showIdField = isEitherType(type, [ + 'edit_badge', + 'add_keys', + 'purge_keys', + 'purge_owners', + 'mint_by_key', + 'mint_by_keys', + 'mint_by_minter', + ]) + const showLimitField = isEitherType(type, ['purge_keys', 'purge_owners']) const showNFTField = type === 'set_nft' - const showOwnerField = type === 'mint_by_key' - const showPrivateKeyField = type === 'mint_by_key' + const showOwnerField = isEitherType(type, ['mint_by_key', 'mint_by_keys']) + const showOwnerListField = isEitherType(type, ['mint_by_minter']) + const showPubkeyField = isEitherType(type, ['mint_by_keys']) + const showPrivateKeyField = isEitherType(type, ['mint_by_key', 'mint_by_keys']) const messages = useMemo(() => contract?.use(contractState.value), [contract, wallet.address, contractState.value]) const payload: DispatchExecuteArgs = { @@ -234,9 +271,16 @@ const BadgeHubExecutePage: NextPage = () => { youtube_url: youtubeUrlState.value || undefined, }, transferrable, - rule: { - by_key: keyState.value, - }, + rule: + mintRule === 'by_key' + ? { + by_key: keyState.value, + } + : mintRule === 'by_minter' + ? { + by_minter: resolvedMinterAddress, + } + : 'by_keys', expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, max_supply: maxSupplyState.value || undefined, }, @@ -260,12 +304,19 @@ const BadgeHubExecutePage: NextPage = () => { youtube_url: youtubeUrlState.value || undefined, }, id: badgeIdState.value, - owner: ownerState.value, + owner: resolvedOwnerAddress, pubkey: pubkeyState.value, signature, - keys: [], - limit: limitState.value, - owners: [], + keys: keyPairs.map((keyPair) => keyPair.publicKey), + limit: limitState.value || undefined, + owners: [ + ...new Set( + ownerListState.values + .map((a) => a.address.trim()) + .filter((address) => address !== '' && isValidAddress(address.trim()) && address.startsWith('stars')) + .concat(ownerList), + ), + ], nft: nftState.value, editFee, contract: contractState.value, @@ -337,6 +388,7 @@ const BadgeHubExecutePage: NextPage = () => { if (txHash) { setLastTx(txHash.split(':')[0]) setCreatedBadgeId(txHash.split(':')[1]) + badgeIdState.onChange(!isNaN(Number(txHash.split(':')[1])) ? Number(txHash.split(':')[1]) : 1) } } }, @@ -388,6 +440,14 @@ const BadgeHubExecutePage: NextPage = () => { link.click() }) } + const handleDownloadKeys = () => { + const element = document.createElement('a') + const file = new Blob([JSON.stringify(keyPairs)], { type: 'text/plain' }) + element.href = URL.createObjectURL(file) + element.download = `badge-${badgeIdState.value}-keys.json` + document.body.appendChild(element) + element.click() + } const copyClaimURL = async () => { const baseURL = NETWORK === 'testnet' ? 'https://badges.publicawesome.dev' : 'https://badges.stargaze.zone' @@ -409,6 +469,12 @@ const BadgeHubExecutePage: NextPage = () => { } } + useEffect(() => { + if (numberOfKeys > 0) { + setKeyPairs(generateKeyPairs(numberOfKeys)) + } + }, [numberOfKeys]) + useEffect(() => { if (privateKeyState.value.length === 64 && resolvedOwnerAddress) handleGenerateSignature(badgeIdState.value, resolvedOwnerAddress, privateKeyState.value) @@ -448,6 +514,15 @@ const BadgeHubExecutePage: NextPage = () => { void resolveOwnerAddress() }, [ownerState.value]) + const resolveMinterAddress = async () => { + await resolveAddress(designatedMinterState.value.trim(), wallet).then((resolvedAddress) => { + setResolvedMinterAddress(resolvedAddress) + }) + } + useEffect(() => { + void resolveMinterAddress() + }, [designatedMinterState.value]) + const resolveManagerAddress = async () => { await resolveAddress(managerState.value.trim(), wallet).then((resolvedAddress) => { setBadge({ @@ -472,9 +547,16 @@ const BadgeHubExecutePage: NextPage = () => { youtube_url: youtubeUrlState.value || undefined, }, transferrable, - rule: { - by_key: keyState.value, - }, + rule: + mintRule === 'by_key' + ? { + by_key: keyState.value, + } + : mintRule === 'by_minter' + ? { + by_minter: resolvedMinterAddress, + } + : 'by_keys', expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, max_supply: maxSupplyState.value || undefined, }) @@ -508,9 +590,16 @@ const BadgeHubExecutePage: NextPage = () => { youtube_url: youtubeUrlState.value || undefined, }, transferrable, - rule: { - by_key: keyState.value, - }, + rule: + mintRule === 'by_key' + ? { + by_key: keyState.value, + } + : mintRule === 'by_minter' + ? { + by_minter: resolvedMinterAddress, + } + : 'by_keys', expiry: timestamp ? timestamp.getTime() * 1000000 : undefined, max_supply: maxSupplyState.value || undefined, }) @@ -541,7 +630,7 @@ const BadgeHubExecutePage: NextPage = () => { /> - {showBadgeField && createdBadgeId && createdBadgeKey && ( + {showBadgeField && createdBadgeId && createdBadgeKey && mintRule === 'by_key' && (
@@ -598,14 +687,116 @@ const BadgeHubExecutePage: NextPage = () => {
)} + + {showBadgeField && createdBadgeId && mintRule === 'by_keys' && ( + +
+ Badge ID:{` ${createdBadgeId as string}`} +
+
+
+ + You may select Message Type {'>'} Add Keys to add whitelisted keys authorized to mint a badge. + +
+
+
+
+ )} + + {showBadgeField && createdBadgeId && mintRule === 'by_minter' && ( + +
+ Badge successfully created with ID:{` ${createdBadgeId as string}`} +
+ Designated Minter Address: {` ${resolvedMinterAddress}`} +
+
+
+ + You may select Message Type {'>'} Mint by Minter to mint badges using the designated minter wallet. + +
+
+
+
+ )} +
+ +
+
+
+ Mint Rule: +
+ { + setMintRule('by_key') + setCreatedBadgeId(null) + }} + type="radio" + /> + +
+
+ { + setMintRule('by_keys') + setCreatedBadgeId(null) + }} + type="radio" + /> + +
+
+ { + setMintRule('by_minter') + setCreatedBadgeId(null) + }} + type="radio" + /> + +
+
+
+
+
{showIdField && } {showBadgeField && } - {showBadgeField && } - {showBadgeField && } + {showBadgeField && mintRule === 'by_key' && } + {showBadgeField && mintRule === 'by_key' && } + {showBadgeField && mintRule === 'by_minter' && } {showMetadataField && (
Metadata @@ -636,6 +827,57 @@ const BadgeHubExecutePage: NextPage = () => { title="Owner" /> )} + +
+ + + You may optionally choose a text file of additional owner addresses. + + +
+
+ +
+
+
+ Number of Keys + + The number of public keys to be whitelisted for minting badges + +
+ setNumberOfKeys(Number(e.target.value))} + required + type="number" + value={numberOfKeys} + /> +
+
+
+ + 0 && type === 'add_keys'}> + +
+ + Make sure to download the whitelisted public keys together with their private key counterparts. + + +
+
+
+ {showLimitField && } + {showPubkeyField && } {showPrivateKeyField && } {showNFTField && }
diff --git a/pages/contracts/badgeHub/query.tsx b/pages/contracts/badgeHub/query.tsx index df7b56a..466b889 100644 --- a/pages/contracts/badgeHub/query.tsx +++ b/pages/contracts/badgeHub/query.tsx @@ -154,7 +154,7 @@ const BadgeHubQueryPage: NextPage = () => { ))} - + diff --git a/pages/contracts/baseMinter/instantiate.tsx b/pages/contracts/baseMinter/instantiate.tsx index 3e79f54..4bcaaa5 100644 --- a/pages/contracts/baseMinter/instantiate.tsx +++ b/pages/contracts/baseMinter/instantiate.tsx @@ -105,7 +105,7 @@ const BaseMinterInstantiatePage: NextPage = () => { name: 'royaltyShare', title: 'Share Percentage', subtitle: 'Percentage of royalties to be paid', - placeholder: '8%', + placeholder: '5%', }) const { data, isLoading, mutate } = useMutation( diff --git a/pages/contracts/vendingMinter/execute.tsx b/pages/contracts/vendingMinter/execute.tsx index de73e62..17787e2 100644 --- a/pages/contracts/vendingMinter/execute.tsx +++ b/pages/contracts/vendingMinter/execute.tsx @@ -55,8 +55,8 @@ const VendingMinterExecutePage: NextPage = () => { const priceState = useNumberInputState({ id: 'price', name: 'price', - title: 'Price', - subtitle: 'Enter the token price', + title: type === 'update_discount_price' ? 'Discount Price' : 'Price', + subtitle: type === 'update_discount_price' ? 'New discount price in STARS' : 'Enter the token price', }) const contractState = useInputState({ @@ -86,7 +86,7 @@ const VendingMinterExecutePage: NextPage = () => { const showLimitField = type === 'update_per_address_limit' const showTokenIdField = type === 'mint_for' const showRecipientField = isEitherType(type, ['mint_to', 'mint_for']) - const showPriceField = type === 'update_mint_price' + const showPriceField = isEitherType(type, ['update_mint_price', 'update_discount_price']) const messages = useMemo(() => contract?.use(contractState.value), [contract, wallet.address, contractState.value]) const payload: DispatchExecuteArgs = { diff --git a/pages/contracts/vendingMinter/instantiate.tsx b/pages/contracts/vendingMinter/instantiate.tsx index 2695f2f..7c48f12 100644 --- a/pages/contracts/vendingMinter/instantiate.tsx +++ b/pages/contracts/vendingMinter/instantiate.tsx @@ -103,7 +103,7 @@ const VendingMinterInstantiatePage: NextPage = () => { name: 'royaltyShare', title: 'Share Percentage', subtitle: 'Percentage of royalties to be paid', - placeholder: '8%', + placeholder: '5%', }) const unitPriceState = useNumberInputState({ diff --git a/pages/index.tsx b/pages/index.tsx index d208893..22c0ac6 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -15,8 +15,8 @@ const HomePage: NextPage = () => { Looking for a fast and efficient way to build an NFT collection? Stargaze Studio is the solution.

- Stargaze Studio is built to provide useful smart contract interfaces that helps you build and deploy your own - NFT collections in no time. + Stargaze Studio is built to provide useful smart contract interfaces that help you build and deploy your own NFT + collections in no time.


diff --git a/utils/hash.ts b/utils/hash.ts index c98cebf..ca64e60 100644 --- a/utils/hash.ts +++ b/utils/hash.ts @@ -1,5 +1,6 @@ /* eslint-disable eslint-comments/disable-enable-pair */ +import * as crypto from 'crypto' import { Word32Array } from 'jscrypto' import { SHA256 } from 'jscrypto/SHA256' import * as secp256k1 from 'secp256k1' @@ -23,3 +24,21 @@ export function generateSignature(id: number, owner: string, privateKey: string) return '' } } + +export function generateKeyPairs(amount: number) { + const keyPairs: { publicKey: string; privateKey: string }[] = [] + for (let i = 0; i < amount; i++) { + let privKey: Buffer + do { + privKey = crypto.randomBytes(32) + } while (!secp256k1.privateKeyVerify(privKey)) + + const privateKey = privKey.toString('hex') + const publicKey = Buffer.from(secp256k1.publicKeyCreate(privKey)).toString('hex') + keyPairs.push({ + publicKey, + privateKey, + }) + } + return keyPairs +}