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) { & { isOwnerAccount: boolean; name: string; - contractChainId: number; + contract: ThirdwebContract; setTokenId: Dispatch>; 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( @@ -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 + } /> ) : ( @@ -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({ resolver: zodResolver(claimConditionFormSchema), @@ -544,193 +585,181 @@ function ClaimConditionSection(props: { return (
- {props.noClaimConditionSet && !addClaimConditionButtonClicked && ( - <> - - - No Claim Condition Set - - You have not set a claim condition for this token. You can set a - claim condition by clicking the "Set Claim Condition" button. - - - - - + {props.noClaimConditionSet && ( + + + No Claim Condition Set + + You have not set a claim condition for this token. You can set a + claim condition by clicking the "Set Claim Condition" button. + + )} - {(!props.noClaimConditionSet || addClaimConditionButtonClicked) && ( -
- -
-
- ( - - Price Per Token - - - - - - )} - /> + + +
+
+ ( + + Price Per Token + + + + + + )} + /> - ( - - Currency - - - )} - /> -
+ ( + + Currency + + + )} + /> +
-
- ( - - Max Available Supply - - - - - - )} - /> +
+ ( + + Max Available Supply + + + + + + )} + /> - ( - - Maximum number of mints per wallet - - - - - - )} + ( + + Maximum number of mints per wallet + + + + + + )} + /> +
+ + +
+ form.setValue("startTime", from)} + setTo={(to: Date) => form.setValue("endTime", to)} />
- - -
- form.setValue("startTime", from)} - setTo={(to: Date) => form.setValue("endTime", to)} - /> -
-
- - - -
- Allowlist -
- {allowListFields.fields.map((fieldItem, index) => ( -
- ( - - - - - - - )} - /> - - - -
- ))} - - {allowListFields.fields.length === 0 && ( - - - - No allowlist configured - - - )} -
- -
- -
- -
+ + + + +
+ Allowlist +
+ {allowListFields.fields.map((fieldItem, index) => ( +
+ ( + + + + + + + )} + /> + + + +
+ ))} + + {allowListFields.fields.length === 0 && ( + + + + No allowlist configured + + + )}
-
- + +
+
- {" "} - - )} + +
+ + Update + +
+
+ {" "} +
); } @@ -747,7 +776,9 @@ function PrimarySaleRecipientSection(props: { primarySaleRecipient: string | undefined; update: (values: PrimarySaleRecipientFormValues) => Promise; isOwnerAccount: boolean; - contractChainId: number; + isSplitRecipient?: boolean; + contract: ThirdwebContract; + referenceContract: string; }) { const form = useForm({ 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 (
@@ -781,11 +817,32 @@ function PrimarySaleRecipientSection(props: { Sale Recipient - +
+ + {props.isOwnerAccount && ( + + + + )} +
@@ -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 ( ); } export function MintableModuleUI( props: Omit & { - primarySaleRecipient: string | undefined; + primarySaleRecipient?: string | undefined; + isSplitRecipient?: boolean; isPending: boolean; isOwnerAccount: boolean; updatePrimaryRecipient: (values: UpdateFormValues) => Promise; mint: (values: MintFormValues) => Promise; 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( @@ -258,9 +297,10 @@ const primarySaleRecipientFormSchema = z.object({ function PrimarySalesSection(props: { primarySaleRecipient: string | undefined; + isSplitRecipient?: boolean; update: (values: UpdateFormValues) => Promise; isOwnerAccount: boolean; - contractChainId: number; + contract: ThirdwebContract; }) { const form = useForm({ 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 ( @@ -297,11 +342,26 @@ function PrimarySalesSection(props: { sales of the assets. - +
+ + + + +
@@ -316,7 +376,7 @@ function PrimarySalesSection(props: { type="submit" isPending={updateMutation.isPending} transactionCount={1} - txChainID={props.contractChainId} + txChainID={props.contract.chain.id} > Update 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" /> + + + +
+ +
+
+

+ Split Fees +

+ +
+ +
+

+ Controller +

+ +
+
+ + + + +
+ +
+ + + + ( + + Currency + + + )} + /> + + + +
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
+
+ + ); +} 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; +}) { + 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 ( + + {props.children} + + {activeAccount ? ( + + {props.children} + + ) : ( + + + No Claim Condition Set + + You have not set a claim condition for this token. You can set a + claim condition by clicking the "Set Claim Condition" button. + + + )} + + + ); +} + +// 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; + updateSplit: (values: { + recipients: string[]; + allocations: bigint[]; + controller: string; + }) => Promise; + recipients: Recipient[] | undefined; + chainId: number; +}) { + const form = useForm>({ + 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 ( +
+ + + Create Split + + The receipients assigned below will be rewarded the fees received + based on their allocations. + + + + {formFields.fields.map((fieldItem, index) => ( +
+
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ + + + +
+ ))} + + {form.formState.errors.totalAllocation && ( +

+ {form.formState.errors.totalAllocation.message} +

+ )} + +
+ +
+ + + + + Advanced + + + ( + + Controller + + + + + + )} + /> + + + + + + + {props.isNewSplit ? "Create Split" : "Update Split"} + + +
+ + ); +} + +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 ( +
+ + + {tab === "splitFeesCard" && + props.splits.map((split) => ( + + ))} + + {tab === "claimFeesCard" && ( + <> + {props.splits.map((split) => ( + + ))} + + + + + + )} +
+ ); +} + +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 ( +
+ {/* Header */} +
+ {/* Title */} +
+

+ Split Fees + {/* Info Dialog */} + + + + + + + Split Fees Contract + + This contract holds the funds that are split between the + recipients. + + + {/* Avoid adding focus on other elements to prevent tooltips from opening on modal open */} + + +
+ +
+
+

+ Split Fees +

+ +
+ +
+

+ Controller +

+ +
+
+ + +
+

+
+ +
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
+
+ +
+ router.refresh()} + > + + +
+
+ ); +} 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 ( + + ); +} 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; } return bytesToHex(result);