diff --git a/.env.example b/.env.example index b941cb0..f0f762f 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,16 @@ -APP_VERSION=0.4.8 +APP_VERSION=0.4.9 NEXT_PUBLIC_PINATA_ENDPOINT_URL=https://api.pinata.cloud/pinning/pinFileToIPFS -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_CODE_ID=1911 +NEXT_PUBLIC_SG721_UPDATABLE_CODE_ID=1912 +NEXT_PUBLIC_VENDING_MINTER_CODE_ID=1909 +NEXT_PUBLIC_BASE_MINTER_CODE_ID=1910 +NEXT_PUBLIC_VENDING_FACTORY_ADDRESS="stars1ynec878x5phexq3hj4zdgvp6r5ayfmxks38kvunwyjugqn3hqeqq3cgtuw" +NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS="stars1fnfywcnzzwledr93at65qm8gf953tjxgh6u2u4r8n9vsdv7u75eqe7ecn3" +NEXT_PUBLIC_BASE_FACTORY_ADDRESS="stars1sr37phnuahzsc6tpner9875g3fy69khlgvvyzgs2vjtuupw6lffqd7lark" +NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS="stars13pw8r33dsnghlxfj2upaywf38z2fc6npuw9maq9e5cpet4v285sscgzjp2" NEXT_PUBLIC_SG721_NAME_ADDRESS="stars1fx74nkqkw2748av8j7ew7r3xt9cgjqduwn8m0ur5lhe49uhlsasszc5fhr" -NEXT_PUBLIC_BASE_MINTER_CODE_ID=613 -NEXT_PUBLIC_WHITELIST_CODE_ID=1835 +NEXT_PUBLIC_WHITELIST_CODE_ID=1913 NEXT_PUBLIC_BADGE_HUB_CODE_ID=1336 NEXT_PUBLIC_BADGE_HUB_ADDRESS="stars1dacun0xn7z73qzdcmq27q3xn6xuprg8e2ugj364784al2v27tklqynhuqa" NEXT_PUBLIC_BADGE_NFT_CODE_ID=1337 diff --git a/components/collections/actions/Action.tsx b/components/collections/actions/Action.tsx index 305e907..9cdfad6 100644 --- a/components/collections/actions/Action.tsx +++ b/components/collections/actions/Action.tsx @@ -26,7 +26,7 @@ import { resolveAddress } from 'utils/resolveAddress' import type { CollectionInfo } from '../../../contracts/sg721/contract' import { TextInput } from '../../forms/FormInput' -import type { MinterType } from './Combobox' +import type { MinterType, Sg721Type } from './Combobox' interface CollectionActionsProps { minterContractAddress: string @@ -35,6 +35,7 @@ interface CollectionActionsProps { vendingMinterMessages: VendingMinterInstance | undefined baseMinterMessages: BaseMinterInstance | undefined minterType: MinterType + sg721Type: Sg721Type } type ExplicitContentType = true | false | undefined @@ -46,6 +47,7 @@ export const CollectionActions = ({ vendingMinterMessages, baseMinterMessages, minterType, + sg721Type, }: CollectionActionsProps) => { const wallet = useWallet() const [lastTx, setLastTx] = useState('') @@ -100,7 +102,15 @@ export const CollectionActions = ({ id: 'token-uri', name: 'tokenURI', title: 'Token URI', - subtitle: 'URI for the token to be minted', + subtitle: 'URI for the token', + placeholder: 'ipfs://', + }) + + const baseURIState = useInputState({ + id: 'base-uri', + name: 'baseURI', + title: 'Base URI', + subtitle: 'Base URI to batch update token metadata with', placeholder: 'ipfs://', }) @@ -154,13 +164,18 @@ export const CollectionActions = ({ placeholder: '5%', }) - const showTokenUriField = type === 'mint_token_uri' + const showTokenUriField = isEitherType(type, ['mint_token_uri', 'update_token_metadata']) const showWhitelistField = type === 'set_whitelist' const showDateField = isEitherType(type, ['update_start_time', 'update_start_trading_time']) const showLimitField = type === 'update_per_address_limit' - const showTokenIdField = isEitherType(type, ['transfer', 'mint_for', 'burn']) + const showTokenIdField = isEitherType(type, ['transfer', 'mint_for', 'burn', 'update_token_metadata']) const showNumberOfTokensField = type === 'batch_mint' - const showTokenIdListField = isEitherType(type, ['batch_burn', 'batch_transfer', 'batch_mint_for']) + const showTokenIdListField = isEitherType(type, [ + 'batch_burn', + 'batch_transfer', + 'batch_mint_for', + 'batch_update_token_metadata', + ]) const showRecipientField = isEitherType(type, [ 'transfer', 'mint_to', @@ -176,6 +191,7 @@ export const CollectionActions = ({ const showExternalLinkField = type === 'update_collection_info' const showRoyaltyRelatedFields = type === 'update_collection_info' const showExplicitContentField = type === 'update_collection_info' + const showBaseUriField = type === 'batch_update_token_metadata' const payload: DispatchExecuteArgs = { whitelist: whitelistState.value, @@ -185,7 +201,9 @@ export const CollectionActions = ({ sg721Contract: sg721ContractAddress, tokenId: tokenIdState.value, tokenIds: tokenIdListState.value, - tokenUri: tokenURIState.value, + tokenUri: tokenURIState.value.trim().endsWith('/') + ? tokenURIState.value.trim().slice(0, -1) + : tokenURIState.value.trim(), batchNumber: batchNumberState.value, vendingMinterMessages, baseMinterMessages, @@ -195,6 +213,9 @@ export const CollectionActions = ({ txSigner: wallet.address, type, price: priceState.value.toString(), + baseUri: baseURIState.value.trim().endsWith('/') + ? baseURIState.value.trim().slice(0, -1) + : baseURIState.value.trim(), collectionInfo, } const resolveRecipientAddress = async () => { @@ -350,13 +371,14 @@ export const CollectionActions = ({
- + {showRecipientField && } {showTokenUriField && } {showWhitelistField && } {showLimitField && } - {showTokenIdField && } + {showTokenIdField && } {showTokenIdListField && } + {showBaseUriField && } {showNumberOfTokensField && } {showPriceField && } {showDescriptionField && } diff --git a/components/collections/actions/Combobox.tsx b/components/collections/actions/Combobox.tsx index 3aa0814..d520d6f 100644 --- a/components/collections/actions/Combobox.tsx +++ b/components/collections/actions/Combobox.tsx @@ -6,27 +6,31 @@ import { Fragment, useEffect, useState } from 'react' import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' import type { ActionListItem } from './actions' -import { BASE_ACTION_LIST, VENDING_ACTION_LIST } from './actions' +import { BASE_ACTION_LIST, SG721_UPDATABLE_ACTION_LIST, VENDING_ACTION_LIST } from './actions' export type MinterType = 'base' | 'vending' +export type Sg721Type = 'updatable' | 'base' export interface ActionsComboboxProps { value: ActionListItem | null onChange: (item: ActionListItem) => void minterType?: MinterType + sg721Type?: Sg721Type } -export const ActionsCombobox = ({ value, onChange, minterType }: ActionsComboboxProps) => { +export const ActionsCombobox = ({ value, onChange, minterType, sg721Type }: ActionsComboboxProps) => { const [search, setSearch] = useState('') const [ACTION_LIST, SET_ACTION_LIST] = useState(VENDING_ACTION_LIST) useEffect(() => { if (minterType === 'base') { - SET_ACTION_LIST(BASE_ACTION_LIST) - } else { - SET_ACTION_LIST(VENDING_ACTION_LIST) - } - }, [minterType]) + if (sg721Type === 'updatable') SET_ACTION_LIST(BASE_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST)) + else SET_ACTION_LIST(BASE_ACTION_LIST) + } else if (minterType === 'vending') { + if (sg721Type === 'updatable') SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST)) + else SET_ACTION_LIST(VENDING_ACTION_LIST) + } else SET_ACTION_LIST(VENDING_ACTION_LIST.concat(SG721_UPDATABLE_ACTION_LIST)) + }, [minterType, sg721Type]) const filtered = search === '' ? ACTION_LIST : matchSorter(ACTION_LIST, search, { keys: ['id', 'name', 'description'] }) diff --git a/components/collections/actions/actions.ts b/components/collections/actions/actions.ts index 021e83c..e7d44ab 100644 --- a/components/collections/actions/actions.ts +++ b/components/collections/actions/actions.ts @@ -9,9 +9,7 @@ import type { BaseMinterInstance } from '../../../contracts/baseMinter/contract' export type ActionType = typeof ACTION_TYPES[number] export const ACTION_TYPES = [ - 'mint', 'mint_token_uri', - 'purge', 'update_mint_price', 'update_discount_price', 'remove_discount_price', @@ -32,6 +30,9 @@ export const ACTION_TYPES = [ 'shuffle', 'airdrop', 'burn_remaining', + 'update_token_metadata', + 'batch_update_token_metadata', + 'freeze_token_metadata', ] as const export interface ActionListItem { @@ -84,11 +85,6 @@ export const BASE_ACTION_LIST: ActionListItem[] = [ ] export const VENDING_ACTION_LIST: ActionListItem[] = [ - { - id: 'mint', - name: 'Mint', - description: `Mint a token`, - }, { id: 'update_mint_price', name: 'Update Mint Price', @@ -111,7 +107,7 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [ }, { id: 'batch_mint', - name: 'Batch Mint', + name: 'Batch Mint To', description: `Mint multiple tokens to a user`, }, { @@ -189,10 +185,23 @@ export const VENDING_ACTION_LIST: ActionListItem[] = [ name: 'Burn Remaining Tokens', description: 'Burn remaining tokens', }, +] + +export const SG721_UPDATABLE_ACTION_LIST: ActionListItem[] = [ { - id: 'purge', - name: 'Purge', - description: `Purge`, + id: 'update_token_metadata', + name: 'Update Token Metadata', + description: `Update the metadata URI for a token`, + }, + { + id: 'batch_update_token_metadata', + name: 'Batch Update Token Metadata', + description: `Update the metadata URI for a range of tokens`, + }, + { + id: 'freeze_token_metadata', + name: 'Freeze Token Metadata', + description: `Render the metadata for tokens no longer updatable`, }, ] @@ -213,9 +222,7 @@ export type DispatchExecuteArgs = { txSigner: string } & ( | { type: undefined } - | { type: Select<'mint'> } | { 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'> } @@ -236,6 +243,9 @@ export type DispatchExecuteArgs = { | { type: Select<'burn_remaining'> } | { type: Select<'update_collection_info'>; collectionInfo: CollectionInfo | undefined } | { type: Select<'freeze_collection_info'> } + | { type: Select<'update_token_metadata'>; tokenId: number; tokenUri: string } + | { type: Select<'batch_update_token_metadata'>; tokenIds: string; baseUri: string } + | { type: Select<'freeze_token_metadata'> } ) export const dispatchExecute = async (args: DispatchExecuteArgs) => { @@ -244,15 +254,9 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { throw new Error('Cannot execute actions') } switch (args.type) { - case 'mint': { - return vendingMinterMessages.mint(txSigner) - } case 'mint_token_uri': { return baseMinterMessages.mint(txSigner, args.tokenUri) } - case 'purge': { - return vendingMinterMessages.purge(txSigner) - } case 'update_mint_price': { return vendingMinterMessages.updateMintPrice(txSigner, args.price) } @@ -289,6 +293,15 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { case 'freeze_collection_info': { return sg721Messages.freezeCollectionInfo() } + case 'update_token_metadata': { + return sg721Messages.updateTokenMetadata(args.tokenId.toString(), args.tokenUri) + } + case 'batch_update_token_metadata': { + return sg721Messages.batchUpdateTokenMetadata(args.tokenIds, args.baseUri) + } + case 'freeze_token_metadata': { + return sg721Messages.freezeTokenMetadata() + } case 'shuffle': { return vendingMinterMessages.shuffle(txSigner) } @@ -328,15 +341,9 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { const { messages: baseMinterMessages } = useBaseMinterContract() const { minterContract, sg721Contract } = args switch (args.type) { - case 'mint': { - return vendingMinterMessages(minterContract)?.mint() - } case 'mint_token_uri': { return baseMinterMessages(minterContract)?.mint(args.tokenUri) } - case 'purge': { - return vendingMinterMessages(minterContract)?.purge() - } case 'update_mint_price': { return vendingMinterMessages(minterContract)?.updateMintPrice(args.price) } @@ -373,6 +380,15 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'freeze_collection_info': { return sg721Messages(sg721Contract)?.freezeCollectionInfo() } + case 'update_token_metadata': { + return sg721Messages(sg721Contract)?.updateTokenMetadata(args.tokenId.toString(), args.tokenUri) + } + case 'batch_update_token_metadata': { + return sg721Messages(sg721Contract)?.batchUpdateTokenMetadata(args.tokenIds, args.baseUri) + } + case 'freeze_token_metadata': { + return sg721Messages(sg721Contract)?.freezeTokenMetadata() + } case 'shuffle': { return vendingMinterMessages(minterContract)?.shuffle() } diff --git a/components/collections/creation/CollectionDetails.tsx b/components/collections/creation/CollectionDetails.tsx index bad2dec..17a40a2 100644 --- a/components/collections/creation/CollectionDetails.tsx +++ b/components/collections/creation/CollectionDetails.tsx @@ -8,8 +8,9 @@ import { FormControl } from 'components/FormControl' import { FormGroup } from 'components/FormGroup' import { useInputState } from 'components/forms/FormInput.hooks' import { InputDateTime } from 'components/InputDateTime' +import { Tooltip } from 'components/Tooltip' import type { ChangeEvent } from 'react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { toast } from 'react-hot-toast' import { TextInput } from '../../forms/FormInput' @@ -31,12 +32,16 @@ export interface CollectionDetailsDataProps { externalLink?: string startTradingTime?: string explicit: boolean + updatable: boolean } export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minterType }: CollectionDetailsProps) => { const [coverImage, setCoverImage] = useState(null) const [timestamp, setTimestamp] = useState() const [explicit, setExplicit] = useState(false) + const [updatable, setUpdatable] = useState(false) + + const initialRender = useRef(true) const nameState = useInputState({ id: 'name', @@ -76,6 +81,7 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte externalLink: externalLinkState.value || undefined, startTradingTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '', explicit, + updatable, } onChange(data) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -91,6 +97,7 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte coverImage, timestamp, explicit, + updatable, ]) const selectCoverImage = (event: ChangeEvent) => { @@ -109,6 +116,22 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte reader.readAsArrayBuffer(event.target.files[0]) } + useEffect(() => { + if (initialRender.current) { + initialRender.current = false + } else if (updatable) { + toast.success('Token metadata will be updatable upon collection creation.', { + style: { maxWidth: 'none' }, + icon: '✅📝', + }) + } else { + toast.error('Token metadata will not be updatable upon collection creation.', { + style: { maxWidth: 'none' }, + icon: '⛔🔏', + }) + } + }, [updatable]) + return (
@@ -174,7 +197,7 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte
- + Does the collection contain explicit content?
@@ -216,6 +239,35 @@ export const CollectionDetails = ({ onChange, uploadMethod, coverImageUrl, minte
+ + + ℹī¸ When enabled, the metadata for tokens can be updated after the collection is created until the + metadata is frozen by the creator. + +
+ } + placement="bottom" + > +
+ +
+
) diff --git a/contracts/baseFactory/contract.ts b/contracts/baseFactory/contract.ts index 20fd166..ef4e965 100644 --- a/contracts/baseFactory/contract.ts +++ b/contracts/baseFactory/contract.ts @@ -4,6 +4,8 @@ import { coin } from '@cosmjs/proto-signing' import type { logs } from '@cosmjs/stargate' import { BASE_FACTORY_ADDRESS } from 'utils/constants' +import { BASE_FACTORY_UPDATABLE_ADDRESS } from '../../utils/constants' + export interface CreateBaseMinterResponse { readonly baseMinterAddress: string readonly sg721Address: string @@ -21,11 +23,12 @@ export interface BaseFactoryInstance { senderAddress: string, msg: Record, funds: Coin[], + updatable?: boolean, ) => Promise } export interface BaseFactoryMessages { - createBaseMinter: (msg: Record) => CreateBaseMinterMessage + createBaseMinter: (msg: Record, updatable?: boolean) => CreateBaseMinterMessage } export interface CreateBaseMinterMessage { @@ -56,8 +59,16 @@ export const baseFactory = (client: SigningCosmWasmClient, txSigner: string): Ba senderAddress: string, msg: Record, funds: Coin[], + updatable?: boolean, ): Promise => { - const result = await client.execute(senderAddress, BASE_FACTORY_ADDRESS, msg, 'auto', '', funds) + const result = await client.execute( + senderAddress, + updatable ? BASE_FACTORY_UPDATABLE_ADDRESS : BASE_FACTORY_ADDRESS, + msg, + 'auto', + '', + funds, + ) return { baseMinterAddress: result.logs[0].events[5].attributes[0].value, @@ -75,12 +86,12 @@ export const baseFactory = (client: SigningCosmWasmClient, txSigner: string): Ba } const messages = (contractAddress: string) => { - const createBaseMinter = (msg: Record): CreateBaseMinterMessage => { + const createBaseMinter = (msg: Record, updatable?: boolean): CreateBaseMinterMessage => { return { sender: txSigner, contract: contractAddress, msg, - funds: [coin('1000000000', 'ustars')], + funds: [coin(updatable ? '3000000000' : '1000000000', 'ustars')], } } diff --git a/contracts/baseFactory/messages/execute.ts b/contracts/baseFactory/messages/execute.ts index 374aba9..1bb8612 100644 --- a/contracts/baseFactory/messages/execute.ts +++ b/contracts/baseFactory/messages/execute.ts @@ -10,6 +10,7 @@ export interface DispatchExecuteArgs { txSigner: string msg: Record funds: Coin[] + updatable?: boolean } export const dispatchExecute = async (args: DispatchExecuteArgs) => { @@ -17,12 +18,12 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { if (!messages) { throw new Error('cannot dispatch execute, messages is not defined') } - return messages.createBaseMinter(txSigner, args.msg, args.funds) + return messages.createBaseMinter(txSigner, args.msg, args.funds, args.updatable) } export const previewExecutePayload = (args: DispatchExecuteArgs) => { // eslint-disable-next-line react-hooks/rules-of-hooks const { messages } = useBaseFactoryContract() const { contract } = args - return messages(contract)?.createBaseMinter(args.msg) + return messages(contract)?.createBaseMinter(args.msg, args.updatable) } diff --git a/contracts/sg721/contract.ts b/contracts/sg721/contract.ts index 270eb73..bce6b13 100644 --- a/contracts/sg721/contract.ts +++ b/contracts/sg721/contract.ts @@ -86,6 +86,9 @@ export interface SG721Instance { burn: (tokenId: string) => Promise batchBurn: (tokenIds: string) => Promise batchTransfer: (recipient: string, tokenIds: string) => Promise + updateTokenMetadata: (tokenId: string, tokenURI: string) => Promise + batchUpdateTokenMetadata: (tokenIds: string, tokenURI: string) => Promise + freezeTokenMetadata: () => Promise } export interface Sg721Messages { @@ -101,6 +104,9 @@ export interface Sg721Messages { batchTransfer: (recipient: string, tokenIds: string) => BatchTransferMessage updateCollectionInfo: (collectionInfo: CollectionInfo) => UpdateCollectionInfoMessage freezeCollectionInfo: () => FreezeCollectionInfoMessage + updateTokenMetadata: (tokenId: string, tokenURI: string) => UpdateTokenMetadataMessage + batchUpdateTokenMetadata: (tokenIds: string, tokenURI: string) => BatchUpdateTokenMetadataMessage + freezeTokenMetadata: () => FreezeTokenMetadataMessage } export interface TransferNFTMessage { @@ -215,6 +221,32 @@ export interface BatchTransferMessage { funds: Coin[] } +export interface UpdateTokenMetadataMessage { + sender: string + contract: string + msg: { + update_token_metadata: { + token_id: string + token_uri: string + } + } + funds: Coin[] +} + +export interface BatchUpdateTokenMetadataMessage { + sender: string + contract: string + msg: Record[] + funds: Coin[] +} + +export interface FreezeTokenMetadataMessage { + sender: string + contract: string + msg: { freeze_token_metadata: Record } + funds: Coin[] +} + export interface UpdateCollectionInfoMessage { sender: string contract: string @@ -570,6 +602,65 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con return res.transactionHash } + const batchUpdateTokenMetadata = async (tokenIds: string, baseURI: string): Promise => { + const executeContractMsgs: MsgExecuteContractEncodeObject[] = [] + if (tokenIds.includes(':')) { + const [start, end] = tokenIds.split(':').map(Number) + for (let i = start; i <= end; i++) { + const msg = { + update_token_metadata: { token_id: i.toString(), token_uri: `${baseURI}/${i}` }, + } + const executeContractMsg: MsgExecuteContractEncodeObject = { + typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract', + value: MsgExecuteContract.fromPartial({ + sender: txSigner, + contract: contractAddress, + msg: toUtf8(JSON.stringify(msg)), + }), + } + + executeContractMsgs.push(executeContractMsg) + } + } else { + const tokenNumbers = tokenIds.split(',').map(Number) + for (let i = 0; i < tokenNumbers.length; i++) { + const msg = { + update_token_metadata: { token_id: tokenNumbers[i].toString(), token_uri: `${baseURI}/${tokenNumbers[i]}` }, + } + const executeContractMsg: MsgExecuteContractEncodeObject = { + typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract', + value: MsgExecuteContract.fromPartial({ + sender: txSigner, + contract: contractAddress, + msg: toUtf8(JSON.stringify(msg)), + }), + } + + executeContractMsgs.push(executeContractMsg) + } + } + + const res = await client.signAndBroadcast(txSigner, executeContractMsgs, 'auto', 'batch update metadata') + + return res.transactionHash + } + + const updateTokenMetadata = async (tokenId: string, tokenURI: string): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + update_token_metadata: { + token_id: tokenId, + token_uri: tokenURI, + }, + }, + 'auto', + '', + ) + return res.transactionHash + } + const freezeCollectionInfo = async (): Promise => { const res = await client.execute( txSigner, @@ -583,6 +674,19 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con return res.transactionHash } + const freezeTokenMetadata = async (): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + freeze_token_metadata: {}, + }, + 'auto', + '', + ) + return res.transactionHash + } + return { contractAddress, ownerOf, @@ -609,6 +713,9 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con batchTransfer, updateCollectionInfo, freezeCollectionInfo, + updateTokenMetadata, + batchUpdateTokenMetadata, + freezeTokenMetadata, } } @@ -804,6 +911,58 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con funds: [], } } + + const batchUpdateTokenMetadata = (tokenIds: string, baseURI: string): BatchUpdateTokenMetadataMessage => { + const msg: Record[] = [] + if (tokenIds.includes(':')) { + const [start, end] = tokenIds.split(':').map(Number) + for (let i = start; i <= end; i++) { + msg.push({ + update_token_metadata: { token_id: i.toString(), token_uri: `${baseURI}/${i}` }, + }) + } + } else { + const tokenNumbers = tokenIds.split(',').map(Number) + for (let i = 0; i < tokenNumbers.length; i++) { + msg.push({ + update_token_metadata: { token_id: tokenNumbers[i].toString(), token_uri: `${baseURI}/${tokenNumbers[i]}` }, + }) + } + } + + return { + sender: txSigner, + contract: contractAddress, + msg, + funds: [], + } + } + + const updateTokenMetadata = (tokenId: string, tokenURI: string) => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + update_token_metadata: { + token_id: tokenId, + token_uri: tokenURI, + }, + }, + funds: [], + } + } + + const freezeTokenMetadata = () => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + freeze_token_metadata: {}, + }, + funds: [], + } + } + const updateCollectionInfo = (collectionInfo: CollectionInfo) => { return { sender: txSigner, @@ -814,6 +973,7 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con funds: [], } } + const freezeCollectionInfo = () => { return { sender: txSigner, @@ -838,6 +998,9 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con batchTransfer, updateCollectionInfo, freezeCollectionInfo, + updateTokenMetadata, + batchUpdateTokenMetadata, + freezeTokenMetadata, } } diff --git a/contracts/sg721/messages/execute.ts b/contracts/sg721/messages/execute.ts index de628b5..bd45913 100644 --- a/contracts/sg721/messages/execute.ts +++ b/contracts/sg721/messages/execute.ts @@ -12,6 +12,8 @@ export const EXECUTE_TYPES = [ 'revoke_all', 'mint', 'burn', + 'update_token_metadata', + 'freeze_token_metadata', ] as const export interface ExecuteListItem { @@ -61,6 +63,16 @@ export const EXECUTE_LIST: ExecuteListItem[] = [ name: 'Burn', description: `Burn a token transaction sender has access to`, }, + { + id: 'update_token_metadata', + name: 'Update Token Metadata', + description: `Update the metadata URI for a token`, + }, + { + id: 'freeze_token_metadata', + name: 'Freeze Token Metadata', + description: `Render the metadata for tokens no longer updatable`, + }, ] export interface DispatchExecuteProps { @@ -84,6 +96,8 @@ export type DispatchExecuteArgs = { | { type: Select<'revoke_all'>; operator: string } | { type: Select<'mint'>; recipient: string; tokenId: string; tokenURI?: string } | { type: Select<'burn'>; tokenId: string } + | { type: Select<'update_token_metadata'>; tokenId: string; tokenURI: string } + | { type: Select<'freeze_token_metadata'> } ) export const dispatchExecute = async (args: DispatchExecuteArgs) => { @@ -116,6 +130,12 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { case 'burn': { return messages.burn(args.tokenId) } + case 'update_token_metadata': { + return messages.updateTokenMetadata(args.tokenId, args.tokenURI) + } + case 'freeze_token_metadata': { + return messages.freezeTokenMetadata() + } default: { throw new Error('unknown execute type') } @@ -151,6 +171,12 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'burn': { return messages(contract)?.burn(args.tokenId) } + case 'update_token_metadata': { + return messages(contract)?.updateTokenMetadata(args.tokenId, args.tokenURI) + } + case 'freeze_token_metadata': { + return messages(contract)?.freezeTokenMetadata() + } default: { return {} } diff --git a/contracts/vendingFactory/contract.ts b/contracts/vendingFactory/contract.ts index d8408a4..120907f 100644 --- a/contracts/vendingFactory/contract.ts +++ b/contracts/vendingFactory/contract.ts @@ -4,6 +4,8 @@ import { coin } from '@cosmjs/proto-signing' import type { logs } from '@cosmjs/stargate' import { VENDING_FACTORY_ADDRESS } from 'utils/constants' +import { VENDING_FACTORY_UPDATABLE_ADDRESS } from '../../utils/constants' + export interface CreateVendingMinterResponse { readonly vendingMinterAddress: string readonly sg721Address: string @@ -21,11 +23,12 @@ export interface VendingFactoryInstance { senderAddress: string, msg: Record, funds: Coin[], + updatable?: boolean, ) => Promise } export interface VendingFactoryMessages { - createVendingMinter: (msg: Record) => CreateVendingMinterMessage + createVendingMinter: (msg: Record, updatable?: boolean) => CreateVendingMinterMessage } export interface CreateVendingMinterMessage { @@ -50,8 +53,16 @@ export const vendingFactory = (client: SigningCosmWasmClient, txSigner: string): senderAddress: string, msg: Record, funds: Coin[], + updatable?: boolean, ): Promise => { - const result = await client.execute(senderAddress, VENDING_FACTORY_ADDRESS, msg, 'auto', '', funds) + const result = await client.execute( + senderAddress, + updatable ? VENDING_FACTORY_UPDATABLE_ADDRESS : VENDING_FACTORY_ADDRESS, + msg, + 'auto', + '', + funds, + ) return { vendingMinterAddress: result.logs[0].events[5].attributes[0].value, @@ -68,12 +79,12 @@ export const vendingFactory = (client: SigningCosmWasmClient, txSigner: string): } const messages = (contractAddress: string) => { - const createVendingMinter = (msg: Record): CreateVendingMinterMessage => { + const createVendingMinter = (msg: Record, updatable?: boolean): CreateVendingMinterMessage => { return { sender: txSigner, contract: contractAddress, msg, - funds: [coin('3000000000', 'ustars')], + funds: [coin(updatable ? '5000000000' : '3000000000', 'ustars')], } } diff --git a/contracts/vendingFactory/messages/execute.ts b/contracts/vendingFactory/messages/execute.ts index 7939a7f..05cbb8c 100644 --- a/contracts/vendingFactory/messages/execute.ts +++ b/contracts/vendingFactory/messages/execute.ts @@ -10,6 +10,7 @@ export interface DispatchExecuteArgs { txSigner: string msg: Record funds: Coin[] + updatable?: boolean } export const dispatchExecute = async (args: DispatchExecuteArgs) => { @@ -17,12 +18,12 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { if (!messages) { throw new Error('cannot dispatch execute, messages is not defined') } - return messages.createVendingMinter(txSigner, args.msg, args.funds) + return messages.createVendingMinter(txSigner, args.msg, args.funds, args.updatable) } export const previewExecutePayload = (args: DispatchExecuteArgs) => { // eslint-disable-next-line react-hooks/rules-of-hooks const { messages } = useVendingFactoryContract() const { contract } = args - return messages(contract)?.createVendingMinter(args.msg) + return messages(contract)?.createVendingMinter(args.msg, args.updatable) } diff --git a/contracts/vendingMinter/messages/execute.ts b/contracts/vendingMinter/messages/execute.ts index d75f4e6..fdce751 100644 --- a/contracts/vendingMinter/messages/execute.ts +++ b/contracts/vendingMinter/messages/execute.ts @@ -29,7 +29,7 @@ export const EXECUTE_LIST: ExecuteListItem[] = [ { id: 'mint', name: 'Mint', - description: `Mint new tokens for a given address`, + description: `Mint a new token`, }, { id: 'purge', diff --git a/env.d.ts b/env.d.ts index bb733fe..c4e8faf 100644 --- a/env.d.ts +++ b/env.d.ts @@ -15,10 +15,13 @@ declare namespace NodeJS { readonly APP_VERSION: string readonly NEXT_PUBLIC_SG721_CODE_ID: string + readonly NEXT_PUBLIC_SG721_UPDATABLE_CODE_ID: string readonly NEXT_PUBLIC_WHITELIST_CODE_ID: string readonly NEXT_PUBLIC_VENDING_MINTER_CODE_ID: string readonly NEXT_PUBLIC_VENDING_FACTORY_ADDRESS: string + readonly NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS: string readonly NEXT_PUBLIC_BASE_FACTORY_ADDRESS: string + readonly NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS: string readonly NEXT_PUBLIC_SG721_NAME_ADDRESS: string readonly NEXT_PUBLIC_BASE_MINTER_CODE_ID: string readonly NEXT_PUBLIC_BADGE_HUB_CODE_ID: string diff --git a/package.json b/package.json index 2a082d1..150a347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stargaze-studio", - "version": "0.4.8", + "version": "0.4.9", "workspaces": [ "packages/*" ], diff --git a/pages/collections/actions.tsx b/pages/collections/actions.tsx index 5c50eda..445a199 100644 --- a/pages/collections/actions.tsx +++ b/pages/collections/actions.tsx @@ -15,7 +15,7 @@ import { useDebounce } from 'utils/debounce' import { withMetadata } from 'utils/layout' import { links } from 'utils/links' -import type { MinterType } from '../../components/collections/actions/Combobox' +import type { MinterType, Sg721Type } from '../../components/collections/actions/Combobox' const CollectionActionsPage: NextPage = () => { const { baseMinter: baseMinterContract, vendingMinter: vendingMinterContract, sg721: sg721Contract } = useContracts() @@ -23,6 +23,7 @@ const CollectionActionsPage: NextPage = () => { const [action, setAction] = useState(false) const [minterType, setMinterType] = useState('vending') + const [sg721Type, setSg721Type] = useState('updatable') const sg721ContractState = useInputState({ id: 'sg721-contract-address', @@ -39,6 +40,7 @@ const CollectionActionsPage: NextPage = () => { }) const debouncedMinterContractState = useDebounce(minterContractState.value, 300) + const debouncedSg721ContractState = useDebounce(sg721ContractState.value, 300) const vendingMinterMessages = useMemo( () => vendingMinterContract?.use(minterContractState.value), @@ -109,10 +111,45 @@ const CollectionActionsPage: NextPage = () => { .catch((err) => { console.log(err) setMinterType('vending') - console.log('Unable to retrieve contract version') + console.log('Unable to retrieve contract type. Defaulting to "vending".') }) }, [debouncedMinterContractState, wallet.client]) + useEffect(() => { + async function getSg721ContractType() { + if (wallet.client && debouncedSg721ContractState.length > 0) { + const client = wallet.client + const data = await toast.promise( + client.queryContractRaw( + debouncedSg721ContractState, + toUtf8(Buffer.from(Buffer.from('contract_info').toString('hex'), 'hex').toString()), + ), + { + loading: 'Retrieving SG721 type...', + error: 'SG721 type retrieval failed.', + success: 'SG721 type retrieved.', + }, + ) + const contract: string = JSON.parse(new TextDecoder().decode(data as Uint8Array)).contract + console.log(contract) + return contract + } + } + void getSg721ContractType() + .then((contract) => { + if (contract?.includes('sg721-updatable')) { + setSg721Type('updatable') + } else { + setSg721Type('base') + } + }) + .catch((err) => { + console.log(err) + setMinterType('base') + console.log('Unable to retrieve contract type. Defaulting to "base".') + }) + }, [debouncedSg721ContractState, wallet.client]) + return (
@@ -178,6 +215,7 @@ const CollectionActionsPage: NextPage = () => { minterType={minterType} sg721ContractAddress={sg721ContractState.value} sg721Messages={sg721Messages} + sg721Type={sg721Type} vendingMinterMessages={vendingMinterMessages} /> )) || ( diff --git a/pages/collections/create.tsx b/pages/collections/create.tsx index 92964f8..1c77795 100644 --- a/pages/collections/create.tsx +++ b/pages/collections/create.tsx @@ -1,4 +1,5 @@ /* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-nested-ternary */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ @@ -39,11 +40,15 @@ import { upload } from 'services/upload' import { compareFileArrays } from 'utils/compareFileArrays' import { BASE_FACTORY_ADDRESS, + BASE_FACTORY_UPDATABLE_ADDRESS, BLOCK_EXPLORER_URL, NETWORK, SG721_CODE_ID, + SG721_UPDATABLE_CODE_ID, STARGAZE_URL, VENDING_FACTORY_ADDRESS, + VENDING_FACTORY_UPDATABLE_ADDRESS, + WHITELIST_CODE_ID, } from 'utils/constants' import { withMetadata } from 'utils/layout' import { links } from 'utils/links' @@ -412,7 +417,7 @@ const CollectionCreationPage: NextPage = () => { whitelist, }, collection_params: { - code_id: SG721_CODE_ID, + code_id: collectionDetails?.updatable ? SG721_UPDATABLE_CODE_ID : SG721_CODE_ID, name: collectionDetails?.name, symbol: collectionDetails?.symbol, info: { @@ -433,11 +438,12 @@ const CollectionCreationPage: NextPage = () => { } const payload: VendingFactoryDispatchExecuteArgs = { - contract: VENDING_FACTORY_ADDRESS, + contract: collectionDetails?.updatable ? VENDING_FACTORY_UPDATABLE_ADDRESS : VENDING_FACTORY_ADDRESS, messages: vendingFactoryMessages, txSigner: wallet.address, msg, - funds: [coin('3000000000', 'ustars')], + funds: [coin(collectionDetails?.updatable ? '5000000000' : '3000000000', 'ustars')], + updatable: collectionDetails?.updatable, } const data = await vendingFactoryDispatchExecute(payload) setTransactionHash(data.transactionHash) @@ -462,7 +468,7 @@ const CollectionCreationPage: NextPage = () => { create_minter: { init_msg: null, collection_params: { - code_id: SG721_CODE_ID, + code_id: collectionDetails?.updatable ? SG721_UPDATABLE_CODE_ID : SG721_CODE_ID, name: collectionDetails?.name, symbol: collectionDetails?.symbol, info: { @@ -483,11 +489,12 @@ const CollectionCreationPage: NextPage = () => { } const payload: BaseFactoryDispatchExecuteArgs = { - contract: BASE_FACTORY_ADDRESS, + contract: collectionDetails?.updatable ? BASE_FACTORY_UPDATABLE_ADDRESS : BASE_FACTORY_ADDRESS, messages: baseFactoryMessages, txSigner: wallet.address, msg, - funds: [coin('1000000000', 'ustars')], + funds: [coin(collectionDetails?.updatable ? '3000000000' : '1000000000', 'ustars')], + updatable: collectionDetails?.updatable, } await baseFactoryDispatchExecute(payload) .then(async (data) => { @@ -845,11 +852,20 @@ const CollectionCreationPage: NextPage = () => { const checkwalletBalance = () => { if (!wallet.initialized) throw new Error('Wallet not connected.') if (minterType === 'vending' && whitelistDetails?.whitelistType === 'new' && whitelistDetails.memberLimit) { - const amountNeeded = Math.ceil(Number(whitelistDetails.memberLimit) / 1000) * 100000000 + 3000000000 + const amountNeeded = + Math.ceil(Number(whitelistDetails.memberLimit) / 1000) * 100000000 + + (collectionDetails?.updatable ? 5000000000 : 3000000000) if (amountNeeded >= Number(wallet.balance[0].amount)) throw new Error('Insufficient wallet balance to instantiate the required contracts.') } else { - const amountNeeded = minterType === 'vending' ? 3000000000 : 1000000000 + const amountNeeded = + minterType === 'vending' + ? collectionDetails?.updatable + ? 5000000000 + : 3000000000 + : collectionDetails?.updatable + ? 3000000000 + : 1000000000 if (amountNeeded >= Number(wallet.balance[0].amount)) throw new Error('Insufficient wallet balance to instantiate the required contracts.') } diff --git a/pages/contracts/sg721/execute.tsx b/pages/contracts/sg721/execute.tsx index 0b39ce5..eac2b1e 100644 --- a/pages/contracts/sg721/execute.tsx +++ b/pages/contracts/sg721/execute.tsx @@ -85,11 +85,19 @@ const Sg721ExecutePage: NextPage = () => { placeholder: 'ipfs://xyz...', }) - const showTokenIdField = isEitherType(type, ['transfer_nft', 'send_nft', 'approve', 'revoke', 'mint', 'burn']) + const showTokenIdField = isEitherType(type, [ + 'transfer_nft', + 'send_nft', + 'approve', + 'revoke', + 'mint', + 'burn', + 'update_token_metadata', + ]) const showRecipientField = isEitherType(type, ['transfer_nft', 'send_nft', 'approve', 'revoke', 'mint']) const showOperatorField = isEitherType(type, ['approve_all', 'revoke_all']) const showMessageField = type === 'send_nft' - const showTokenURIField = type === 'mint' + const showTokenURIField = isEitherType(type, ['mint', 'update_token_metadata']) const messages = useMemo(() => contract?.use(contractState.value), [contract, contractState.value]) const payload: DispatchExecuteArgs = { @@ -99,7 +107,7 @@ const Sg721ExecutePage: NextPage = () => { recipient: resolvedRecipientAddress, operator: resolvedOperatorAddress, type, - tokenURI: tokenURIState.value, + tokenURI: tokenURIState.value.trim(), msg: parseJson(messageState.value) || {}, } const { isLoading, mutate } = useMutation( diff --git a/utils/constants.ts b/utils/constants.ts index 9966325..f00df0b 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -1,8 +1,11 @@ export const SG721_CODE_ID = parseInt(process.env.NEXT_PUBLIC_SG721_CODE_ID, 10) +export const SG721_UPDATABLE_CODE_ID = parseInt(process.env.NEXT_PUBLIC_SG721_UPDATABLE_CODE_ID, 10) export const WHITELIST_CODE_ID = parseInt(process.env.NEXT_PUBLIC_WHITELIST_CODE_ID, 10) export const VENDING_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_VENDING_MINTER_CODE_ID, 10) export const VENDING_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_VENDING_FACTORY_ADDRESS +export const VENDING_FACTORY_UPDATABLE_ADDRESS = process.env.NEXT_PUBLIC_VENDING_FACTORY_UPDATABLE_ADDRESS export const BASE_FACTORY_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_ADDRESS +export const BASE_FACTORY_UPDATABLE_ADDRESS = process.env.NEXT_PUBLIC_BASE_FACTORY_UPDATABLE_ADDRESS export const SG721_NAME_ADDRESS = process.env.NEXT_PUBLIC_SG721_NAME_ADDRESS export const BASE_MINTER_CODE_ID = parseInt(process.env.NEXT_PUBLIC_VENDING_MINTER_CODE_ID, 10) export const BADGE_HUB_CODE_ID = parseInt(process.env.NEXT_PUBLIC_BADGE_HUB_CODE_ID, 10)