Modular contracts Edit Extensions UI (#2225)
<!-- start pr-codex -->
## PR-Codex overview
This PR adds support for Modular Core and Modular Extension features, updates contract extension settings UI, and enhances extension management in the contract editor.
### Detailed summary
- Added support for `ModularCore` and `ModularExtension` features
- Updated contract extension settings UI
- Improved extension management in the contract editor
> The following files were skipped due to too many changes: `src/contract-ui/tabs/edit-extensions/components/InstalledExtensionsTable.tsx`, `src/contract-ui/tabs/edit-extensions/components/ExtensionForm.tsx`, `pnpm-lock.yaml`
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
+7
-7
@@ -46,13 +46,13 @@
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query-persist-client": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@thirdweb-dev/chains": "0.1.117",
|
||||
"@thirdweb-dev/react": "4.8.0",
|
||||
"@thirdweb-dev/react-core": "4.8.0",
|
||||
"@thirdweb-dev/sdk": "4.0.94",
|
||||
"@thirdweb-dev/service-utils": "0.4.31",
|
||||
"@thirdweb-dev/chains": "0.1.118",
|
||||
"@thirdweb-dev/react": "4.9.0",
|
||||
"@thirdweb-dev/react-core": "4.9.0",
|
||||
"@thirdweb-dev/sdk": "4.0.95",
|
||||
"@thirdweb-dev/service-utils": "0.4.32",
|
||||
"@thirdweb-dev/storage": "2.0.15",
|
||||
"@thirdweb-dev/wallets": "2.5.33",
|
||||
"@thirdweb-dev/wallets": "2.5.35",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"airtable": "^0.12.2",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -101,7 +101,7 @@
|
||||
"swagger-ui-react": "^5.17.14",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "5.29.0",
|
||||
"thirdweb": "5.29.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.1",
|
||||
"zksync-ethers": "^5.8.0",
|
||||
|
||||
Generated
+1281
-1597
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ export const TransactionButton: React.FC<TransactionButtonProps> = ({
|
||||
size === "sm" ? 3 : size === "lg" ? 6 : size === "xs" ? 2 : 4
|
||||
}))`
|
||||
}
|
||||
isDisabled={isChainDeprecated}
|
||||
isDisabled={isChainDeprecated || restButtonProps.isDisabled}
|
||||
>
|
||||
{children}
|
||||
<Tooltip
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ export const DynamicContractsFieldset = () => {
|
||||
return (
|
||||
<Flex gap={8} direction="column" as="fieldset">
|
||||
<Flex gap={2} direction="column">
|
||||
<Heading size="title.md">Dynamic Contract settings</Heading>
|
||||
<Text>You can set default extensions for your dynamic contract.</Text>
|
||||
<Heading size="title.md">Contract extension settings</Heading>
|
||||
<Text>You can set default extensions for your contract.</Text>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
{fields.map((item, index) => (
|
||||
|
||||
@@ -333,6 +333,9 @@ export const ContractPublishForm: React.FC<ContractPublishFormProps> = ({
|
||||
data.factoryDeploymentData?.customFactoryInput
|
||||
?.customFactoryAddresses,
|
||||
},
|
||||
modularFactoryInput: {
|
||||
hooksParamName: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
contractName: publishMetadata.data?.name,
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
useSigner,
|
||||
useWalletConfig,
|
||||
} from "@thirdweb-dev/react";
|
||||
import { FeatureWithEnabled } from "@thirdweb-dev/sdk/dist/declarations/src/evm/constants/contract-features";
|
||||
import {
|
||||
FeatureName,
|
||||
FeatureWithEnabled,
|
||||
} from "@thirdweb-dev/sdk/dist/declarations/src/evm/constants/contract-features";
|
||||
import { DeploymentTransaction } from "@thirdweb-dev/sdk/dist/declarations/src/evm/types/any-evm/deploy-data";
|
||||
import {
|
||||
Abi,
|
||||
@@ -46,9 +49,11 @@ import {
|
||||
extractEventsFromAbi,
|
||||
extractFunctionParamsFromAbi,
|
||||
extractFunctionsFromAbi,
|
||||
fetchContractMetadata,
|
||||
fetchPreDeployMetadata,
|
||||
fetchRawPredeployMetadata,
|
||||
getTrustedForwarders,
|
||||
isExtensionEnabled,
|
||||
} from "@thirdweb-dev/sdk";
|
||||
import {
|
||||
getZkTransactionsForDeploy,
|
||||
@@ -889,18 +894,57 @@ export async function fetchPublishedContracts(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPublishedContractsWithFeature(
|
||||
sdk: ThirdwebSDK,
|
||||
queryClient: QueryClient,
|
||||
feature: FeatureName,
|
||||
address?: string | null,
|
||||
) {
|
||||
invariant(sdk, "sdk not provided");
|
||||
invariant(address, "address is not defined");
|
||||
const tempResult = ((await sdk.getPublisher().getAll(address)) || []).filter(
|
||||
(c) => c.id,
|
||||
);
|
||||
const tempResultWithMetadata = await Promise.all(
|
||||
tempResult.map(async (c) => ({
|
||||
...c,
|
||||
metadata: await fetchFullPublishMetadata(sdk, c.metadataUri, queryClient),
|
||||
})),
|
||||
);
|
||||
|
||||
const resultWithFeature = await Promise.all(
|
||||
tempResultWithMetadata.map(async (c) => ({
|
||||
...c,
|
||||
deployMetadata: await fetchContractMetadata(
|
||||
c.metadata.metadataUri,
|
||||
StorageSingleton,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return resultWithFeature.filter((c) => {
|
||||
const extensions = detectFeatures(c.deployMetadata.abi as Abi);
|
||||
return isExtensionEnabled(c.deployMetadata.abi as Abi, feature, extensions);
|
||||
});
|
||||
}
|
||||
|
||||
export type PublishedContractDetails = Awaited<
|
||||
ReturnType<typeof fetchPublishedContracts>
|
||||
>[number];
|
||||
|
||||
export function usePublishedContractsQuery(address?: string) {
|
||||
export function usePublishedContractsQuery(
|
||||
address?: string,
|
||||
feature?: FeatureName,
|
||||
) {
|
||||
const sdk = getThirdwebSDK(Polygon.chainId, getDashboardChainRpc(Polygon));
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery<PublishedContractDetails[]>(
|
||||
["published-contracts", address],
|
||||
["published-contracts", address, feature],
|
||||
() => {
|
||||
invariant(sdk, "sdk not provided");
|
||||
return fetchPublishedContracts(sdk, queryClient, address);
|
||||
return feature && feature.length > 0
|
||||
? fetchPublishedContractsWithFeature(sdk, queryClient, feature, address)
|
||||
: fetchPublishedContracts(sdk, queryClient, address);
|
||||
},
|
||||
{
|
||||
enabled: !!address && !!sdk,
|
||||
|
||||
@@ -97,6 +97,11 @@ const LazyContractSourcesPage = dynamic(() =>
|
||||
({ ContractSourcesPage }) => ContractSourcesPage,
|
||||
),
|
||||
);
|
||||
const LazyContractEditExtensionsPage = dynamic(() =>
|
||||
import("../tabs/edit-extensions/page").then(
|
||||
({ ContractEditExtensionsPage }) => ContractEditExtensionsPage,
|
||||
),
|
||||
);
|
||||
|
||||
export function useContractRouteConfig(
|
||||
contractAddress: string,
|
||||
@@ -129,6 +134,15 @@ export function useContractRouteConfig(
|
||||
component: ContractOverviewPage,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
title: "Edit extensions",
|
||||
path: "edit-extensions",
|
||||
isEnabled: extensionDetectedState({
|
||||
contractQuery,
|
||||
feature: ["ModularCore"],
|
||||
}),
|
||||
component: LazyContractEditExtensionsPage,
|
||||
},
|
||||
{
|
||||
title: "Code Snippets",
|
||||
path: "code",
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Flex,
|
||||
Input,
|
||||
Select,
|
||||
FormControl,
|
||||
Skeleton,
|
||||
Spacer,
|
||||
Grid,
|
||||
useToast,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { FormLabel, FormErrorMessage, Text } from "tw-components";
|
||||
import {
|
||||
useAllVersions,
|
||||
usePublishedContractsQuery,
|
||||
} from "components/contract-components/hooks";
|
||||
import { installPublishedExtension } from "thirdweb/extensions/modular";
|
||||
import {
|
||||
sendTransaction,
|
||||
waitForReceipt,
|
||||
type ContractOptions,
|
||||
} from "thirdweb";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { thirdwebClient } from "../../../../@/constants/client";
|
||||
import { compatibleExtensions } from "@thirdweb-dev/sdk";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { resolveImplementation } from "thirdweb/utils";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
|
||||
type FormData = {
|
||||
publisherAddress: string;
|
||||
extensionContract: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
type InstallExtensionFormProps = {
|
||||
contract: ContractOptions;
|
||||
refetchExtensions: () => void;
|
||||
account: Account;
|
||||
installedExtensions: {
|
||||
data?: string[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const InstallExtensionForm = (props: InstallExtensionFormProps) => {
|
||||
const { register, watch, formState, resetField, reset } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
version: "latest",
|
||||
},
|
||||
});
|
||||
const toast = useToast();
|
||||
const { contract, account } = props;
|
||||
const { errors } = formState;
|
||||
|
||||
const publishedContractsQuery = usePublishedContractsQuery(
|
||||
watch("publisherAddress"),
|
||||
"ModularExtension",
|
||||
);
|
||||
const allVersions = useAllVersions(
|
||||
watch("publisherAddress"),
|
||||
watch("extensionContract"),
|
||||
);
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const installTransaction = installPublishedExtension({
|
||||
contract,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
account,
|
||||
extensionName: watch("extensionContract"),
|
||||
publisherAddress: watch("publisherAddress"),
|
||||
version: watch("version"),
|
||||
});
|
||||
|
||||
const txResult = await sendTransaction({
|
||||
transaction: installTransaction,
|
||||
account,
|
||||
});
|
||||
|
||||
await waitForReceipt(txResult);
|
||||
},
|
||||
onSuccess() {
|
||||
props.refetchExtensions();
|
||||
|
||||
toast({
|
||||
variant: "solid",
|
||||
position: "bottom",
|
||||
title: "Extension installed successfully",
|
||||
status: "success",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// clear form
|
||||
reset({
|
||||
publisherAddress: "",
|
||||
extensionContract: "",
|
||||
version: "latest",
|
||||
});
|
||||
},
|
||||
onError(err) {
|
||||
toast({
|
||||
variant: "solid",
|
||||
position: "bottom",
|
||||
title: "Failed to install extension",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
console.error("Error during installation:", err);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
installMutation.mutate();
|
||||
};
|
||||
|
||||
const extensionContractInputProps = register("extensionContract", {
|
||||
required: "Extension name is required",
|
||||
});
|
||||
|
||||
const selectedExtension = publishedContractsQuery.data?.find(
|
||||
(x) => x.id === watch("extensionContract"),
|
||||
);
|
||||
|
||||
// Get core contract bytecode
|
||||
const coreContractByteCodeQuery = useQuery({
|
||||
queryKey: ["getBytecode", contract.address, contract.chain.id],
|
||||
queryFn: async () => {
|
||||
const coreImplementation = await resolveImplementation(contract);
|
||||
return coreImplementation.bytecode;
|
||||
},
|
||||
retry: false,
|
||||
// 30 minutes
|
||||
// staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
// Get Installed extension bytecodes
|
||||
const installedExtensionBytecodesQuery = useQuery({
|
||||
queryKey: [
|
||||
"installedExtensionBytecodes",
|
||||
contract.address,
|
||||
contract.chain.id,
|
||||
props.installedExtensions.data,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const extensionAddress = props.installedExtensions.data;
|
||||
if (!extensionAddress) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
extensionAddress.map(async (address) => {
|
||||
const result = await resolveImplementation({
|
||||
client: thirdwebClient,
|
||||
address,
|
||||
chain: contract.chain,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Failed to fetch bytecode for extension");
|
||||
}
|
||||
|
||||
return result.bytecode;
|
||||
}),
|
||||
);
|
||||
},
|
||||
enabled: !!props.installedExtensions.data,
|
||||
retry: false,
|
||||
// 30 minutes
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
// check if selected extension is compatible with the core contract and installed extensions
|
||||
const isExtensionCompatibleQuery = useQuery({
|
||||
queryKey: [
|
||||
"isExtensionCompatible",
|
||||
contract.address,
|
||||
contract.chain.id,
|
||||
installedExtensionBytecodesQuery.data,
|
||||
coreContractByteCodeQuery.data,
|
||||
selectedExtension,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (
|
||||
!coreContractByteCodeQuery.data ||
|
||||
!installedExtensionBytecodesQuery.data ||
|
||||
!selectedExtension
|
||||
) {
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
|
||||
return isExtensionCompatible({
|
||||
contractInfo: {
|
||||
bytecode: coreContractByteCodeQuery.data,
|
||||
installedExtensionBytecodes: installedExtensionBytecodesQuery.data,
|
||||
chainId: contract.chain.id,
|
||||
},
|
||||
extensionInfo: {
|
||||
bytecodeUri: selectedExtension.metadata.bytecodeUri,
|
||||
},
|
||||
});
|
||||
},
|
||||
retry: false,
|
||||
// 30 minutes
|
||||
staleTime: 1000 * 60 * 30,
|
||||
enabled:
|
||||
!!selectedExtension &&
|
||||
!!coreContractByteCodeQuery.data &&
|
||||
!!installedExtensionBytecodesQuery.data,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
gap={4}
|
||||
gridTemplateColumns={{
|
||||
base: "1fr",
|
||||
md: "1fr 1fr",
|
||||
lg: "1fr 1fr 1fr",
|
||||
}}
|
||||
>
|
||||
<FormControl isInvalid={!!errors.publisherAddress}>
|
||||
<FormLabel>Publisher</FormLabel>
|
||||
<Input
|
||||
disabled={installMutation.isLoading}
|
||||
bg="backgroundHighlight"
|
||||
placeholder="Publisher address"
|
||||
{...register("publisherAddress", {
|
||||
required: "Publisher address is required",
|
||||
})}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.publisherAddress && errors.publisherAddress.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!errors.extensionContract ||
|
||||
isExtensionCompatibleQuery.data === false
|
||||
}
|
||||
isRequired={true}
|
||||
>
|
||||
<FormLabel>Extension Name</FormLabel>
|
||||
<Skeleton
|
||||
isLoaded={
|
||||
!!publishedContractsQuery.data ||
|
||||
!publishedContractsQuery.isFetching
|
||||
}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Select
|
||||
disabled={
|
||||
installMutation.isLoading ||
|
||||
publishedContractsQuery?.data?.length === 0 ||
|
||||
publishedContractsQuery.isLoading
|
||||
}
|
||||
bg="backgroundHighlight"
|
||||
{...extensionContractInputProps}
|
||||
onChange={(e) => {
|
||||
// reset version when extension changes
|
||||
resetField("version");
|
||||
extensionContractInputProps.onChange(e);
|
||||
}}
|
||||
placeholder={
|
||||
publishedContractsQuery.data?.length === 0
|
||||
? "No extensions"
|
||||
: "Select extension"
|
||||
}
|
||||
>
|
||||
{publishedContractsQuery?.data?.map(({ id }) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Skeleton>
|
||||
<FormErrorMessage fontWeight={500}>
|
||||
{!isExtensionCompatibleQuery.isFetching &&
|
||||
isExtensionCompatibleQuery.data === false &&
|
||||
"Extension is not compatible"}
|
||||
{errors.extensionContract && errors.extensionContract.message}
|
||||
</FormErrorMessage>
|
||||
|
||||
{isExtensionCompatibleQuery.isFetching && selectedExtension && (
|
||||
<Flex alignItems="center" gap={1.5} mt={2}>
|
||||
<Text size="body.sm" fontWeight={500} color="blue.500">
|
||||
Checking Compatibility
|
||||
</Text>
|
||||
<Spinner size={"xs"} color="blue.500" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{isExtensionCompatibleQuery.isError && (
|
||||
<Flex alignItems="center" gap={2} mt={2}>
|
||||
<Text
|
||||
fontWeight={500}
|
||||
size="body.sm"
|
||||
_dark={{
|
||||
color: "yellow.500",
|
||||
}}
|
||||
_light={{
|
||||
color: "yellow.700",
|
||||
}}
|
||||
>
|
||||
Extension may not be compatible
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.version} isRequired={true}>
|
||||
<FormLabel>Extension Version</FormLabel>
|
||||
<Skeleton isLoaded={!allVersions.isFetching} borderRadius="lg">
|
||||
<Select
|
||||
disabled={
|
||||
!allVersions.data ||
|
||||
allVersions.isLoading ||
|
||||
isExtensionCompatibleQuery.data === false ||
|
||||
installMutation.isLoading ||
|
||||
isExtensionCompatibleQuery.isFetching
|
||||
}
|
||||
bg="backgroundHighlight"
|
||||
w="full"
|
||||
{...register("version", {
|
||||
required: "Version is required",
|
||||
})}
|
||||
>
|
||||
<option value="latest">Latest</option>
|
||||
{allVersions?.data?.map(({ version }) => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Skeleton>
|
||||
<FormErrorMessage>
|
||||
{errors.version && errors.version.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Spacer h={5} />
|
||||
|
||||
{/* Submit */}
|
||||
<Flex justify={"flex-end"}>
|
||||
<TransactionButton
|
||||
transactionCount={1}
|
||||
isLoading={installMutation.isLoading}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
alignSelf="flex-end"
|
||||
isDisabled={
|
||||
!formState.isValid ||
|
||||
isExtensionCompatibleQuery.data === false ||
|
||||
isExtensionCompatibleQuery.isFetching
|
||||
}
|
||||
>
|
||||
Install
|
||||
</TransactionButton>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
async function isExtensionCompatible(options: {
|
||||
contractInfo: {
|
||||
bytecode: string;
|
||||
installedExtensionBytecodes: string[];
|
||||
chainId: number;
|
||||
};
|
||||
extensionInfo: {
|
||||
bytecodeUri: string;
|
||||
};
|
||||
}) {
|
||||
// 1. get extension's bytecode
|
||||
const res = await download({
|
||||
client: thirdwebClient,
|
||||
uri: options.extensionInfo.bytecodeUri,
|
||||
});
|
||||
|
||||
const extensionBytecode = await res.text();
|
||||
|
||||
// 2. check compatibility with core and installed extensions
|
||||
try {
|
||||
const isCompatible = await compatibleExtensions(
|
||||
options.contractInfo.bytecode,
|
||||
[...options.contractInfo.installedExtensionBytecodes, extensionBytecode],
|
||||
options.contractInfo.chainId,
|
||||
);
|
||||
|
||||
return isCompatible;
|
||||
} catch (e) {
|
||||
console.error("Error during compatibility check:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
Flex,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useToast,
|
||||
Box,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaRegTrashAlt } from "react-icons/fa";
|
||||
import { Button, Text, Heading } from "tw-components";
|
||||
import { uninstallExtensionByProxy } from "thirdweb/extensions/modular";
|
||||
import {
|
||||
sendTransaction,
|
||||
waitForReceipt,
|
||||
type ContractOptions,
|
||||
} from "thirdweb";
|
||||
import { AddressCopyButton } from "tw-components/AddressCopyButton";
|
||||
import { useExtensionContractInfo } from "./extensionContractInfo";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const InstalledExtensionsTable = (props: {
|
||||
contract: ContractOptions;
|
||||
installedExtensions: {
|
||||
data?: string[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
refetchExtensions: () => void;
|
||||
ownerAccount?: Account;
|
||||
}) => {
|
||||
const { installedExtensions, ownerAccount } = props;
|
||||
|
||||
if (
|
||||
!installedExtensions.isLoading &&
|
||||
installedExtensions.data?.length === 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2" size="title.md">
|
||||
Installed Extensions
|
||||
</Heading>
|
||||
<Alert
|
||||
status="error"
|
||||
fontSize={"small"}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
bg="alertBg"
|
||||
>
|
||||
<AlertIcon />
|
||||
<Flex direction="column" gap={0.5} ml={1}>
|
||||
<Text color="heading" size="body.lg" fontWeight={500}>
|
||||
No extensions installed
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2" size="title.md">
|
||||
Installed Extensions
|
||||
</Heading>
|
||||
<TableContainer
|
||||
rounded={"lg"}
|
||||
overflowX={{ base: "auto", md: "initial" }}
|
||||
>
|
||||
<Table position="relative">
|
||||
<Thead>
|
||||
<TableHeading> Extension Name </TableHeading>
|
||||
<TableHeading> Description </TableHeading>
|
||||
<TableHeading> Publisher Address </TableHeading>
|
||||
<TableHeading> Extension Address </TableHeading>
|
||||
<TableHeading> Version </TableHeading>
|
||||
{ownerAccount && <TableHeading> Remove </TableHeading>}
|
||||
</Thead>
|
||||
|
||||
<Tbody>
|
||||
{installedExtensions.isLoading ? (
|
||||
<>
|
||||
<SkeletonRow ownerAccount={ownerAccount} />
|
||||
<SkeletonRow ownerAccount={ownerAccount} />
|
||||
<SkeletonRow ownerAccount={ownerAccount} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{installedExtensions.data?.map((e, i) => (
|
||||
<ExtensionRow
|
||||
key={i}
|
||||
extensionAddress={e}
|
||||
contract={props.contract}
|
||||
onRemoveExtension={props.refetchExtensions}
|
||||
ownerAccount={ownerAccount}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function SkeletonRow(props: { ownerAccount?: Account }) {
|
||||
return (
|
||||
<Tr borderBottomWidth={1} _last={{ borderBottomWidth: 0 }}>
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
|
||||
{/* Version */}
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
|
||||
{/* Remove */}
|
||||
{props.ownerAccount && (
|
||||
<TableData>
|
||||
<Skeleton height="24px" rounded="xl" />
|
||||
</TableData>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionRow(props: {
|
||||
extensionAddress: string;
|
||||
contract: ContractOptions;
|
||||
onRemoveExtension: () => void;
|
||||
ownerAccount?: Account;
|
||||
}) {
|
||||
const { contract, extensionAddress, ownerAccount } = props;
|
||||
const toast = useToast();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const contractInfo = useExtensionContractInfo(props.extensionAddress);
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: async (account: Account) => {
|
||||
const uninstallTransaction = uninstallExtensionByProxy({
|
||||
contract,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
extensionProxyAddress: extensionAddress,
|
||||
extensionData: "0x",
|
||||
});
|
||||
|
||||
const txResult = await sendTransaction({
|
||||
transaction: uninstallTransaction,
|
||||
account,
|
||||
});
|
||||
|
||||
await waitForReceipt(txResult);
|
||||
},
|
||||
onSuccess() {
|
||||
toast({
|
||||
variant: "solid",
|
||||
position: "bottom",
|
||||
title: "Extension Removed successfully",
|
||||
status: "success",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
props.onRemoveExtension();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
variant: "solid",
|
||||
position: "bottom",
|
||||
title: "Failed to remove extension",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
console.error("Error during uninstallation:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!ownerAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
uninstallMutation.mutate(ownerAccount);
|
||||
};
|
||||
|
||||
if (!contractInfo) {
|
||||
return <SkeletonRow ownerAccount={ownerAccount} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr borderBottomWidth={1} _last={{ borderBottomWidth: 0 }}>
|
||||
<TableData>
|
||||
<Text color="heading">{contractInfo.name}</Text>
|
||||
</TableData>
|
||||
<TableData>
|
||||
<Text>{contractInfo.description || "..."}</Text>
|
||||
</TableData>
|
||||
<TableData>
|
||||
<AddressCopyButton size="xs" address={contractInfo.publisher} />
|
||||
</TableData>
|
||||
<TableData>
|
||||
<AddressCopyButton size="xs" address={extensionAddress} />
|
||||
</TableData>
|
||||
|
||||
{/* Version */}
|
||||
<TableData>
|
||||
<Text>{contractInfo.version}</Text>
|
||||
</TableData>
|
||||
|
||||
{/* Remove */}
|
||||
{ownerAccount && (
|
||||
<TableData>
|
||||
<Flex>
|
||||
<Tooltip
|
||||
label="Remove"
|
||||
bg="backgroundHighlight"
|
||||
border="1px solid"
|
||||
borderColor="borderColor"
|
||||
color="accent.500"
|
||||
rounded="xl"
|
||||
placement="top"
|
||||
fontSize="small"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
<Button
|
||||
isLoading={uninstallMutation.isLoading}
|
||||
onClick={onOpen}
|
||||
color="red.500"
|
||||
variant="outline"
|
||||
_hover={{
|
||||
bg: "accent.100",
|
||||
}}
|
||||
rounded={"xl"}
|
||||
p={2}
|
||||
>
|
||||
<FaRegTrashAlt
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</TableData>
|
||||
)}
|
||||
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove();
|
||||
}}
|
||||
>
|
||||
<ModalHeader fontSize={"larger"} pb={1}>
|
||||
Uninstall Extension
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
Are you sure you want to uninstall{" "}
|
||||
<Box as="span" fontWeight={500} color="heading">
|
||||
{contractInfo.name}
|
||||
</Box>{" "}
|
||||
?
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter pt={10} gap={3}>
|
||||
<Button
|
||||
type="button"
|
||||
mr={3}
|
||||
m={0}
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<TransactionButton
|
||||
transactionCount={1}
|
||||
isLoading={uninstallMutation.isLoading}
|
||||
type="submit"
|
||||
colorScheme="red"
|
||||
alignSelf="flex-end"
|
||||
mr={3}
|
||||
m={0}
|
||||
>
|
||||
Uninstall
|
||||
</TransactionButton>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function TableData(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Td borderBottomWidth="inherit" borderBottomColor="borderColor" py={5}>
|
||||
{props.children}
|
||||
</Td>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeading(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Th border="none" color="faded">
|
||||
{props.children}
|
||||
</Th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import { useAddress } from "@thirdweb-dev/react";
|
||||
import { usePublishedContractsFromDeploy } from "components/contract-components/hooks";
|
||||
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useExtensionContractInfo(contractAddress: string) {
|
||||
const address = useAddress();
|
||||
const activeNetworkInfo = useEVMContractInfo();
|
||||
|
||||
const publishedContractsFromDeploy = usePublishedContractsFromDeploy(
|
||||
contractAddress,
|
||||
activeNetworkInfo?.chain?.chainId,
|
||||
);
|
||||
|
||||
const publishedContractToShow = useMemo(() => {
|
||||
const reversedPublishedContractsFromDeploy = [
|
||||
...(publishedContractsFromDeploy.data || []),
|
||||
].reverse();
|
||||
|
||||
return (
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) => publishedContract.publisher === address,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy.find(
|
||||
(publishedContract) =>
|
||||
publishedContract.publisher === THIRDWEB_DEPLOYER_ADDRESS,
|
||||
) ||
|
||||
reversedPublishedContractsFromDeploy[
|
||||
reversedPublishedContractsFromDeploy.length - 1
|
||||
] ||
|
||||
undefined
|
||||
);
|
||||
}, [publishedContractsFromDeploy.data, address]);
|
||||
|
||||
return publishedContractToShow as typeof publishedContractToShow | undefined;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Stack,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
Spacer,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { InstalledExtensionsTable } from "./components/InstalledExtensionsTable";
|
||||
import { InstallExtensionForm } from "./components/ExtensionForm";
|
||||
import { Heading, Text } from "../../../tw-components";
|
||||
import { useEVMContractInfo } from "../../../@3rdweb-sdk/react";
|
||||
import { defineChain, getContract } from "thirdweb";
|
||||
import { getInstalledExtensions, owner } from "thirdweb/extensions/modular";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import { thirdwebClient } from "../../../lib/thirdweb-client";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ContractEditExtensionsPageProps {
|
||||
contractAddress?: string;
|
||||
}
|
||||
|
||||
export const ContractEditExtensionsPage: React.FC<
|
||||
ContractEditExtensionsPageProps
|
||||
> = ({ contractAddress }) => {
|
||||
const contractInfo = useEVMContractInfo();
|
||||
|
||||
const chainId = contractInfo?.chain?.chainId;
|
||||
|
||||
if (!contractAddress || !chainId) {
|
||||
return (
|
||||
<Flex flex={1} alignItems="center" justifyContent="center" h={[300, 500]}>
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return <Content contractAddress={contractAddress} chainId={chainId} />;
|
||||
};
|
||||
|
||||
function Content(props: { contractAddress: string; chainId: number }) {
|
||||
const { contractAddress, chainId } = props;
|
||||
const account = useActiveAccount();
|
||||
|
||||
const contract = useMemo(
|
||||
() =>
|
||||
getContract({
|
||||
client: thirdwebClient,
|
||||
address: contractAddress,
|
||||
chain: defineChain(chainId),
|
||||
}),
|
||||
[contractAddress, chainId],
|
||||
);
|
||||
|
||||
const installedExtensionsQuery = useReadContract(getInstalledExtensions, {
|
||||
contract,
|
||||
});
|
||||
|
||||
const ownerQuery = useReadContract(owner, {
|
||||
contract,
|
||||
});
|
||||
|
||||
function refetchExtensions() {
|
||||
installedExtensionsQuery.refetch();
|
||||
}
|
||||
|
||||
if (ownerQuery.isLoading) {
|
||||
return (
|
||||
<Flex flex={1} alignItems="center" justifyContent="center" h={[300, 500]}>
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ownerQuery.data) {
|
||||
return (
|
||||
<Flex flex={1} alignItems="center" justifyContent="center" h={[300, 500]}>
|
||||
<Text> Failed to resolve contract owner </Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = ownerQuery.data === account?.address;
|
||||
|
||||
const installedExtensions = {
|
||||
isLoading: installedExtensionsQuery.isLoading,
|
||||
data: installedExtensionsQuery.data
|
||||
? installedExtensionsQuery.data.map((x) => x.implementation)
|
||||
: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={4}>
|
||||
{/* Alert */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<Heading as="h2" size="title.md">
|
||||
Edit Extensions
|
||||
</Heading>
|
||||
<Alert
|
||||
status="info"
|
||||
fontSize={"small"}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
bg="alertBg"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="blue.500"
|
||||
>
|
||||
<AlertIcon />
|
||||
<Flex direction="column" gap={0.5} ml={1}>
|
||||
<Text color="heading" size="body.lg" fontWeight={500}>
|
||||
Add capabilities to your contract by installing extensions.
|
||||
</Text>
|
||||
<Text>Lookup extensions to install in your contract</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
<Spacer h={10} />
|
||||
<InstallExtensionForm
|
||||
contract={contract}
|
||||
refetchExtensions={refetchExtensions}
|
||||
account={account}
|
||||
installedExtensions={installedExtensions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<>
|
||||
<Alert
|
||||
status="error"
|
||||
fontSize={"small"}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
bg="alertBg"
|
||||
>
|
||||
<AlertIcon />
|
||||
<Flex direction="column" gap={0.5} ml={1}>
|
||||
<Text color="heading" size="body.lg" fontWeight={500}>
|
||||
You do not have permissions to edit extensions
|
||||
</Text>
|
||||
<Text>Connect owner wallet to edit extensions</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer h={14} />
|
||||
|
||||
<InstalledExtensionsTable
|
||||
installedExtensions={installedExtensions}
|
||||
refetchExtensions={refetchExtensions}
|
||||
contract={contract}
|
||||
ownerAccount={isOwner ? account : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +267,8 @@ const GUIDES_FOR_EXTENSION: Record<FeatureName, Item[]> = {
|
||||
PackVRF: [],
|
||||
PluginRouter: [],
|
||||
DynamicContract: [],
|
||||
ModularCore: [],
|
||||
ModularExtension: [],
|
||||
DirectListings: [],
|
||||
EnglishAuctions: [],
|
||||
Offers: [],
|
||||
@@ -427,6 +429,8 @@ const TEMPLATES_FOR_EXTENSION: Record<FeatureName, Item[]> = {
|
||||
PackVRF: [],
|
||||
PluginRouter: [],
|
||||
DynamicContract: [],
|
||||
ModularCore: [],
|
||||
ModularExtension: [],
|
||||
DirectListings: [],
|
||||
EnglishAuctions: [],
|
||||
Offers: [],
|
||||
|
||||
+5
-1
@@ -73,7 +73,7 @@ const chakraTheme: Theme = extendTheme({
|
||||
border: "3px solid",
|
||||
borderColor: "transparent",
|
||||
borderRadius: "md",
|
||||
background: `linear-gradient(${bgColor}, ${bgColor}) padding-box,
|
||||
background: `linear-gradient(${bgColor}, ${bgColor}) padding-box,
|
||||
linear-gradient(135deg, ${lgFrom}, ${lgTo}, ${lgFrom}) border-box`,
|
||||
transitionProperty: "opacity, background",
|
||||
transitionDuration: "slow",
|
||||
@@ -383,6 +383,10 @@ const chakraTheme: Theme = extendTheme({
|
||||
bgWhite: { default: "#fff", _dark: "black" },
|
||||
backgroundBody: { default: "backgroundLight", _dark: "backgroundDark" },
|
||||
backgroundHighlight: { default: "white", _dark: "#131417" },
|
||||
alertBg: {
|
||||
default: "hsl(220 18% 92% / 1)",
|
||||
_dark: "hsl(225 10% 12.5% / 1)",
|
||||
},
|
||||
backgroundCardHighlight: { default: "white", _dark: "#131417" },
|
||||
wordmark: { default: "#262A36", _dark: "whiteAlpha.900" },
|
||||
heading: { default: "#262A36", _dark: "#ECECEC" },
|
||||
|
||||
Reference in New Issue
Block a user