diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b480a52..b27b987 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @findolor @MightOfOaks @name-user1 +* @MightOfOaks @Ninjatosba diff --git a/components/LinkTabs.data.ts b/components/LinkTabs.data.ts index cd0afa1..f8f3a45 100644 --- a/components/LinkTabs.data.ts +++ b/components/LinkTabs.data.ts @@ -16,6 +16,11 @@ export const sg721LinkTabs: LinkTabProps[] = [ description: `Execute SG721 contract actions`, href: '/contracts/sg721/execute', }, + { + title: 'Migrate', + description: `Migrate SG721 contract`, + href: '/contracts/sg721/migrate', + }, ] export const minterLinkTabs: LinkTabProps[] = [ diff --git a/components/collections/actions/Action.tsx b/components/collections/actions/Action.tsx index 19779a9..f8b5f49 100644 --- a/components/collections/actions/Action.tsx +++ b/components/collections/actions/Action.tsx @@ -1,3 +1,4 @@ +import { toUtf8 } from '@cosmjs/encoding' import { AirdropUpload } from 'components/AirdropUpload' import { Button } from 'components/Button' import type { DispatchExecuteArgs } from 'components/collections/actions/actions' @@ -83,6 +84,22 @@ export const CollectionActions = ({ subtitle: 'Address of the recipient', }) + const tokenURIState = useInputState({ + id: 'token-uri', + name: 'tokenURI', + title: 'Token URI', + 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://', + }) + const whitelistState = useInputState({ id: 'whitelist-address', name: 'whitelistAddress', @@ -90,14 +107,33 @@ export const CollectionActions = ({ subtitle: 'Address of the whitelist contract', }) + const royaltyPaymentAddressState = useInputState({ + id: 'royalty-payment-address', + name: 'royaltyPaymentAddress', + title: 'Royalty Payment Address', + subtitle: 'Address to receive royalties.', + placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...', + }) + + const royaltyShareState = useInputState({ + id: 'royalty-share', + name: 'royaltyShare', + title: 'Share Percentage', + subtitle: 'Percentage of royalties to be paid', + placeholder: '8%', + }) + const showWhitelistField = type === 'set_whitelist' const showDateField = type === 'update_start_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']) + const showTokenIdListField = isEitherType(type, ['batch_burn', 'batch_transfer', 'batch_update_token_metadata']) const showRecipientField = isEitherType(type, ['transfer', 'mint_to', 'mint_for', 'batch_mint', 'batch_transfer']) const showAirdropFileField = type === 'airdrop' + const showRoyaltyInfoFields = type === 'update_royalty_info' + const showTokenUriField = type === 'update_token_metadata' + const showBaseUriField = type === 'batch_update_token_metadata' const payload: DispatchExecuteArgs = { whitelist: whitelistState.value, @@ -113,6 +149,16 @@ export const CollectionActions = ({ recipient: recipientState.value, recipients: airdropArray, txSigner: wallet.address, + royaltyInfo: { + payment_address: royaltyPaymentAddressState.value, + share_bps: Number(royaltyShareState.value), + }, + baseUri: baseURIState.value.trim().endsWith('/') + ? baseURIState.value.trim().slice(0, -1) + : baseURIState.value.trim(), + tokenUri: tokenURIState.value.trim().endsWith('/') + ? tokenURIState.value.trim().slice(0, -1) + : tokenURIState.value.trim(), type, } @@ -140,6 +186,38 @@ export const CollectionActions = ({ if (minterContractAddress === '' && sg721ContractAddress === '') { throw new Error('Please enter minter and sg721 contract addresses!') } + + if ( + type === 'update_royalty_info' && + (royaltyShareState.value ? !royaltyPaymentAddressState.value : royaltyPaymentAddressState.value) + ) { + throw new Error('Royalty payment address and share percentage are both required') + } + + if ( + type === 'update_royalty_info' && + royaltyPaymentAddressState.value && + !royaltyPaymentAddressState.value.trim().endsWith('.stars') + ) { + const contractInfoResponse = await wallet.client + ?.queryContractRaw( + royaltyPaymentAddressState.value.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 txHash = await toast.promise(dispatchExecute(payload), { error: `${type.charAt(0).toUpperCase() + type.slice(1)} execute failed!`, loading: 'Executing message...', @@ -151,7 +229,7 @@ export const CollectionActions = ({ }, { onError: (error) => { - toast.error(String(error)) + toast.error(String(error), { style: { maxWidth: 'none' } }) }, }, ) @@ -170,8 +248,12 @@ export const CollectionActions = ({ {showWhitelistField && } {showLimitField && } {showTokenIdField && } + {showTokenUriField && } {showTokenIdListField && } + {showBaseUriField && } {showNumberOfTokensField && } + {showRoyaltyInfoFields && } + {showRoyaltyInfoFields && } {showAirdropFileField && ( ; recipient: string; tokenIds: string } | { type: Select<'burn'>; tokenId: number } | { type: Select<'batch_burn'>; tokenIds: string } + | { type: Select<'update_royalty_info'>; royaltyInfo: RoyaltyInfo } | { type: Select<'airdrop'>; recipients: string[] } + | { 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) => { @@ -165,12 +193,24 @@ export const dispatchExecute = async (args: DispatchExecuteArgs) => { case 'burn': { return sg721Messages.burn(args.tokenId.toString()) } + case 'update_royalty_info': { + return sg721Messages.updateRoyaltyInfo(args.royaltyInfo) + } case 'batch_burn': { return sg721Messages.batchBurn(args.tokenIds) } case 'airdrop': { return minterMessages.airdrop(txSigner, args.recipients) } + 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() + } default: { throw new Error('Unknown action') } @@ -208,6 +248,15 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'withdraw': { return minterMessages(minterContract)?.withdraw() } + 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 'transfer': { return sg721Messages(sg721Contract)?.transferNft(args.recipient, args.tokenId.toString()) } @@ -217,6 +266,9 @@ export const previewExecutePayload = (args: DispatchExecuteArgs) => { case 'burn': { return sg721Messages(sg721Contract)?.burn(args.tokenId.toString()) } + case 'update_royalty_info': { + return sg721Messages(sg721Contract)?.updateRoyaltyInfo(args.royaltyInfo) + } case 'batch_burn': { return sg721Messages(sg721Contract)?.batchBurn(args.tokenIds) } diff --git a/contracts/sg721/contract.ts b/contracts/sg721/contract.ts index ba108f1..b27ae05 100644 --- a/contracts/sg721/contract.ts +++ b/contracts/sg721/contract.ts @@ -1,6 +1,6 @@ import type { MsgExecuteContractEncodeObject, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { toBase64, toUtf8 } from '@cosmjs/encoding' -import type { Coin } from '@cosmjs/stargate' +import type { Coin, logs } from '@cosmjs/stargate' import { coin } from '@cosmjs/stargate' import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx' @@ -9,6 +9,16 @@ export interface InstantiateResponse { readonly transactionHash: string } +export interface MigrateResponse { + readonly transactionHash: string + readonly logs: readonly logs.Log[] +} + +export interface RoyaltyInfo { + payment_address: string + share_bps: number +} + export type Expiration = { at_height: number } | { at_time: string } | { never: Record } export interface SG721Instance { @@ -65,11 +75,15 @@ export interface SG721Instance { revokeAll: (operator: string) => Promise /// Mint a new NFT, can only be called by the contract minter mint: (tokenId: string, owner: string, tokenURI?: string) => Promise //MintMsg + updateRoyaltyInfo: (royaltyInfo: RoyaltyInfo) => Promise /// Burn an NFT the sender has access to 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 { @@ -80,9 +94,51 @@ export interface Sg721Messages { approveAll: (operator: string, expires?: Expiration) => ApproveAllMessage revokeAll: (operator: string) => RevokeAllMessage mint: (tokenId: string, owner: string, tokenURI?: string) => MintMessage + updateRoyaltyInfo: (royaltyInfo: RoyaltyInfo) => UpdateRoyaltyInfoMessage burn: (tokenId: string) => BurnMessage batchBurn: (tokenIds: string) => BatchBurnMessage batchTransfer: (recipient: string, tokenIds: string) => BatchTransferMessage + updateTokenMetadata: (tokenId: string, tokenURI?: string) => UpdateTokenMetadataMessage + batchUpdateTokenMetadata: (tokenIds: string, tokenURI?: string) => BatchUpdateTokenMetadataMessage + freezeTokenMetadata: () => FreezeTokenMetadataMessage +} + +export interface UpdateRoyaltyInfoMessage { + sender: string + contract: string + msg: { + update_royalty_info: { + payment_address: string + share_bps: number + } + } + 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 TransferNFTMessage { @@ -206,6 +262,13 @@ export interface SG721Contract { admin?: string, ) => Promise + migrate: ( + senderAddress: string, + contractAddress: string, + codeId: number, + migrateMsg: Record, + ) => Promise + use: (contractAddress: string) => SG721Instance messages: (contractAddress: string) => Sg721Messages @@ -413,6 +476,22 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con return res.transactionHash } + const updateRoyaltyInfo = async (royaltyInfo: RoyaltyInfo): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + update_royalty_info: { + payment_address: royaltyInfo.payment_address, + share_bps: royaltyInfo.share_bps * 100, + }, + }, + 'auto', + '', + ) + return res.transactionHash + } + const burn = async (tokenId: string): Promise => { const res = await client.execute( txSigner, @@ -513,6 +592,81 @@ 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 ? `${baseURI}/${i}` : undefined }, + } + 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 ? `${baseURI}/${tokenNumbers[i]}` : undefined, + }, + } + 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 ? tokenURI : undefined, + }, + }, + 'auto', + '', + ) + return res.transactionHash + } + + const freezeTokenMetadata = async (): Promise => { + const res = await client.execute( + txSigner, + contractAddress, + { + freeze_token_metadata: {}, + }, + 'auto', + '', + ) + return res.transactionHash + } + return { contractAddress, ownerOf, @@ -521,6 +675,9 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con allOperators, numTokens, contractInfo, + updateTokenMetadata, + freezeTokenMetadata, + batchUpdateTokenMetadata, nftInfo, allNftInfo, tokens, @@ -534,12 +691,26 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con approveAll, revokeAll, mint, + updateRoyaltyInfo, burn, batchBurn, batchTransfer, } } + const migrate = async ( + senderAddress: string, + contractAddress: string, + codeId: number, + migrateMsg: Record, + ): Promise => { + const result = await client.migrate(senderAddress, contractAddress, codeId, migrateMsg, 'auto') + return { + transactionHash: result.transactionHash, + logs: result.logs, + } + } + const instantiate = async ( senderAddress: string, codeId: number, @@ -659,6 +830,20 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con } } + const updateRoyaltyInfo = (royaltyInfo: RoyaltyInfo) => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + update_royalty_info: { + payment_address: royaltyInfo.payment_address, + share_bps: royaltyInfo.share_bps * 100, + }, + }, + funds: [], + } + } + const burn = (tokenId: string) => { return { sender: txSigner, @@ -720,6 +905,60 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con } } + 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 ? `${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 ? `${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 ? tokenURI : '', + }, + }, + funds: [], + } + } + + const freezeTokenMetadata = () => { + return { + sender: txSigner, + contract: contractAddress, + msg: { + freeze_token_metadata: {}, + }, + funds: [], + } + } + return { transferNft, sendNft, @@ -728,11 +967,15 @@ export const SG721 = (client: SigningCosmWasmClient, txSigner: string): SG721Con approveAll, revokeAll, mint, + updateRoyaltyInfo, burn, batchBurn, batchTransfer, + batchUpdateTokenMetadata, + updateTokenMetadata, + freezeTokenMetadata, } } - return { use, instantiate, messages } + return { use, instantiate, migrate, messages } } diff --git a/contracts/sg721/useContract.ts b/contracts/sg721/useContract.ts index d5fee82..4eae403 100644 --- a/contracts/sg721/useContract.ts +++ b/contracts/sg721/useContract.ts @@ -2,7 +2,7 @@ import type { Coin } from '@cosmjs/proto-signing' import { useWallet } from 'contexts/wallet' import { useCallback, useEffect, useState } from 'react' -import type { SG721Contract, SG721Instance, Sg721Messages } from './contract' +import type { MigrateResponse, SG721Contract, SG721Instance, Sg721Messages } from './contract' import { SG721 as initContract } from './contract' interface InstantiateResponse { @@ -18,6 +18,7 @@ export interface UseSG721ContractProps { admin?: string, funds?: Coin[], ) => Promise + migrate: (contractAddress: string, codeId: number, migrateMsg: Record) => Promise use: (customAddress: string) => SG721Instance | undefined updateContractAddress: (contractAddress: string) => void messages: (contractAddress: string) => Sg721Messages | undefined @@ -55,6 +56,19 @@ export function useSG721Contract(): UseSG721ContractProps { [SG721, wallet], ) + const migrate = useCallback( + (contractAddress: string, codeId: number, migrateMsg: Record): Promise => { + return new Promise((resolve, reject) => { + if (!SG721) { + reject(new Error('Contract is not initialized.')) + return + } + SG721.migrate(wallet.address, contractAddress, codeId, migrateMsg).then(resolve).catch(reject) + }) + }, + [SG721, wallet], + ) + const use = useCallback( (customAddress = ''): SG721Instance | undefined => { return SG721?.use(address || customAddress) @@ -74,5 +88,6 @@ export function useSG721Contract(): UseSG721ContractProps { use, updateContractAddress, messages, + migrate, } } diff --git a/pages/contracts/sg721/migrate.tsx b/pages/contracts/sg721/migrate.tsx new file mode 100644 index 0000000..ce37207 --- /dev/null +++ b/pages/contracts/sg721/migrate.tsx @@ -0,0 +1,132 @@ +import { Button } from 'components/Button' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { useExecuteComboboxState } from 'components/contracts/sg721/ExecuteCombobox.hooks' +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { JsonPreview } from 'components/JsonPreview' +import { LinkTabs } from 'components/LinkTabs' +import { sg721LinkTabs } from 'components/LinkTabs.data' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { MigrateResponse } from 'contracts/sg721' +import type { NextPage } from 'next' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import type { FormEvent } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaArrowRight } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +const Sg721MigratePage: NextPage = () => { + const { sg721: contract } = useContracts() + const wallet = useWallet() + + const [lastTx, setLastTx] = useState('') + + const comboboxState = useExecuteComboboxState() + const type = comboboxState.value?.id + const codeIdState = useNumberInputState({ + id: 'code-id', + name: 'code-id', + title: 'Code ID', + subtitle: 'Code ID of the New Sg721 contract', + placeholder: '1', + }) + + const contractState = useInputState({ + id: 'contract-address', + name: 'contract-address', + title: 'Sg721 Address', + subtitle: 'Address of the Sg721 contract', + }) + const contractAddress = contractState.value + + const { data, isLoading, mutate } = useMutation( + async (event: FormEvent): Promise => { + event.preventDefault() + if (!contract) { + throw new Error('Smart contract connection failed') + } + if (!wallet.initialized) { + throw new Error('Please connect your wallet.') + } + + const migrateMsg = {} + return toast.promise(contract.migrate(contractAddress, codeIdState.value, migrateMsg), { + error: `Migration failed!`, + loading: 'Executing message...', + success: (tx) => { + if (tx) { + setLastTx(tx.transactionHash) + } + return `Transaction success!` + }, + }) + }, + { + onError: (error) => { + toast.error(String(error), { style: { maxWidth: 'none' } }) + }, + }, + ) + + const router = useRouter() + + useEffect(() => { + if (contractAddress.length > 0) { + void router.replace({ query: { contractAddress } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractAddress]) + useEffect(() => { + const initial = new URL(document.URL).searchParams.get('contractAddress') + if (initial && initial.length > 0) contractState.onChange(initial) + }, []) + + return ( + + + + + + + + + + + + + } type="submit"> + Execute + + + + + + + + + + + + ) +} + +export default withMetadata(Sg721MigratePage, { center: false })