Skip to content

Commit 557d3e3

Browse files
committed
[Dashboard] Feature: UI for crosschain modules (#5399)
https://linear.app/thirdweb/project/[modular-contracts]-op-interoperability-module-87740e1390a9/overview <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces new features and improvements related to cross-chain contract deployment in the `thirdweb` framework, enhancing functionality and usability for developers. ### Detailed summary - Added `getDeployedCloneFactoryContract` export in `contract.ts`. - Introduced `moduleInstalledEvent` export in `modules.ts`. - Updated TypeScript documentation links. - Enhanced sidebar links in `getContractPageSidebarLinks.ts`. - Improved `namehash.ts` with typed return value. - Created `NoCrossChainPrompt` component for user feedback. - Added `nonce` and `initializeData` options in `installPublishedModule.ts`. - Updated event handling for `ProxyDeployedV2`. - Implemented cross-chain deployment logic in various files. - Updated tests to cover new cross-chain functionality. - Refactored contract deployment methods to support new parameters. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 06d6504 commit 557d3e3

File tree

17 files changed

+1250
-20
lines changed

17 files changed

+1250
-20
lines changed

apps/dashboard/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
/// <reference types="next/image-types/global" />
33

44
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
5+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export function getContractPageSidebarLinks(data: {
2424
hide: !data.metadata.isModularCore,
2525
exactMatch: true,
2626
},
27+
{
28+
label: "Cross Chain (Beta)",
29+
href: `${layoutPrefix}/cross-chain`,
30+
exactMatch: true,
31+
},
2732
{
2833
label: "Code Snippets",
2934
href: `${layoutPrefix}/code`,
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Form } from "@/components/ui/form";
5+
import {
6+
Table,
7+
TableBody,
8+
TableCell,
9+
TableContainer,
10+
TableHead,
11+
TableHeader,
12+
TableRow,
13+
} from "@/components/ui/table";
14+
import { getThirdwebClient } from "@/constants/thirdweb.server";
15+
import { zodResolver } from "@hookform/resolvers/zod";
16+
import {
17+
type ColumnDef,
18+
flexRender,
19+
getCoreRowModel,
20+
useReactTable,
21+
} from "@tanstack/react-table";
22+
import { verifyContract } from "app/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
23+
import {
24+
type DeployModalStep,
25+
DeployStatusModal,
26+
useDeployStatusModal,
27+
} from "components/contract-components/contract-deploy-form/deploy-context-modal";
28+
import { useTxNotifications } from "hooks/useTxNotifications";
29+
import Link from "next/link";
30+
import { useEffect, useMemo, useState } from "react";
31+
import { useForm } from "react-hook-form";
32+
import {
33+
defineChain,
34+
eth_getCode,
35+
getRpcClient,
36+
prepareTransaction,
37+
sendAndConfirmTransaction,
38+
} from "thirdweb";
39+
import type {
40+
FetchDeployMetadataResult,
41+
ThirdwebContract,
42+
} from "thirdweb/contract";
43+
import {
44+
deployContractfromDeployMetadata,
45+
getOrDeployInfraForPublishedContract,
46+
} from "thirdweb/deploys";
47+
import { useActiveAccount, useSwitchActiveWalletChain } from "thirdweb/react";
48+
import { concatHex, padHex } from "thirdweb/utils";
49+
import { z } from "zod";
50+
import { SingleNetworkSelector } from "./single-network-selector";
51+
52+
type CrossChain = {
53+
id: number;
54+
network: string;
55+
chainId: number;
56+
status: "DEPLOYED" | "NOT_DEPLOYED";
57+
};
58+
59+
const formSchema = z.object({
60+
amounts: z.object({
61+
"84532": z.string(),
62+
"11155420": z.string(),
63+
"919": z.string(),
64+
"111557560": z.string(),
65+
"999999999": z.string(),
66+
"11155111": z.string(),
67+
"421614": z.string(),
68+
}),
69+
});
70+
type FormSchema = z.output<typeof formSchema>;
71+
72+
export function DataTable({
73+
data,
74+
coreMetadata,
75+
coreContract,
76+
modulesMetadata,
77+
initializeData,
78+
inputSalt,
79+
initCode,
80+
isDirectDeploy,
81+
}: {
82+
data: CrossChain[];
83+
coreMetadata: FetchDeployMetadataResult;
84+
coreContract: ThirdwebContract;
85+
modulesMetadata?: FetchDeployMetadataResult[];
86+
initializeData?: `0x${string}`;
87+
inputSalt?: `0x${string}`;
88+
initCode?: `0x${string}`;
89+
isDirectDeploy: boolean;
90+
}) {
91+
const activeAccount = useActiveAccount();
92+
const switchChain = useSwitchActiveWalletChain();
93+
const deployStatusModal = useDeployStatusModal();
94+
const { onError } = useTxNotifications(
95+
"Successfully deployed contract",
96+
"Failed to deploy contract",
97+
);
98+
99+
const [customChainData, setCustomChainData] = useState<CrossChain[]>(() => {
100+
try {
101+
const storedData = window.localStorage.getItem(
102+
`crosschain-${coreContract.address}`,
103+
);
104+
return storedData ? JSON.parse(storedData) : [];
105+
} catch (error) {
106+
console.error("Failed to read from localStorage", error);
107+
return [];
108+
}
109+
});
110+
111+
// eslint-disable-next-line no-restricted-syntax
112+
useEffect(() => {
113+
try {
114+
window.localStorage.setItem(
115+
`crosschain-${coreContract.address}`,
116+
JSON.stringify(customChainData),
117+
);
118+
} catch (error) {
119+
console.error("Failed to write to localStorage", error);
120+
}
121+
}, [customChainData, coreContract.address]);
122+
123+
const mergedChainData = useMemo(() => {
124+
const chainMap = new Map<number, CrossChain>();
125+
for (const item of [...data, ...customChainData]) {
126+
chainMap.set(item.chainId, item); // Deduplicate by chainId
127+
}
128+
return Array.from(chainMap.values());
129+
}, [data, customChainData]);
130+
131+
const form = useForm<FormSchema>({
132+
resolver: zodResolver(formSchema),
133+
values: {
134+
amounts: {
135+
"84532": "", // Base
136+
"11155420": "", // OP testnet
137+
"919": "", // Mode Network
138+
"111557560": "", // Cyber
139+
"999999999": "", // Zora
140+
"11155111": "", // Sepolia
141+
"421614": "",
142+
},
143+
},
144+
});
145+
146+
const columns: ColumnDef<CrossChain>[] = [
147+
{
148+
accessorKey: "network",
149+
header: "Network",
150+
cell: ({ row }) => {
151+
if (row.getValue("status") === "DEPLOYED") {
152+
return (
153+
<Link
154+
target="_blank"
155+
className="text-blue-500 underline"
156+
href={`/${row.getValue("chainId")}/${coreContract.address}`}
157+
>
158+
{row.getValue("network")}
159+
</Link>
160+
);
161+
}
162+
return row.getValue("network");
163+
},
164+
},
165+
{
166+
accessorKey: "chainId",
167+
header: "Chain ID",
168+
},
169+
{
170+
accessorKey: "status",
171+
header: "Status",
172+
cell: ({ row }) => {
173+
if (row.getValue("status") === "DEPLOYED") {
174+
return <p>Deployed</p>;
175+
}
176+
return (
177+
<Button
178+
type="button"
179+
onClick={() => deployContract(row.getValue("chainId"))}
180+
>
181+
Deploy
182+
</Button>
183+
);
184+
},
185+
},
186+
];
187+
188+
const table = useReactTable({
189+
data: mergedChainData,
190+
columns,
191+
getCoreRowModel: getCoreRowModel(),
192+
});
193+
194+
const deployContract = async (chainId: number) => {
195+
try {
196+
if (!activeAccount) {
197+
throw new Error("No active account");
198+
}
199+
200+
// eslint-disable-next-line no-restricted-syntax
201+
const chain = defineChain(chainId);
202+
const client = getThirdwebClient();
203+
const salt =
204+
inputSalt || concatHex(["0x03", padHex("0x", { size: 31 })]).toString();
205+
206+
await switchChain(chain);
207+
208+
const steps: DeployModalStep[] = [
209+
{
210+
type: "deploy",
211+
signatureCount: 1,
212+
},
213+
];
214+
215+
deployStatusModal.setViewContractLink("");
216+
deployStatusModal.open(steps);
217+
218+
let crosschainContractAddress: string | undefined;
219+
if (initCode && isDirectDeploy) {
220+
const tx = prepareTransaction({
221+
client,
222+
chain,
223+
to: "0x4e59b44847b379578588920cA78FbF26c0B4956C",
224+
data: initCode,
225+
});
226+
227+
await sendAndConfirmTransaction({
228+
transaction: tx,
229+
account: activeAccount,
230+
});
231+
232+
const code = await eth_getCode(
233+
getRpcClient({
234+
client,
235+
chain,
236+
}),
237+
{
238+
address: coreContract.address,
239+
},
240+
);
241+
242+
if (code && code.length > 2) {
243+
crosschainContractAddress = coreContract.address;
244+
}
245+
} else {
246+
crosschainContractAddress = await deployContractfromDeployMetadata({
247+
account: activeAccount,
248+
chain,
249+
client,
250+
deployMetadata: coreMetadata,
251+
isCrosschain: true,
252+
initializeData,
253+
salt,
254+
});
255+
256+
verifyContract({
257+
address: crosschainContractAddress as `0x${string}`,
258+
chain,
259+
client,
260+
});
261+
if (modulesMetadata) {
262+
for (const m of modulesMetadata) {
263+
await getOrDeployInfraForPublishedContract({
264+
chain,
265+
client,
266+
account: activeAccount,
267+
contractId: m.name,
268+
publisher: m.publisher,
269+
});
270+
}
271+
}
272+
}
273+
deployStatusModal.nextStep();
274+
deployStatusModal.setViewContractLink(
275+
`/${chain.id}/${crosschainContractAddress}`,
276+
);
277+
deployStatusModal.close();
278+
279+
setCustomChainData((prevData) =>
280+
prevData.map((row) =>
281+
row.chainId === chainId ? { ...row, status: "DEPLOYED" } : row,
282+
),
283+
);
284+
} catch (e) {
285+
onError(e);
286+
console.error("failed to deploy contract", e);
287+
deployStatusModal.close();
288+
}
289+
};
290+
291+
const handleAddRow = (chain: { chainId: number; name: string }) => {
292+
const existingChain = customChainData.find(
293+
(row) => row.chainId === chain.chainId,
294+
);
295+
if (existingChain) {
296+
return;
297+
}
298+
299+
const newRow: CrossChain = {
300+
id: chain.chainId,
301+
network: chain.name,
302+
chainId: chain.chainId,
303+
status: "NOT_DEPLOYED",
304+
};
305+
306+
if (!customChainData.some((row) => row.chainId === chain.chainId)) {
307+
setCustomChainData((prevData) => [...prevData, newRow]);
308+
}
309+
};
310+
311+
return (
312+
<Form {...form}>
313+
<form>
314+
<div
315+
style={{
316+
maxHeight: "500px",
317+
overflowY: "auto",
318+
}}
319+
>
320+
<TableContainer>
321+
<Table>
322+
<TableHeader>
323+
{table.getHeaderGroups().map((headerGroup) => (
324+
<TableRow key={headerGroup.id}>
325+
{headerGroup.headers.map((header) => {
326+
return (
327+
<TableHead key={header.id}>
328+
{header.isPlaceholder
329+
? null
330+
: flexRender(
331+
header.column.columnDef.header,
332+
header.getContext(),
333+
)}
334+
</TableHead>
335+
);
336+
})}
337+
</TableRow>
338+
))}
339+
</TableHeader>
340+
<TableBody>
341+
{table.getRowModel().rows.map((row) => (
342+
<TableRow
343+
key={row.id}
344+
data-state={row.getIsSelected() && "selected"}
345+
>
346+
{row.getVisibleCells().map((cell) => (
347+
<TableCell key={cell.id}>
348+
{flexRender(
349+
cell.column.columnDef.cell,
350+
cell.getContext(),
351+
)}
352+
</TableCell>
353+
))}
354+
</TableRow>
355+
))}
356+
</TableBody>
357+
</Table>
358+
<DeployStatusModal deployStatusModal={deployStatusModal} />
359+
</TableContainer>
360+
</div>
361+
362+
<div className="mt-4">
363+
<SingleNetworkSelector onAddRow={handleAddRow} className="w-full" />
364+
</div>
365+
</form>
366+
</Form>
367+
);
368+
}

0 commit comments

Comments
 (0)