diff --git a/apps/dashboard/src/contract-ui/hooks/permissions.ts b/apps/dashboard/src/contract-ui/hooks/permissions.ts new file mode 100644 index 000000000..17bc7a072 --- /dev/null +++ b/apps/dashboard/src/contract-ui/hooks/permissions.ts @@ -0,0 +1,125 @@ +import { + type BaseTransactionOptions, + type Hex, + type ThirdwebContract, + encode, + getAddress, +} from "thirdweb"; +import { multicall } from "thirdweb/extensions/common"; +import { resolveAddress } from "thirdweb/extensions/ens"; +import * as PermissionsExt from "thirdweb/extensions/permissions"; +import type { Account } from "thirdweb/wallets"; + +export async function getAllRoleMembers(options: BaseTransactionOptions) { + return Object.fromEntries( + await Promise.all( + Object.keys(PermissionsExt.roleMap).map( + async (role) => + [ + role, + await PermissionsExt.getAllRoleMembers({ ...options, role }).catch( + () => [], + ), + ] as const, + ), + ), + ); +} + +export function createSetAllRoleMembersTx(options: { + account: Account; + contract: ThirdwebContract; + roleMemberMap: Awaited>; +}) { + return multicall({ + contract: options.contract, + asyncParams: async () => { + const connectedWalletAddress = getAddress(options.account.address); + // if we are removing multiple roles, we need to allways remove the connected wallet address *last* + // this is so we don't renounce (i.e.) admin role first and then try to revoke someone else's (i.e.) admin role after (which will revert the entire txn because we are no longer an admin) + const roles = Object.keys(options.roleMemberMap); + if (roles.length === 0) { + throw new Error("No roles to set"); + } + // get the current role members + const currentRoles = await getAllRoleMembers(options); + const encoded: Hex[] = []; + // add / remove admin role at the end so we don't revoke admin then grant + const sortedRoles = roles.sort((role) => (role === "admin" ? 1 : -1)); + for (let i = 0; i < sortedRoles.length; i++) { + const role = sortedRoles[i]; + const [addresses, currentAddresses] = await Promise.all([ + Promise.all( + options.roleMemberMap[role]?.map((addressOrEns) => + resolveAddress({ + client: options.contract.client, + name: addressOrEns, + }), + ) || [], + ), + Promise.all( + currentRoles[role]?.map((addressOrEns) => + resolveAddress({ + client: options.contract.client, + name: addressOrEns, + }), + ) || [], + ), + ]); + const toAdd = addresses.filter( + (address) => !currentAddresses.includes(address), + ); + const toRemove = currentAddresses.filter( + (address) => !addresses.includes(address), + ); + + // if we're removing more than one address we have to make sure we always remove the *connected* (acting) wallet address first + // otherwise we'll revoke the connected wallet address and then try to revoke someone else's address which will revert the entire txn + if (toRemove.length > 1) { + const index = toRemove.indexOf(connectedWalletAddress); + if (index > -1) { + toRemove.splice(index, 1); + toRemove.push(connectedWalletAddress); + } + } + if (toAdd.length) { + const encodedToAdd = await Promise.all( + toAdd.map((addr) => + encode( + PermissionsExt.grantRole({ + contract: options.contract, + role, + targetAccountAddress: addr, + }), + ), + ), + ); + encoded.push(...encodedToAdd); + } + if (toRemove.length) { + const encodedToRemove = await Promise.all( + toRemove.map((address) => { + const tx = + getAddress(address) === connectedWalletAddress + ? PermissionsExt.renounceRole({ + contract: options.contract, + role, + targetAccountAddress: address, + }) + : PermissionsExt.revokeRole({ + contract: options.contract, + role, + targetAccountAddress: address, + }); + return encode(tx); + }), + ); + encoded.push(...encodedToRemove); + } + } + return { + data: encoded, + }; + }, + }); +} diff --git a/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx b/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx index ba7780782..fdb62cb8a 100644 --- a/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx +++ b/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx @@ -11,12 +11,12 @@ import { useClipboard, useToast, } from "@chakra-ui/react"; -import { useAllRoleMembers, useContract } from "@thirdweb-dev/react"; import { useTabHref } from "contract-ui/utils"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import { FiCopy } from "react-icons/fi"; import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; +import { useReadContract } from "thirdweb/react"; import { Button, Card, @@ -26,6 +26,7 @@ import { type TrackedLinkProps, } from "tw-components"; import { shortenIfAddress } from "utils/usedapp-external"; +import { getAllRoleMembers } from "../../../hooks/permissions"; interface PermissionsTableProps { contract: ThirdwebContract; @@ -36,8 +37,9 @@ export const PermissionsTable: React.FC = ({ contract, trackingCategory, }) => { - const contractQuery = useContract(contract.address); - const allRoleMembers = useAllRoleMembers(contractQuery.contract); + const allRoleMembers = useReadContract(getAllRoleMembers, { + contract, + }); const permissionsHref = useTabHref("permissions"); const members = useMemo(() => { diff --git a/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx b/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx index f0b2a8093..4c32cdb82 100644 --- a/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx +++ b/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx @@ -10,7 +10,6 @@ interface ContractPermissionProps { role: string; description: string; isLoading: boolean; - isPrebuilt: boolean; contract: ThirdwebContract; } @@ -18,7 +17,7 @@ export const ContractPermission: React.FC = ({ role, description, isLoading, - isPrebuilt, + contract, }) => { const { @@ -80,7 +79,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "lister" && isPrebuilt && ( + {role === "lister" && ( {isLoading || isSubmitting ? ( @@ -115,7 +114,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "asset" && isPrebuilt && ( + {role === "asset" && ( {isLoading || isSubmitting ? ( @@ -191,7 +190,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "lister" && isPrebuilt && ( + {role === "lister" && ( = ({ )} - {role === "asset" && isPrebuilt && ( + {role === "asset" && ( = { - [role in RolesForContract]: string[]; +type PermissionFormContext = { + [role: string]: string[]; }; -export const Permissions = ({ +export function Permissions({ contract, }: { contract: ThirdwebContract; -}) => { +}) { const trackEvent = useTrack(); - const { contract: contractV4 } = useContract(contract.address); - const allRoleMembers = useAllRoleMembers(contractV4); - const setAllRoleMembers = useSetAllRoleMembers(contractV4 as TContract); + const account = useActiveAccount(); + const allRoleMembers = useReadContract(getAllRoleMembers, { + contract, + }); + const sendTx = useSendAndConfirmTransaction(); const transformedQueryData = useMemo(() => { if (!allRoleMembers.data) { return {}; } - return allRoleMembers.data as PermissionFormContext; + return allRoleMembers.data satisfies PermissionFormContext; }, [allRoleMembers.data]); const form = useForm({ @@ -44,27 +46,14 @@ export const Permissions = ({ values: transformedQueryData, }); - const { data: contractType } = useContractType(contract.address); - const contractData = - BuiltinContractMap[contractType as keyof typeof BuiltinContractMap]; - const { onSuccess, onError } = useTxNotifications( "Permissions updated", "Failed to update permissions", ); const roles = useMemo(() => { - return Object.keys(allRoleMembers.data || ROLE_DESCRIPTION_MAP).filter( - (role) => - contractData && contractData.contractType !== "custom" - ? contractData.roles?.includes(role as keyof typeof roleMap) - : true, - ); - }, [allRoleMembers.data, contractData]); - - const isPrebuilt = - BuiltinContractMap[contractType as keyof typeof BuiltinContractMap] - .contractType !== "custom"; + return Object.keys(allRoleMembers.data || ROLE_DESCRIPTION_MAP); + }, [allRoleMembers.data]); return ( @@ -73,20 +62,28 @@ export const Permissions = ({ direction="column" as="form" onSubmit={form.handleSubmit((d) => { + if (!account) { + onError(new Error("Wallet not connected!")); + return; + } trackEvent({ category: "permissions", action: "set-permissions", label: "attempt", }); - // if we switch back to mutateAsync then *need* to catch errors - setAllRoleMembers.mutate(d as PermissionFormContext, { - onSuccess: (_data, variables) => { + const tx = createSetAllRoleMembersTx({ + account, + contract, + roleMemberMap: d, + }); + sendTx.mutate(tx, { + onSuccess: () => { trackEvent({ category: "permissions", action: "set-permissions", label: "success", }); - form.reset(variables); + form.reset(d); onSuccess(); }, onError: (error) => { @@ -108,7 +105,6 @@ export const Permissions = ({ key={role} role={role} description={ROLE_DESCRIPTION_MAP[role] || ""} - isPrebuilt={isPrebuilt} contract={contract} /> ); @@ -118,7 +114,7 @@ export const Permissions = ({ borderRadius="md" isDisabled={ !allRoleMembers.data || - setAllRoleMembers.isLoading || + sendTx.isPending || !form.formState.isDirty } onClick={() => form.reset(allRoleMembers.data)} @@ -130,7 +126,7 @@ export const Permissions = ({ transactionCount={1} isDisabled={!form.formState.isDirty} type="submit" - isLoading={setAllRoleMembers.isLoading} + isLoading={sendTx.isPending} loadingText="Saving permissions ..." > Update permissions @@ -139,4 +135,4 @@ export const Permissions = ({ ); -}; +}