diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index 71a514d27d9..4516a4fcaff 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -362,7 +362,11 @@ export function reportAssetCreationFailed( properties: { contractType: AssetContractType; error: string } & ( | { assetType: "nft"; - step: "deploy-contract" | "mint-nfts" | "set-claim-conditions"; + step: + | "deploy-contract" + | "mint-nfts" + | "set-claim-conditions" + | "set-admins"; } | { assetType: "coin"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/SocialUrls.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/SocialUrls.tsx index c709b30bf4d..9990f1718c7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/SocialUrls.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/SocialUrls.tsx @@ -34,7 +34,7 @@ export function SocialUrlsFieldset(props: {

Social URLs

{fields.length > 0 && ( -
+
{fields.map((field, index) => (
(props: { + form: UseFormReturn; +}) { + // T contains all properties of WithAdmins, so this is ok + const form = props.form as unknown as UseFormReturn; + const account = useActiveAccount(); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "admins", + }); + + const handleAddAddress = () => { + append({ address: "" }); + }; + + const handleRemoveAddress = (index: number) => { + const field = fields[index]; + if (field?.address === account?.address) { + return; // Don't allow removing the connected address + } + remove(index); + }; + + return ( +
+
+

Admins

+

+ These wallets will have authority on the token +

+
+ + {fields.length > 0 && ( +
+ {fields.map((field, index) => ( +
+
+ ( + + + + + + + )} + /> +
+ + +
+ ))} +
+ )} + + + + {form.watch("admins").length === 0 && ( +

+ At least one admin address is required +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/schema.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/schema.ts index c344c60dd44..251d4cc241d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/schema.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/schema.ts @@ -22,6 +22,22 @@ export const socialUrlsSchema = z.array( }), ); +export const addressArraySchema = z.array( + z.object({ + address: z.string().refine( + (value) => { + if (isAddress(value)) { + return true; + } + return false; + }, + { + message: "Invalid address", + }, + ), + }), +); + export const addressSchema = z.string().refine( (value) => { if (isAddress(value)) { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts index 2414f069442..a48db4a3bd7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts @@ -1,9 +1,15 @@ -import { isAddress } from "thirdweb"; import * as z from "zod"; -import { socialUrlsSchema } from "../../_common/schema"; +import { + addressArraySchema, + addressSchema, + socialUrlsSchema, +} from "../../_common/schema"; import type { NFTMetadataWithPrice } from "../upload-nfts/batch-upload/process-files"; export const nftCollectionInfoFormSchema = z.object({ + admins: addressArraySchema.refine((addresses) => addresses.length > 0, { + message: "At least one admin is required", + }), chain: z.string().min(1, "Chain is required"), description: z.string().optional(), image: z.instanceof(File).optional(), @@ -12,14 +18,6 @@ export const nftCollectionInfoFormSchema = z.object({ symbol: z.string(), }); -const addressSchema = z.string().refine((value) => { - if (isAddress(value)) { - return true; - } - - return false; -}); - export const nftSalesSettingsFormSchema = z.object({ primarySaleRecipient: addressSchema, royaltyBps: z.coerce.number().min(0).max(10000), @@ -37,6 +35,14 @@ export type CreateNFTCollectionAllValues = { }; export type CreateNFTCollectionFunctions = { + setAdmins: (values: { + contractAddress: string; + contractType: "DropERC721" | "DropERC1155"; + admins: { + address: string; + }[]; + chain: string; + }) => Promise; erc721: { deployContract: (values: CreateNFTCollectionAllValues) => Promise<{ contractAddress: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/collection-info/nft-collection-info-fieldset.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/collection-info/nft-collection-info-fieldset.tsx index 2399888a1b8..6e3f8a502e4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/collection-info/nft-collection-info-fieldset.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/collection-info/nft-collection-info-fieldset.tsx @@ -10,6 +10,7 @@ import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; import { Form } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset"; import { SocialUrlsFieldset } from "../../_common/SocialUrls"; import { StepCard } from "../../_common/step-card"; import type { NFTCollectionInfoFormValues } from "../_common/form"; @@ -126,6 +127,8 @@ export function NFTCollectionInfoFieldset(props: {
+ + diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx index c3a40d3c18f..9e7ad28d13a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx @@ -8,7 +8,7 @@ import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient, } from "thirdweb"; -import { useActiveAccount } from "thirdweb/react"; +import { useActiveAccount, useActiveWalletChain } from "thirdweb/react"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; import type { Team } from "@/api/team"; import { @@ -163,11 +163,16 @@ export function CreateNFTPageUI(props: { } function useNFTCollectionInfoForm() { + const chain = useActiveWalletChain(); + const account = useActiveAccount(); return useForm({ - resolver: zodResolver(nftCollectionInfoFormSchema), - reValidateMode: "onChange", - values: { - chain: "1", + defaultValues: { + admins: [ + { + address: account?.address || "", + }, + ], + chain: chain?.id.toString() || "1", description: "", image: undefined, name: "", @@ -183,5 +188,7 @@ function useNFTCollectionInfoForm() { ], symbol: "", }, + resolver: zodResolver(nftCollectionInfoFormSchema), + reValidateMode: "onChange", }); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx index 7db1b21e18b..ccb54f5ae5b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx @@ -18,6 +18,7 @@ import { lazyMint as lazyMint1155, setClaimConditions as setClaimConditions1155, } from "thirdweb/extensions/erc1155"; +import { grantRole } from "thirdweb/extensions/permissions"; import { useActiveAccount } from "thirdweb/react"; import { maxUint256 } from "thirdweb/utils"; import { revalidatePathAction } from "@/actions/revalidate"; @@ -332,6 +333,60 @@ export function CreateNFTPage(props: { } } + async function handleSetAdmins(params: { + contractAddress: string; + contractType: "DropERC721" | "DropERC1155"; + admins: { + address: string; + }[]; + chain: string; + }) { + const { contract, activeAccount } = getContractAndAccount({ + chain: params.chain, + }); + + // remove the current account from the list - its already an admin, don't have to add it again + const adminsToAdd = params.admins.filter( + (admin) => admin.address !== activeAccount.address, + ); + + const encodedTxs = await Promise.all( + adminsToAdd.map((admin) => { + const tx = grantRole({ + contract, + role: "admin", + targetAccountAddress: admin.address, + }); + + return encode(tx); + }), + ); + + const tx = multicall({ + contract, + data: encodedTxs, + }); + + try { + await sendAndConfirmTransaction({ + account: activeAccount, + transaction: tx, + }); + } catch (e) { + const errorMessage = parseError(e); + console.error(errorMessage); + + reportAssetCreationFailed({ + assetType: "nft", + contractType: params.contractType, + error: errorMessage, + step: "set-admins", + }); + + throw e; + } + } + return ( { return handleSetClaimConditionsERC721({ formValues, @@ -373,6 +427,7 @@ export function CreateNFTPage(props: { return handleSetClaimConditionsERC1155(params); }, }, + setAdmins: handleSetAdmins, }} onLaunchSuccess={() => { revalidatePathAction( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx index 7010f4c0e39..3828489267d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx @@ -8,7 +8,12 @@ import { import Link from "next/link"; import { useMemo, useRef, useState } from "react"; import { defineChain, type ThirdwebClient } from "thirdweb"; -import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react"; +import { + TokenProvider, + TokenSymbol, + useActiveAccount, + useActiveWallet, +} from "thirdweb/react"; import { reportAssetCreationFailed, reportAssetCreationSuccessful, @@ -41,6 +46,7 @@ import type { const stepIds = { "deploy-contract": "deploy-contract", "mint-nfts": "mint-nfts", + "set-admins": "set-admins", "set-claim-conditions": "set-claim-conditions", } as const; @@ -61,7 +67,8 @@ export function LaunchNFT(props: { const [isModalOpen, setIsModalOpen] = useState(false); const activeWallet = useActiveWallet(); const walletRequiresApproval = activeWallet?.id !== "inApp"; - const [contractLink, setContractLink] = useState(null); + const account = useActiveAccount(); + const contractAddressRef = useRef(null); function updateStatus( index: number, @@ -108,6 +115,19 @@ export function LaunchNFT(props: { }, ]; + if ( + account && + formValues.collectionInfo.admins.some( + (admin) => admin.address !== account.address, + ) + ) { + initialSteps.push({ + id: stepIds["set-admins"], + label: "Set admins", + status: { type: "idle" }, + }); + } + setSteps(initialSteps); setIsModalOpen(true); executeSteps(initialSteps, 0); @@ -134,13 +154,15 @@ export function LaunchNFT(props: { return shouldDeployERC721 ? "erc721" : "erc1155"; }, [formValues.nfts]); + const contractLink = contractAddressRef.current + ? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}` + : null; + async function executeStep(steps: MultiStepState[], stepId: StepId) { if (stepId === "deploy-contract") { const result = await props.createNFTFunctions[ercType].deployContract(formValues); - setContractLink( - `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${result.contractAddress}`, - ); + contractAddressRef.current = result.contractAddress; } else if (stepId === "set-claim-conditions") { if (ercType === "erc721") { await props.createNFTFunctions.erc721.setClaimConditions(formValues); @@ -185,6 +207,18 @@ export function LaunchNFT(props: { } } else if (stepId === "mint-nfts") { await props.createNFTFunctions[ercType].lazyMintNFTs(formValues); + } else if (stepId === "set-admins") { + // this is type guard, this can never happen + if (!contractAddressRef.current) { + throw new Error("Contract address not set"); + } + + await props.createNFTFunctions.setAdmins({ + admins: formValues.collectionInfo.admins, + chain: formValues.collectionInfo.chain, + contractAddress: contractAddressRef.current, + contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155", + }); } } @@ -304,7 +338,7 @@ export function LaunchNFT(props: { dialogCloseClassName="hidden" >
- + Status @@ -412,6 +446,24 @@ export function LaunchNFT(props: { } /> + + 1 ? "Admins" : "Admin" + } + > +
+ {formValues.collectionInfo.admins.map((admin) => ( + + ))} +
+
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/single-upload/single-upload-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/single-upload/single-upload-nft.tsx index eb766bcaaf9..53406e779cc 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/single-upload/single-upload-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/single-upload/single-upload-nft.tsx @@ -4,7 +4,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowLeftIcon, ArrowRightIcon, AsteriskIcon } from "lucide-react"; import { useId } from "react"; import { useForm } from "react-hook-form"; -import type { ThirdwebClient } from "thirdweb"; +import { + getAddress, + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, +} from "thirdweb"; import { FileInput } from "@/components/blocks/FileInput"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; import { TokenSelector } from "@/components/blocks/TokenSelector"; @@ -32,6 +36,8 @@ import type { NFTMetadataWithPrice } from "../batch-upload/process-files"; import { nftWithPriceSchema } from "../schema"; import { AttributesFieldset } from "./attributes"; +const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + export function SingleUploadNFT(props: { client: ThirdwebClient; onNext: () => void; @@ -46,6 +52,9 @@ export function SingleUploadNFT(props: { description: "", image: undefined, name: "", + price_amount: "1", + price_currency: nativeTokenAddress, + supply: "1", }, resolver: zodResolver(nftWithPriceSchema), reValidateMode: "onChange", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx index f97b4e06df4..3e0fb138e99 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx @@ -8,6 +8,7 @@ import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient, } from "thirdweb"; +import { useActiveWalletChain } from "thirdweb/react"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; import type { Team } from "@/api/team"; import { @@ -44,12 +45,11 @@ export function CreateTokenAssetPageUI(props: { const [step, setStep] = useState<"token-info" | "distribution" | "launch">( "token-info", ); + const activeWalletChain = useActiveWalletChain(); const tokenInfoForm = useForm({ - resolver: zodResolver(tokenInfoFormSchema), - reValidateMode: "onChange", - values: { - chain: "1", + defaultValues: { + chain: activeWalletChain?.id.toString() || "1", description: "", image: undefined, name: "", @@ -65,6 +65,8 @@ export function CreateTokenAssetPageUI(props: { ], symbol: "", }, + resolver: zodResolver(tokenInfoFormSchema), + reValidateMode: "onChange", }); const tokenDistributionForm = useForm({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx index fd3aa75cddd..bbd5f0cea00 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx @@ -262,7 +262,7 @@ export function LaunchTokenStatus(props: { dialogCloseClassName="hidden" >
- + Status