diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts index 474b31d404b..b147255e8f0 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts @@ -24,6 +24,12 @@ export function getContractPageSidebarLinks(data: { hide: !data.metadata.isModularCore, exactMatch: true, }, + { + label: "Split Fees", + href: `${layoutPrefix}/split-fees`, + hide: !data.metadata.isModularCore, + exactMatch: true, + }, { label: "Code Snippets", href: `${layoutPrefix}/code`, diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Claimable.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Claimable.tsx index 643569d6ad8..d7c820882ae 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Claimable.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Claimable.tsx @@ -24,7 +24,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { TransactionButton } from "components/buttons/TransactionButton"; import { addDays, fromUnixTime } from "date-fns"; import { useAllChainsData } from "hooks/chains/allChains"; @@ -40,11 +40,13 @@ import { useFieldArray, useForm } from "react-hook-form"; import { NATIVE_TOKEN_ADDRESS, type PreparedTransaction, + type ThirdwebContract, ZERO_ADDRESS, getContract, sendAndConfirmTransaction, toTokens, } from "thirdweb"; +import { getBytecode } from "thirdweb/contract"; import { decimals } from "thirdweb/extensions/erc20"; import { ClaimableERC20, @@ -52,7 +54,9 @@ import { ClaimableERC1155, } from "thirdweb/modules"; import { useActiveAccount, useReadContract } from "thirdweb/react"; +import { isContractDeployed } from "thirdweb/utils"; import { z } from "zod"; +import ConfigureSplit from "../../split-fees/ConfigureSplitFees"; import { addressSchema } from "../zod-schemas"; import { CurrencySelector } from "./CurrencySelector"; import { ModuleCardUI, type ModuleCardUIProps } from "./module-card"; @@ -71,6 +75,10 @@ export type ClaimConditionValue = { const positiveIntegerRegex = /^[0-9]\d*$/; +const splitWalletBytecode = + "0x3d3d3d3d363d3d37363d7341dc1be6e4c7698f46268251b88b1f789aa9df265af43d3d93803e602a57fd5bf3"; +("0x3d3d3d3d363d3d37363d73207d879bae9cbf900d5e1eec04019613cedc25455af43d3d93803e602a57fd5bf3"); + function ClaimableModule(props: ModuleInstanceProps) { const { contract, ownerAccount } = props; const account = useActiveAccount(); @@ -108,6 +116,28 @@ function ClaimableModule(props: ModuleInstanceProps) { }, ); + const splitRecipientContract = getContract({ + address: primarySaleRecipientQuery.data || "", + chain: contract.chain, + client: contract.client, + }); + + const isSplitRecipientQuery = useQuery({ + queryKey: ["isSplitRecipient", primarySaleRecipientQuery.data], + queryFn: async () => { + if (!primarySaleRecipientQuery.data) return false; + + const contractDeployed = await isContractDeployed(splitRecipientContract); + if (!contractDeployed) return false; + + const bytecode = await getBytecode(splitRecipientContract); + if (bytecode !== splitWalletBytecode) return false; + + return true; + }, + enabled: !!primarySaleRecipientQuery.data, + }); + const noClaimConditionSet = claimConditionQuery.data?.availableSupply === 0n && claimConditionQuery.data?.allowlistMerkleRoot === @@ -254,9 +284,14 @@ function ClaimableModule(props: ModuleInstanceProps) { <ClaimableModuleUI {...props} primarySaleRecipientSection={{ - data: primarySaleRecipientQuery.data - ? { primarySaleRecipient: primarySaleRecipientQuery.data } - : undefined, + data: + primarySaleRecipientQuery.data && !isSplitRecipientQuery.isLoading + ? { + primarySaleRecipient: primarySaleRecipientQuery.data, + isSplitRecipient: isSplitRecipientQuery.data || false, + referenceContract: props.contract.address, + } + : undefined, setPrimarySaleRecipient, }} claimConditionSection={{ @@ -279,7 +314,7 @@ function ClaimableModule(props: ModuleInstanceProps) { }} isOwnerAccount={!!ownerAccount} name={props.contractInfo.name} - contractChainId={props.contract.chain.id} + contract={props.contract} setTokenId={setTokenId} isValidTokenId={isValidTokenId} noClaimConditionSet={noClaimConditionSet} @@ -294,7 +329,7 @@ export function ClaimableModuleUI( props: Omit<ModuleCardUIProps, "children" | "updateButton"> & { isOwnerAccount: boolean; name: string; - contractChainId: number; + contract: ThirdwebContract; setTokenId: Dispatch<SetStateAction<string>>; isValidTokenId: boolean; noClaimConditionSet: boolean; @@ -305,6 +340,8 @@ export function ClaimableModuleUI( data: | { primarySaleRecipient: string; + isSplitRecipient: boolean; + referenceContract: string; } | undefined; }; @@ -340,7 +377,7 @@ export function ClaimableModuleUI( <MintNFTSection mint={props.mintSection.mint} name={props.name} - contractChainId={props.contractChainId} + contractChainId={props.contract.chain.id} /> </AccordionContent> </AccordionItem> @@ -375,7 +412,7 @@ export function ClaimableModuleUI( } update={props.claimConditionSection.setClaimCondition} name={props.name} - chainId={props.contractChainId} + chainId={props.contract.chain.id} noClaimConditionSet={props.noClaimConditionSet} currencyDecimals={ props.claimConditionSection.data?.currencyDecimals @@ -409,7 +446,13 @@ export function ClaimableModuleUI( update={ props.primarySaleRecipientSection.setPrimarySaleRecipient } - contractChainId={props.contractChainId} + contract={props.contract} + isSplitRecipient={ + props.primarySaleRecipientSection.data?.isSplitRecipient + } + referenceContract={ + props.primarySaleRecipientSection.data?.referenceContract + } /> ) : ( <Skeleton className="h-[74px]" /> @@ -470,8 +513,6 @@ function ClaimConditionSection(props: { const { idToChain } = useAllChainsData(); const chain = idToChain.get(props.chainId); const { tokenId, claimCondition } = props; - const [addClaimConditionButtonClicked, setAddClaimConditionButtonClicked] = - useState(false); const form = useForm<ClaimConditionFormValues>({ resolver: zodResolver(claimConditionFormSchema), @@ -544,193 +585,181 @@ function ClaimConditionSection(props: { return ( <div className="flex flex-col gap-6"> - {props.noClaimConditionSet && !addClaimConditionButtonClicked && ( - <> - <Alert variant="warning"> - <CircleAlertIcon className="size-5 max-sm:hidden" /> - <AlertTitle>No Claim Condition Set</AlertTitle> - <AlertDescription> - You have not set a claim condition for this token. You can set a - claim condition by clicking the "Set Claim Condition" button. - </AlertDescription> - </Alert> - - <Button - onClick={() => setAddClaimConditionButtonClicked(true)} - variant="outline" - className="w-full" - > - Add Claim Condition - </Button> - </> + {props.noClaimConditionSet && ( + <Alert variant="warning"> + <CircleAlertIcon className="size-5 max-sm:hidden" /> + <AlertTitle>No Claim Condition Set</AlertTitle> + <AlertDescription> + You have not set a claim condition for this token. You can set a + claim condition by clicking the "Set Claim Condition" button. + </AlertDescription> + </Alert> )} - {(!props.noClaimConditionSet || addClaimConditionButtonClicked) && ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="flex flex-col gap-6"> - <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> - <FormField - control={form.control} - name="pricePerToken" - render={({ field }) => ( - <FormItem className="flex-1"> - <FormLabel>Price Per Token</FormLabel> - <FormControl> - <Input {...field} disabled={!props.isOwnerAccount} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="flex flex-col gap-6"> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + <FormField + control={form.control} + name="pricePerToken" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>Price Per Token</FormLabel> + <FormControl> + <Input {...field} disabled={!props.isOwnerAccount} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - <FormField - control={form.control} - name="currencyAddress" - render={({ field }) => ( - <FormItem> - <FormLabel>Currency</FormLabel> - <CurrencySelector chain={chain} field={field} /> - </FormItem> - )} - /> - </div> + <FormField + control={form.control} + name="currencyAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>Currency</FormLabel> + <CurrencySelector chain={chain} field={field} /> + </FormItem> + )} + /> + </div> - <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> - <FormField - control={form.control} - name="maxClaimableSupply" - render={({ field }) => ( - <FormItem className="flex-1"> - <FormLabel>Max Available Supply</FormLabel> - <FormControl> - <Input {...field} disabled={!props.isOwnerAccount} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + <FormField + control={form.control} + name="maxClaimableSupply" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>Max Available Supply</FormLabel> + <FormControl> + <Input {...field} disabled={!props.isOwnerAccount} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - <FormField - control={form.control} - name="maxClaimablePerWallet" - render={({ field }) => ( - <FormItem className="flex-1"> - <FormLabel>Maximum number of mints per wallet</FormLabel> - <FormControl> - <Input {...field} disabled={!props.isOwnerAccount} /> - </FormControl> - <FormMessage /> - </FormItem> - )} + <FormField + control={form.control} + name="maxClaimablePerWallet" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>Maximum number of mints per wallet</FormLabel> + <FormControl> + <Input {...field} disabled={!props.isOwnerAccount} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormFieldSetup + htmlFor="duration" + label="Duration" + isRequired + errorMessage={ + form.formState.errors?.startTime?.message || + form.formState.errors?.endTime?.message + } + > + <div> + <DatePickerWithRange + from={startTime} + to={endTime} + setFrom={(from: Date) => form.setValue("startTime", from)} + setTo={(to: Date) => form.setValue("endTime", to)} /> </div> - - <FormFieldSetup - htmlFor="duration" - label="Duration" - isRequired - errorMessage={ - form.formState.errors?.startTime?.message || - form.formState.errors?.endTime?.message - } - > - <div> - <DatePickerWithRange - from={startTime} - to={endTime} - setFrom={(from: Date) => form.setValue("startTime", from)} - setTo={(to: Date) => form.setValue("endTime", to)} - /> - </div> - </FormFieldSetup> - - <Separator /> - - <div className="w-full space-y-2"> - <FormLabel>Allowlist</FormLabel> - <div className="flex flex-col gap-3"> - {allowListFields.fields.map((fieldItem, index) => ( - <div className="flex items-start gap-3" key={fieldItem.id}> - <FormField - control={form.control} - name={`allowList.${index}.address`} - render={({ field }) => ( - <FormItem className="grow"> - <FormControl> - <Input - placeholder="0x..." - {...field} - disabled={!props.isOwnerAccount} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <ToolTipLabel label="Remove address"> - <Button - variant="outline" - className="!text-destructive-text bg-background" - onClick={() => { - allowListFields.remove(index); - }} - disabled={!props.isOwnerAccount} - > - <Trash2Icon className="size-4" /> - </Button> - </ToolTipLabel> - </div> - ))} - - {allowListFields.fields.length === 0 && ( - <Alert variant="warning"> - <CircleAlertIcon className="size-5 max-sm:hidden" /> - <AlertTitle className="max-sm:!pl-0"> - No allowlist configured - </AlertTitle> - </Alert> - )} - </div> - - <div className="h-1" /> - - <div className="flex gap-3"> - <Button - variant="outline" - size="sm" - onClick={() => { - // add admin by default if adding the first input - allowListFields.append({ - address: "", - }); - }} - className="gap-2" - disabled={!props.isOwnerAccount} - > - <PlusIcon className="size-3" /> - Add Address - </Button> - </div> + </FormFieldSetup> + + <Separator /> + + <div className="w-full space-y-2"> + <FormLabel>Allowlist</FormLabel> + <div className="flex flex-col gap-3"> + {allowListFields.fields.map((fieldItem, index) => ( + <div className="flex items-start gap-3" key={fieldItem.id}> + <FormField + control={form.control} + name={`allowList.${index}.address`} + render={({ field }) => ( + <FormItem className="grow"> + <FormControl> + <Input + placeholder="0x..." + {...field} + disabled={!props.isOwnerAccount} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <ToolTipLabel label="Remove address"> + <Button + variant="outline" + className="!text-destructive-text bg-background" + onClick={() => { + allowListFields.remove(index); + }} + disabled={!props.isOwnerAccount} + > + <Trash2Icon className="size-4" /> + </Button> + </ToolTipLabel> + </div> + ))} + + {allowListFields.fields.length === 0 && ( + <Alert variant="warning"> + <CircleAlertIcon className="size-5 max-sm:hidden" /> + <AlertTitle className="max-sm:!pl-0"> + No allowlist configured + </AlertTitle> + </Alert> + )} </div> - <div className="flex justify-end"> - <TransactionButton + <div className="h-1" /> + + <div className="flex gap-3"> + <Button + variant="outline" size="sm" - className="min-w-24" - disabled={updateMutation.isPending || !props.isOwnerAccount} - type="submit" - isPending={updateMutation.isPending} - txChainID={props.chainId} - transactionCount={1} + onClick={() => { + // add admin by default if adding the first input + allowListFields.append({ + address: "", + }); + }} + className="gap-2" + disabled={!props.isOwnerAccount} > - Update - </TransactionButton> + <PlusIcon className="size-3" /> + Add Address + </Button> </div> </div> - </form>{" "} - </Form> - )} + + <div className="flex justify-end"> + <TransactionButton + size="sm" + className="min-w-24" + disabled={updateMutation.isPending || !props.isOwnerAccount} + type="submit" + isPending={updateMutation.isPending} + txChainID={props.chainId} + transactionCount={1} + > + Update + </TransactionButton> + </div> + </div> + </form>{" "} + </Form> </div> ); } @@ -747,7 +776,9 @@ function PrimarySaleRecipientSection(props: { primarySaleRecipient: string | undefined; update: (values: PrimarySaleRecipientFormValues) => Promise<void>; isOwnerAccount: boolean; - contractChainId: number; + isSplitRecipient?: boolean; + contract: ThirdwebContract; + referenceContract: string; }) { const form = useForm<PrimarySaleRecipientFormValues>({ resolver: zodResolver(primarySaleRecipientFormSchema), @@ -771,6 +802,11 @@ function PrimarySaleRecipientSection(props: { updateMutation.mutateAsync(form.getValues()); }; + const postSplitConfigure = async (splitWallet: string) => { + form.setValue("primarySaleRecipient", splitWallet); + await onSubmit(); + }; + return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> @@ -781,11 +817,32 @@ function PrimarySaleRecipientSection(props: { <FormItem className="flex-1"> <FormLabel>Sale Recipient</FormLabel> <FormControl> - <Input - placeholder="0x..." - {...field} - disabled={!props.isOwnerAccount} - /> + <div className="flex"> + <Input + placeholder="0x..." + {...field} + disabled={!props.isOwnerAccount} + className={ + props.isOwnerAccount ? "rounded-r-none border-r-0" : "" + } + /> + {props.isOwnerAccount && ( + <ConfigureSplit + isNewSplit={!props.isSplitRecipient} + splitWallet={props.primarySaleRecipient || ""} + referenceContract={props.contract} + postSplitConfigure={ + props.isSplitRecipient ? undefined : postSplitConfigure + } + > + <Button className="rounded-lg rounded-l-none border border-l-0 bg-foreground"> + {props.isSplitRecipient + ? "Update Split" + : "Create Split"} + </Button> + </ConfigureSplit> + )} + </div> </FormControl> <FormMessage /> </FormItem> @@ -805,7 +862,7 @@ function PrimarySaleRecipientSection(props: { } type="submit" isPending={updateMutation.isPending} - txChainID={props.contractChainId} + txChainID={props.contract.chain.id} transactionCount={1} > Update diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx index 32a639e2e1b..6f2d1ae1d40 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx @@ -6,6 +6,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Alert, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; import { Form, @@ -21,13 +22,19 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { TransactionButton } from "components/buttons/TransactionButton"; import { useTxNotifications } from "hooks/useTxNotifications"; import { CircleAlertIcon } from "lucide-react"; import { useCallback } from "react"; import { useForm } from "react-hook-form"; -import { type PreparedTransaction, sendAndConfirmTransaction } from "thirdweb"; +import { + type PreparedTransaction, + type ThirdwebContract, + getContract, + sendAndConfirmTransaction, +} from "thirdweb"; +import { getBytecode } from "thirdweb/contract"; import { MintableERC20, MintableERC721, @@ -35,9 +42,11 @@ import { } from "thirdweb/modules"; import { grantRoles, hasAllRoles } from "thirdweb/modules"; import { useReadContract } from "thirdweb/react"; +import { isContractDeployed } from "thirdweb/utils"; import type { NFTMetadataInputLimited } from "types/modified-types"; import { parseAttributes } from "utils/parseAttributes"; import { z } from "zod"; +import ConfigureSplit from "../../split-fees/ConfigureSplitFees"; import { addressSchema } from "../zod-schemas"; import { ModuleCardUI, type ModuleCardUIProps } from "./module-card"; import type { ModuleInstanceProps } from "./module-instance"; @@ -69,6 +78,9 @@ const isValidNft = (values: MintFormValues) => const MINTER_ROLE = 1n; +const splitWalletBytecode = + "0x6080604052600436106100b15760003560e01c8063f04e283e11610069578063f61510891161004e578063f615108914610163578063f7448a3114610197578063fee81cf4146101b757600080fd5b8063f04e283e1461013d578063f2fde38b1461015057600080fd5b806354d1f13d1161009a57806354d1f13d146100d3578063715018a6146100db5780638da5cb5b146100e357600080fd5b806325692962146100b65780634329db46146100c0575b600080fd5b6100be6101f8565b005b6100be6100ce366004610629565b610248565b6100be6103a9565b6100be6103e5565b3480156100ef57600080fd5b507fffffffffffffffffffffffffffffffffffffffffffffffffffffffff74873927545b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020015b60405180910390f35b6100be61014b36600461066b565b6103f9565b6100be61015e36600461066b565b610439565b34801561016f57600080fd5b506101137f000000000000000000000000b0293be0b3d5d5946cfa074b45d507319659c95f81565b3480156101a357600080fd5b506100be6101b236600461068d565b610460565b3480156101c357600080fd5b506101ea6101d236600461066b565b63389a75e1600c908152600091909152602090205490565b604051908152602001610134565b60006202a30067ffffffffffffffff164201905063389a75e1600c5233600052806020600c2055337fdbf36a107da19e49527a7176a1babf963b4b0ff8cde35ee35d6cd8f1f9ac7e1d600080a250565b3373ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000b0293be0b3d5d5946cfa074b45d507319659c95f16146102b7576040517f6fd1f78c00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60007f000000000000000000000000b0293be0b3d5d5946cfa074b45d507319659c95f73ffffffffffffffffffffffffffffffffffffffff168260405160006040518083038185875af1925050503d8060008114610331576040519150601f19603f3d011682016040523d82523d6000602084013e610336565b606091505b50509050806103a5576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f4661696c656420746f2073656e64204574686572000000000000000000000000604482015260640160405180910390fd5b5050565b63389a75e1600c523360005260006020600c2055337ffa7b8eab7da67f412cc9575ed43464468f9bfbae89d1675917346ca6d8fe3c92600080a2565b6103ed61058d565b6103f760006105c3565b565b61040161058d565b63389a75e1600c52806000526020600c20805442111561042957636f5e88186000526004601cfd5b60009055610436816105c3565b50565b61044161058d565b8060601b61045757637448fbae6000526004601cfd5b610436816105c3565b3373ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000b0293be0b3d5d5946cfa074b45d507319659c95f16146104cf576040517f6fd1f78c00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6040517fa9059cbb00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000b0293be0b3d5d5946cfa074b45d507319659c95f811660048301526024820183905283169063a9059cbb906044016020604051808303816000875af1158015610564573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061058891906106b7565b505050565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffff748739275433146103f7576382b429006000526004601cfd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffff74873927805473ffffffffffffffffffffffffffffffffffffffff9092169182907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0600080a355565b60006020828403121561063b57600080fd5b5035919050565b803573ffffffffffffffffffffffffffffffffffffffff8116811461066657600080fd5b919050565b60006020828403121561067d57600080fd5b61068682610642565b9392505050565b600080604083850312156106a057600080fd5b6106a983610642565b946020939093013593505050565b6000602082840312156106c957600080fd5b8151801515811461068657600080fdfea264697066735822122007cf0f59b98eb9ce1e88de58df5114d9b37a2fb3de097a3520bda4a6ac89592664736f6c634300081a0033"; + function MintableModule(props: ModuleInstanceProps) { const { contract, ownerAccount } = props; @@ -86,6 +98,28 @@ function MintableModule(props: ModuleInstanceProps) { roles: MINTER_ROLE, }); + const splitRecipientContract = getContract({ + address: primarySaleRecipientQuery.data || "", + chain: contract.chain, + client: contract.client, + }); + + const isSplitRecipientQuery = useQuery({ + queryKey: ["isSplitRecipient", primarySaleRecipientQuery.data], + queryFn: async () => { + if (!primarySaleRecipientQuery.data) return false; + + const contractDeployed = await isContractDeployed(splitRecipientContract); + if (!contractDeployed) return false; + + const bytecode = await getBytecode(splitRecipientContract); + if (bytecode !== splitWalletBytecode) return false; + + return true; + }, + enabled: !!primarySaleRecipientQuery.data, + }); + const isBatchMetadataInstalled = !!props.allModuleContractInfo.find( (module) => module.name.includes("BatchMetadata"), ); @@ -172,28 +206,32 @@ function MintableModule(props: ModuleInstanceProps) { return ( <MintableModuleUI {...props} - isPending={primarySaleRecipientQuery.isPending} + isPending={ + primarySaleRecipientQuery.isPending || isSplitRecipientQuery.isPending + } primarySaleRecipient={primarySaleRecipientQuery.data} + isSplitRecipient={isSplitRecipientQuery.data} updatePrimaryRecipient={update} mint={mint} isOwnerAccount={!!ownerAccount} name={props.contractInfo.name} isBatchMetadataInstalled={isBatchMetadataInstalled} - contractChainId={contract.chain.id} + contract={contract} /> ); } export function MintableModuleUI( props: Omit<ModuleCardUIProps, "children" | "updateButton"> & { - primarySaleRecipient: string | undefined; + primarySaleRecipient?: string | undefined; + isSplitRecipient?: boolean; isPending: boolean; isOwnerAccount: boolean; updatePrimaryRecipient: (values: UpdateFormValues) => Promise<void>; mint: (values: MintFormValues) => Promise<void>; name: string; isBatchMetadataInstalled: boolean; - contractChainId: number; + contract: ThirdwebContract; }, ) { return ( @@ -215,7 +253,7 @@ export function MintableModuleUI( mint={props.mint} name={props.name} isBatchMetadataInstalled={props.isBatchMetadataInstalled} - contractChainId={props.contractChainId} + contractChainId={props.contract.chain.id} /> )} {!props.isOwnerAccount && ( @@ -240,8 +278,9 @@ export function MintableModuleUI( <PrimarySalesSection isOwnerAccount={props.isOwnerAccount} primarySaleRecipient={props.primarySaleRecipient} + isSplitRecipient={props.isSplitRecipient} update={props.updatePrimaryRecipient} - contractChainId={props.contractChainId} + contract={props.contract} /> </AccordionContent> </AccordionItem> @@ -258,9 +297,10 @@ const primarySaleRecipientFormSchema = z.object({ function PrimarySalesSection(props: { primarySaleRecipient: string | undefined; + isSplitRecipient?: boolean; update: (values: UpdateFormValues) => Promise<void>; isOwnerAccount: boolean; - contractChainId: number; + contract: ThirdwebContract; }) { const form = useForm<UpdateFormValues>({ resolver: zodResolver(primarySaleRecipientFormSchema), @@ -284,6 +324,11 @@ function PrimarySalesSection(props: { updateMutation.mutateAsync(form.getValues()); }; + const postSplitConfigure = async (splitWallet: string) => { + form.setValue("primarySaleRecipient", splitWallet); + await onSubmit(); + }; + return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> @@ -297,11 +342,26 @@ function PrimarySalesSection(props: { sales of the assets. </FormDescription> <FormControl> - <Input - placeholder="0x..." - {...field} - disabled={!props.isOwnerAccount} - /> + <div className="flex"> + <Input + placeholder="0x..." + {...field} + disabled={!props.isOwnerAccount} + className="rounded-r-none border-r-0" + /> + <ConfigureSplit + isNewSplit={!props.isSplitRecipient} + splitWallet={props.primarySaleRecipient || ""} + postSplitConfigure={ + props.isSplitRecipient ? undefined : postSplitConfigure + } + referenceContract={props.contract} + > + <Button className="rounded-lg rounded-l-none border border-l-0 bg-foreground"> + {props.isSplitRecipient ? "Update Split" : "Create Split"} + </Button> + </ConfigureSplit> + </div> </FormControl> <FormMessage /> </FormItem> @@ -316,7 +376,7 @@ function PrimarySalesSection(props: { type="submit" isPending={updateMutation.isPending} transactionCount={1} - txChainID={props.contractChainId} + txChainID={props.contract.chain.id} > Update </TransactionButton> diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/claimable.stories.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/claimable.stories.tsx index fbca5b0d7f6..6eb02cf46f1 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/claimable.stories.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/claimable.stories.tsx @@ -163,6 +163,7 @@ function Component() { ? undefined : { primarySaleRecipient: testAddress1, + isSplitRecipient: false, }, setPrimarySaleRecipient: updatePrimarySaleRecipientStub, }} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/mintable.stories.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/mintable.stories.tsx index 35ae421e33c..4e638ef8cd3 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/mintable.stories.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/mintable.stories.tsx @@ -52,6 +52,7 @@ function Component() { const [name, setName] = useState("MintableERC721"); const [isBatchMetadataInstalled, setIsBatchMetadataInstalled] = useState(false); + const [isSplitRecipient, setIsSplitRecipient] = useState(false); async function updatePrimaryRecipientStub(values: UpdateFormValues) { console.log("submitting", values); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -101,6 +102,13 @@ function Component() { label="isBatchMetadataInstalled" /> + <CheckboxWithLabel + value={isSplitRecipient} + onChange={setIsSplitRecipient} + id="isSplitRecipient" + label="isSplitRecipient" + /> + <Select value={name} onValueChange={(v) => setName(v)}> <SelectTrigger> <SelectValue /> @@ -119,6 +127,7 @@ function Component() { moduleAddress="0x0000000000000000000000000000000000000000" isPending={false} primarySaleRecipient={""} + isSplitRecipient={isSplitRecipient} updatePrimaryRecipient={updatePrimaryRecipientStub} mint={mintStub} uninstallButton={{ @@ -138,6 +147,7 @@ function Component() { moduleAddress="0x0000000000000000000000000000000000000000" isPending={false} primarySaleRecipient={testAddress1} + isSplitRecipient={isSplitRecipient} updatePrimaryRecipient={updatePrimaryRecipientStub} mint={mintStub} uninstallButton={{ @@ -157,6 +167,7 @@ function Component() { moduleAddress="0x0000000000000000000000000000000000000000" isPending={true} primarySaleRecipient={testAddress1} + isSplitRecipient={isSplitRecipient} updatePrimaryRecipient={updatePrimaryRecipientStub} mint={mintStub} uninstallButton={{ diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ClaimFeesCard.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ClaimFeesCard.tsx new file mode 100644 index 00000000000..9618342dc5b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ClaimFeesCard.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Form, FormField, FormItem, FormLabel } from "@/components/ui/form"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useAllChainsData } from "hooks/chains/allChains"; +import { useTxNotifications } from "hooks/useTxNotifications"; +import { InfoIcon } from "lucide-react"; +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { + NATIVE_TOKEN_ADDRESS, + type ThirdwebContract, + eth_getBalance, + getContract, + getRpcClient, + prepareContractCall, + readContract, + sendAndConfirmTransaction, + toEther, + toUnits, +} from "thirdweb"; +import { getBalance, getCurrencyMetadata } from "thirdweb/extensions/erc20"; +import { useActiveAccount } from "thirdweb/react"; +import { CurrencySelector } from "../modules/components/CurrencySelector"; + +export function ClaimFeesCard(props: { + splitWallet: string; + recipients: readonly string[]; + allocations: readonly bigint[]; + controller: string; + splitFeesCore: ThirdwebContract; +}) { + const { idToChain } = useAllChainsData(); + const chain = idToChain.get(props.splitFeesCore.chain.id); + const account = useActiveAccount(); + const form = useForm<{ currencyAddress: string }>({ + values: { + currencyAddress: NATIVE_TOKEN_ADDRESS, + }, + }); + + const currencyAddress = form.watch("currencyAddress"); + const currencyMetadata = useQuery({ + queryKey: ["currencyMetadata", currencyAddress], + queryFn: async () => { + const erc20Contract = getContract({ + address: currencyAddress, + client: props.splitFeesCore.client, + chain: props.splitFeesCore.chain, + }); + return getCurrencyMetadata({ + contract: erc20Contract, + }); + }, + enabled: currencyAddress !== NATIVE_TOKEN_ADDRESS, + }); + + const claimAmounts = useQuery({ + queryKey: ["claimAmounts", currencyAddress], + queryFn: async () => { + const erc6909Balances = await Promise.all( + props.recipients.map(async (recipient) => + readContract({ + contract: props.splitFeesCore, + method: + "function balanceOf(address owner, uint256 id) returns (uint256 amount)", + params: [recipient, BigInt(currencyAddress)], + }), + ), + ); + console.log("erc6909Balances: ", erc6909Balances); + + let splitWalletBalance: bigint; + if (currencyAddress === NATIVE_TOKEN_ADDRESS) { + const rpcRequest = getRpcClient({ + client: props.splitFeesCore.client, + chain: props.splitFeesCore.chain, + }); + splitWalletBalance = await eth_getBalance(rpcRequest, { + address: props.splitWallet, + }); + } else { + const { value } = await getBalance({ + contract: props.splitFeesCore, + address: currencyAddress, + }); + splitWalletBalance = value; + } + console.log("splitWalletBalance: ", splitWalletBalance); + + const totalAllocation = props.allocations.reduce( + (acc, curr) => acc + curr, + 0n, + ); + console.log("totalAllocation: ", totalAllocation); + const claimAmounts = erc6909Balances.map( + (balance, i) => + ((props.allocations[i] || 0n) * splitWalletBalance) / + totalAllocation + + balance, + ); + console.log("claimAmounts: ", claimAmounts); + + return claimAmounts; + }, + }); + console.log("claimAmounts: ", claimAmounts.data); + + const claim = async (recipient: string) => { + if (!account) { + throw new Error("Account does not exist"); + } + const splitWallet = getContract({ + address: props.splitWallet, + client: props.splitFeesCore.client, + chain: props.splitFeesCore.chain, + }); + console.log("splitWallet: ", splitWallet); + let splitWalletBalance: bigint; + if (currencyAddress === NATIVE_TOKEN_ADDRESS) { + const rpcRequest = getRpcClient({ + client: props.splitFeesCore.client, + chain: props.splitFeesCore.chain, + }); + splitWalletBalance = await eth_getBalance(rpcRequest, { + address: props.splitWallet, + }); + } else { + const { value } = await getBalance({ + contract: props.splitFeesCore, + address: currencyAddress, + }); + splitWalletBalance = value; + } + console.log("split balance in claim: ", splitWalletBalance); + + if (splitWalletBalance > 0n) { + const distributeTx = prepareContractCall({ + contract: props.splitFeesCore, + method: "function distribute(address _splitWallet, address _token)", + params: [props.splitWallet, currencyAddress], + }); + await sendAndConfirmTransaction({ + account, + transaction: distributeTx, + }); + } + + const withdrawTx = prepareContractCall({ + contract: props.splitFeesCore, + method: "function withdraw(address account, address _token)", + params: [recipient, currencyAddress], + }); + await sendAndConfirmTransaction({ + account, + transaction: withdrawTx, + }); + }; + + const claimNotifications = useTxNotifications( + "Claim successful", + "Claim failed", + ); + const claimMutation = useMutation({ + mutationFn: claim, + onSuccess: claimNotifications.onSuccess, + onError: claimNotifications.onError, + }); + + const columns = useMemo< + ColumnDef<{ recipient: string; claimable: bigint; claim: string }>[] + >( + () => [ + { + accessorKey: "recipient", + header: "Recipient", + }, + { + accessorKey: "claimable", + header: "Claimable Amount", + cell: ({ row }) => { + if ( + currencyAddress !== NATIVE_TOKEN_ADDRESS && + !currencyMetadata.data + ) + return null; + if (currencyAddress === NATIVE_TOKEN_ADDRESS) { + return <p>{toEther(row.getValue("claimable") as bigint)} ETH</p>; + } + + return ( + <p> + {toUnits( + row.getValue("claimable"), + currencyMetadata.data?.decimals || 18, + )}{" "} + {currencyMetadata.data?.symbol} + </p> + ); + }, + }, + { + accessorKey: "claim", + header: "Claim", + cell: ({ row }) => { + return ( + <Button + onClick={() => claimMutation.mutate(row.getValue("recipient"))} + disabled={ + row.getValue("claimable") === 0n || claimMutation.isPending + } + > + Claim + {claimMutation.isPending && <Spinner className="ml-2 h-4 w-4" />} + </Button> + ); + }, + }, + ], + [currencyAddress, currencyMetadata.data], + ); + + const data = useMemo(() => { + return props.recipients.map((recipient, i) => ({ + recipient, + claimable: claimAmounts?.data?.[i] ?? 0n, + claim: "", // dummy value for react-table + })); + }, [props.recipients, claimAmounts.data]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <section className="rounded-lg border border-border bg-muted/50"> + {/* Header */} + <div className="relative p-4 lg:p-6"> + {/* Title */} + <div className="pr-14"> + <h3 className="mb-1 gap-2 font-semibold text-xl tracking-tight"> + Split Fees + {/* Info Dialog */} + <Dialog> + <DialogTrigger asChild> + <Button + variant="ghost" + className="absolute top-4 right-4 h-auto w-auto p-2 text-muted-foreground" + > + <InfoIcon className="size-5" /> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Split Fees Contract</DialogTitle> + <DialogDescription> + This contract holds the funds that are split between the + recipients. + </DialogDescription> + + {/* Avoid adding focus on other elements to prevent tooltips from opening on modal open */} + <input className="sr-only" aria-hidden /> + + <div className="h-2" /> + + <div className="flex flex-col gap-4"> + <div> + <p className="mb-1 text-muted-foreground text-sm"> + Split Fees + </p> + <CopyAddressButton + className="text-xs" + address={props.splitWallet} + copyIconPosition="left" + variant="outline" + /> + </div> + + <div> + <p className="text-muted-foreground text-sm"> + Controller + </p> + <WalletAddress address={props.controller} /> + </div> + </div> + </DialogHeader> + </DialogContent> + </Dialog> + </h3> + </div> + + <div className="h-2" /> + + <Form {...form}> + <form> + <FormField + control={form.control} + name="currencyAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>Currency</FormLabel> + <CurrencySelector chain={chain} field={field} /> + </FormItem> + )} + /> + </form> + </Form> + + <div className="h-5" /> + + <TableContainer> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + return ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </TableHead> + ); + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </TableContainer> + </div> + </section> + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ConfigureSplitFees.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ConfigureSplitFees.tsx new file mode 100644 index 00000000000..05f98b17bdc --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/ConfigureSplitFees.tsx @@ -0,0 +1,502 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { TransactionButton } from "components/buttons/TransactionButton"; +import { useTxNotifications } from "hooks/useTxNotifications"; +import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { + type ThirdwebContract, + getContract, + parseEventLogs, + prepareContractCall, + prepareEvent, + sendAndConfirmTransaction, +} from "thirdweb"; +import { useActiveAccount, useReadContract } from "thirdweb/react"; +import { z } from "zod"; + +type Recipient = { + address: string; + percentage: string; +}; + +function ConfigureSplit(props: { + children: React.ReactNode; + isNewSplit?: boolean; + splitWallet?: string; + referenceContract: ThirdwebContract; + postSplitConfigure?: (splitWallet: string) => Promise<void> | void; +}) { + const activeAccount = useActiveAccount(); + const [open, setOpen] = useState(false); + + console.log("reference contract: ", props.referenceContract); + const splitFeesCore = getContract({ + address: splitFeesCoreAddress, + client: props.referenceContract.client, + chain: props.referenceContract.chain, + }); + console.log("gets here"); + + const split = useReadContract({ + contract: splitFeesCore, + method: { + type: "function", + name: "getSplit", + inputs: [ + { name: "_splitWallet", type: "address", internalType: "address" }, + ], + outputs: [ + { + name: "", + type: "tuple", + internalType: "struct Split", + components: [ + { name: "controller", type: "address", internalType: "address" }, + { + name: "recipients", + type: "address[]", + internalType: "address[]", + }, + { + name: "allocations", + type: "uint256[]", + internalType: "uint256[]", + }, + { + name: "totalAllocation", + type: "uint256", + internalType: "uint256", + }, + ], + }, + ], + stateMutability: "view", + }, + params: [props.splitWallet || ""], + queryOptions: { + enabled: !!props.splitWallet && !props.isNewSplit, + }, + }); + + const recipients = split.data?.recipients.map((recipient, i) => ({ + address: recipient, + percentage: (Number(split.data?.allocations[i]) / 100).toString(), + })); + + const createSplit = async ({ + recipients, + allocations, + controller, + }: { + recipients: string[]; + allocations: bigint[]; + controller: string; + }) => { + if (!activeAccount) { + throw new Error("No account or chain selected"); + } + + const splitFeesCore = getContract({ + address: splitFeesCoreAddress, + client: props.referenceContract.client, + chain: props.referenceContract.chain, + }); + + const transaction = prepareContractCall({ + contract: splitFeesCore, + method: + "function createSplit(address[] memory _recipients, uint256[] memory _allocations, address _controller, address _referenceContract)", + params: [ + recipients, + allocations, + controller, + props.referenceContract.address, + ], + }); + + const receipt = await sendAndConfirmTransaction({ + transaction, + account: activeAccount, + }); + console.log("receipt for create split: ", receipt); + + const decodedEvent = parseEventLogs({ + events: [ + prepareEvent({ + signature: + "event SplitCreated(address indexed splitWallet, address[] recipients, uint256[] allocations, address controller, address referenceContract)", + }), + ], + logs: receipt.logs, + }); + if (decodedEvent.length === 0 || !decodedEvent[0]) { + throw new Error( + `No ProxyDeployed event found in transaction: ${receipt.transactionHash}`, + ); + } + + if (props.postSplitConfigure) { + const { splitWallet } = decodedEvent[0]?.args as { splitWallet: string }; + console.log("split wallet: ", splitWallet); + await props.postSplitConfigure(splitWallet); + console.log("post split configured"); + } + + split.refetch(); + setOpen(false); + }; + + const updateSplit = async ({ + recipients, + allocations, + controller, + }: { + recipients: string[]; + allocations: bigint[]; + controller: string; + }) => { + if (!activeAccount) { + throw new Error("No account selected"); + } + if (!props.splitWallet) { + throw new Error("No split wallet selected"); + } + + console.log("gets in update split"); + + const splitFeesCore = getContract({ + address: splitFeesCoreAddress, + client: props.referenceContract.client, + chain: props.referenceContract.chain, + }); + console.log("split fees core: ", splitFeesCore); + + const transaction = prepareContractCall({ + contract: splitFeesCore, + method: + "function updateSplit(address _splitWallet, address[] memory _recipients, uint256[] memory _allocations, address _controller)", + params: [props.splitWallet, recipients, allocations, controller], + }); + console.log("transaction: ", transaction); + + await sendAndConfirmTransaction({ + transaction, + account: activeAccount, + }); + console.log("transaction sent"); + + if (props.postSplitConfigure) { + await props.postSplitConfigure(""); + console.log("post split configured"); + } + + split.refetch(); + setOpen(false); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{props.children}</DialogTrigger> + <DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]"> + {activeAccount ? ( + <ConfigureSplitUI + isNewSplit={props.isNewSplit} + activeAddress={activeAccount?.address} + createSplit={createSplit} + updateSplit={updateSplit} + recipients={recipients} + chainId={props.referenceContract.chain.id} + > + {props.children} + </ConfigureSplitUI> + ) : ( + <Alert variant="warning"> + <CircleAlertIcon className="size-5 max-sm:hidden" /> + <AlertTitle>No Claim Condition Set</AlertTitle> + <AlertDescription> + You have not set a claim condition for this token. You can set a + claim condition by clicking the "Set Claim Condition" button. + </AlertDescription> + </Alert> + )} + </DialogContent> + </Dialog> + ); +} + +// TODO: place this somwhere appropriate +const splitFeesCoreAddress = "0x640a2bb44A4c3644B416aCA8e60C67B11E41C8DF"; + +const formSchema = z + .object({ + recipients: z + .array( + z.object({ + address: z.string().length(42, { message: "Invalid address" }), + percentage: z.string().refine((v) => /^\d+(\.\d{1,2})?$/.test(v), { + message: "Invalid percentage", + }), + }), + ) + .min(2, { message: "Must have at least 2 recipients" }), + controller: z.string(), + totalAllocation: z.number(), + }) + .superRefine((input, ctx) => { + const { recipients } = input; + + if (recipients.reduce((sum, r) => sum + Number(r.percentage), 0) !== 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["totalAllocation"], + message: "Total allocation must equal to 100%", + }); + } + }); + +function ConfigureSplitUI(props: { + children: React.ReactNode; + isNewSplit?: boolean; + activeAddress?: string; + createSplit: (values: { + recipients: string[]; + allocations: bigint[]; + controller: string; + }) => Promise<void>; + updateSplit: (values: { + recipients: string[]; + allocations: bigint[]; + controller: string; + }) => Promise<void>; + recipients: Recipient[] | undefined; + chainId: number; +}) { + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + values: { + controller: props.activeAddress || "", + recipients: props.recipients || [ + { + address: props.activeAddress || "", + percentage: "100", + }, + { + address: "", + percentage: "", + }, + ], + // dummy field to trigger validation + totalAllocation: 0, + }, + }); + const formFields = useFieldArray({ + control: form.control, + name: "recipients", + }); + + const createNotifications = useTxNotifications( + "Successfully created split", + "Failed to create split", + ); + + const updateNotifications = useTxNotifications( + "Successfully updated split", + "Failed to update split", + ); + + const createSplitMutation = useMutation({ + mutationFn: props.createSplit, + onSuccess: createNotifications.onSuccess, + onError: createNotifications.onError, + }); + + const updateSplitMutation = useMutation({ + mutationFn: props.updateSplit, + onSuccess: updateNotifications.onSuccess, + onError: updateNotifications.onError, + }); + + const onSubmit = async () => { + const values = form.getValues(); + const { success } = formSchema.safeParse(values); + if (!success) return; + + const allocations = values.recipients.map( + (r) => BigInt(r.percentage) * 100n, + ); + console.log("allocations in submit: ", allocations); + const recipients = values.recipients.map((r) => r.address); + console.log("recipients in submit: ", recipients); + console.log("is new split: ", props.isNewSplit); + await (props.isNewSplit + ? createSplitMutation + : updateSplitMutation + ).mutateAsync({ + recipients, + allocations, + controller: values.controller, + }); + }; + + return ( + <Form {...form}> + <form className="flex flex-col gap-4"> + <DialogHeader> + <DialogTitle>Create Split</DialogTitle> + <DialogDescription> + The receipients assigned below will be rewarded the fees received + based on their allocations. + </DialogDescription> + </DialogHeader> + + {formFields.fields.map((fieldItem, index) => ( + <div className="flex items-start gap-3" key={fieldItem.id}> + <div className="flex gap-x-2"> + <FormField + control={form.control} + name={`recipients.${index}.address`} + render={({ field }) => ( + <FormItem className="grow"> + <FormControl> + <Input placeholder="Address" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`recipients.${index}.percentage`} + render={({ field }) => ( + <FormItem className="grow"> + <FormControl> + <Input + type="number" + placeholder="Percentage" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <ToolTipLabel label="Remove address"> + <Button + variant="outline" + className="!text-destructive-text bg-background" + onClick={() => { + formFields.remove(index); + }} + > + <Trash2Icon className="size-4" /> + </Button> + </ToolTipLabel> + </div> + ))} + + {form.formState.errors.totalAllocation && ( + <p className="font-medium text-destructive-text text-sm"> + {form.formState.errors.totalAllocation.message} + </p> + )} + + <div className="flex gap-3"> + <Button + variant="outline" + size="sm" + onClick={() => { + formFields.append({ + address: "", + percentage: "", + }); + }} + className="gap-2" + > + <PlusIcon className="size-3" /> + Add Recipient + </Button> + </div> + + <Accordion type="single" collapsible className="-mx-1"> + <AccordionItem value="metadata" className="border-none"> + <AccordionTrigger className="border-border border-t px-1"> + Advanced + </AccordionTrigger> + <AccordionContent className="px-1"> + <FormField + control={form.control} + name="controller" + render={({ field }) => ( + <FormItem className="grow"> + <FormLabel>Controller</FormLabel> + <FormControl> + <Input placeholder="0x..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </AccordionContent> + </AccordionItem> + </Accordion> + + <DialogFooter> + <TransactionButton + size="sm" + type="button" + onClick={form.handleSubmit(onSubmit)} + className="min-w-24 gap-2" + disabled={ + props.isNewSplit + ? createSplitMutation.isPending + : updateSplitMutation.isPending + } + isPending={ + props.isNewSplit + ? createSplitMutation.isPending + : updateSplitMutation.isPending + } + transactionCount={1} + txChainID={props.chainId} + > + {props.isNewSplit ? "Create Split" : "Update Split"} + </TransactionButton> + </DialogFooter> + </form> + </Form> + ); +} + +export default ConfigureSplit; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFees.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFees.tsx new file mode 100644 index 00000000000..fa26c385f5e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFees.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { TabButtons } from "@/components/ui/tabs"; +import { useState } from "react"; +import type { ThirdwebContract } from "thirdweb"; +import { ClaimFeesCard } from "./ClaimFeesCard"; +import ConfigureSplit from "./ConfigureSplitFees"; +import { SplitFeesCard } from "./SplitFeesCard"; + +type Split = { + splitWallet: string; + recipients: readonly string[]; + allocations: readonly bigint[]; + controller: string; + referenceContract: string; +}; + +function SplitFees(props: { + splitFeesCore: ThirdwebContract; + splits: Split[]; + coreContract: ThirdwebContract; +}) { + const [tab, setTab] = useState<"splitFeesCard" | "claimFeesCard">( + "splitFeesCard", + ); + + const tabs = [ + { + name: "Split Fees", + onClick: () => setTab("splitFeesCard"), + isActive: tab === "splitFeesCard", + isEnabled: true, + }, + { + name: "Claim Fees", + onClick: () => setTab("claimFeesCard"), + isActive: tab === "claimFeesCard", + isEnabled: true, + }, + ]; + + return ( + <div className="flex flex-col gap-4"> + <TabButtons tabs={tabs} /> + + {tab === "splitFeesCard" && + props.splits.map((split) => ( + <ClaimFeesCard + {...split} + splitFeesCore={props.splitFeesCore} + key={split.splitWallet} + /> + ))} + + {tab === "claimFeesCard" && ( + <> + {props.splits.map((split) => ( + <SplitFeesCard + {...split} + referenceContract={props.coreContract} + key={split.splitWallet} + /> + ))} + + <ConfigureSplit isNewSplit referenceContract={props.coreContract}> + <Button size="sm" className="self-end"> + Create Split + </Button> + </ConfigureSplit> + </> + )} + </div> + ); +} + +export default SplitFees; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFeesCard.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFeesCard.tsx new file mode 100644 index 00000000000..c22d1b5d989 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/SplitFeesCard.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { InfoIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import type { ThirdwebContract } from "thirdweb"; +import { useActiveAccount } from "thirdweb/react"; +import ConfigureSplit from "./ConfigureSplitFees"; + +export function SplitFeesCard(props: { + splitWallet: string; + recipients: readonly string[]; + allocations: readonly bigint[]; + controller: string; + referenceContract: ThirdwebContract; +}) { + const account = useActiveAccount(); + const isController = props.controller === account?.address; + const router = useRouter(); + + const columns: ColumnDef<{ allocation: number; recipient: string }>[] = [ + { + accessorKey: "recipient", + header: "Recipient", + }, + { + accessorKey: "allocation", + header: "Percentage", + }, + ]; + + const totalAllocation = props.allocations.reduce( + (acc, curr) => acc + curr, + 0n, + ); + const data = useMemo( + () => + props.recipients.map((recipient, i) => ({ + recipient: recipient, + allocation: + (Number(props.allocations[i] || 0n) / Number(totalAllocation)) * 100, + })), + [props.recipients, props.allocations, totalAllocation], + ); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <section className="rounded-lg border border-border bg-muted/50"> + {/* Header */} + <div className="relative p-4 lg:p-6"> + {/* Title */} + <div className="pr-14"> + <h3 className="mb-1 gap-2 font-semibold text-xl tracking-tight"> + Split Fees + {/* Info Dialog */} + <Dialog> + <DialogTrigger asChild> + <Button + variant="ghost" + className="absolute top-4 right-4 h-auto w-auto p-2 text-muted-foreground" + > + <InfoIcon className="size-5" /> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Split Fees Contract</DialogTitle> + <DialogDescription> + This contract holds the funds that are split between the + recipients. + </DialogDescription> + + {/* Avoid adding focus on other elements to prevent tooltips from opening on modal open */} + <input className="sr-only" aria-hidden /> + + <div className="h-2" /> + + <div className="flex flex-col gap-4"> + <div> + <p className="mb-1 text-muted-foreground text-sm"> + Split Fees + </p> + <CopyAddressButton + className="text-xs" + address={props.splitWallet} + copyIconPosition="left" + variant="outline" + /> + </div> + + <div> + <p className="text-muted-foreground text-sm"> + Controller + </p> + <WalletAddress address={props.controller} /> + </div> + </div> + </DialogHeader> + </DialogContent> + </Dialog> + </h3> + </div> + + <div className="h-5" /> + + <TableContainer> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + return ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </TableHead> + ); + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </TableContainer> + </div> + + <div className="flex flex-row justify-end gap-3 border-border border-t p-4 lg:p-6"> + <ConfigureSplit + splitWallet={props.splitWallet} + referenceContract={props.referenceContract} + postSplitConfigure={(_splitWallet: string) => router.refresh()} + > + <Button size="sm" className="min-w-24 gap-2" disabled={!isController}> + Update + </Button> + </ConfigureSplit> + </div> + </section> + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/page.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/page.tsx new file mode 100644 index 00000000000..2b2baf327f5 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/split-fees/page.tsx @@ -0,0 +1,130 @@ +import { notFound, redirect } from "next/navigation"; +import { getContractEvents, prepareEvent, readContract } from "thirdweb"; +import { type FetchDeployMetadataResult, getContract } from "thirdweb/contract"; +import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; +import { getContractPageMetadata } from "../_utils/getContractPageMetadata"; +import SplitFees from "./SplitFees"; + +export function getModuleInstallParams(mod: FetchDeployMetadataResult) { + return ( + mod.abi + .filter((a) => a.type === "function") + .find((f) => f.name === "encodeBytesOnInstall")?.inputs || [] + ); +} + +// TODO: place this somwhere appropriate +const splitFeesCoreAddress = "0x640a2bb44A4c3644B416aCA8e60C67B11E41C8DF"; + +export default async function Page(props: { + params: Promise<{ + contractAddress: string; + chain_id: string; + }>; +}) { + const params = await props.params; + const info = await getContractPageParamsInfo(params); + + if (!info) { + notFound(); + } + + const { contract } = info; + + const { isModularCore } = await getContractPageMetadata(contract); + + if (!isModularCore) { + redirect(`/${params.chain_id}/${params.contractAddress}`); + } + const splitFeesCore = getContract({ + address: splitFeesCoreAddress, + client: contract.client, + chain: contract.chain, + }); + + const events = await getContractEvents({ + contract: splitFeesCore, + events: [ + prepareEvent({ + signature: + "event SplitCreated(address indexed splitWallet, address[] recipients, uint256[] allocations, address controller, address referenceContract)", + filters: { + referenceContract: contract.address, + }, + }), + ], + blockRange: 123456n, + }); + + const splits = await Promise.all( + events + .filter( + (e) => + (e.args as { referenceContract: string }).referenceContract === + contract.address, + ) + .map(async (e) => { + const split = await readContract({ + contract: splitFeesCore, + method: { + type: "function", + name: "getSplit", + inputs: [ + { + name: "_splitWallet", + type: "address", + internalType: "address", + }, + ], + outputs: [ + { + name: "", + type: "tuple", + internalType: "struct Split", + components: [ + { + name: "controller", + type: "address", + internalType: "address", + }, + { + name: "recipients", + type: "address[]", + internalType: "address[]", + }, + { + name: "allocations", + type: "uint256[]", + internalType: "uint256[]", + }, + { + name: "totalAllocation", + type: "uint256", + internalType: "uint256", + }, + ], + }, + ], + stateMutability: "view", + }, + params: [(e.args as { splitWallet: string }).splitWallet], + }); + return { + splitWallet: (e.args as { splitWallet: string }).splitWallet, + recipients: split.recipients, + allocations: split.allocations, + controller: split.controller, + referenceContract: contract.address, + }; + }), + ); + console.log("splits: ", splits); + + return ( + <SplitFees + splitFeesCore={splitFeesCore} + splits={splits} + coreContract={contract} + /> + ); +} diff --git a/packages/thirdweb/src/utils/ens/namehash.ts b/packages/thirdweb/src/utils/ens/namehash.ts index 87eb6fae7e4..fa450a83f30 100644 --- a/packages/thirdweb/src/utils/ens/namehash.ts +++ b/packages/thirdweb/src/utils/ens/namehash.ts @@ -20,7 +20,10 @@ export function namehash(name: string) { const hashed = hashFromEncodedLabel ? toBytes(hashFromEncodedLabel) : keccak256(stringToBytes(item), "bytes"); - result = keccak256(concat([result, hashed]), "bytes"); + result = keccak256( + concat([result, hashed]), + "bytes", + ) as Uint8Array<ArrayBuffer>; } return bytesToHex(result);