Skip to content

Commit b73fa4b

Browse files
committed
[dashboard-engine] updates for circle and wallet credentials (#6202)
closes TOOL-3351 <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces support for managing Circle wallet credentials in the thirdweb Engine, enhancing the wallet management features by allowing users to create, update, and configure Circle wallet credentials. ### Detailed summary - Added `Wallet Credentials` section in the sidebar. - Introduced `Circle` wallet option in backend wallet configurations. - Created new components for managing Circle wallet credentials. - Implemented credential creation and update functionalities. - Updated UI components to support Circle wallet management. - Enhanced documentation for Circle wallet setup and usage. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent cbfe9ec commit b73fa4b

File tree

19 files changed

+1160
-34
lines changed

19 files changed

+1160
-34
lines changed

apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,6 @@ export const engineKeys = {
125125
[...engineKeys.all, engineId, "alerts"] as const,
126126
notificationChannels: (engineId: string) =>
127127
[...engineKeys.all, engineId, "notificationChannels"] as const,
128+
walletCredentials: (instance: string) =>
129+
[...engineKeys.all, instance, "walletCredentials"] as const,
128130
};

apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ type EngineFeature =
9393
| "CONTRACT_SUBSCRIPTIONS"
9494
| "IP_ALLOWLIST"
9595
| "HETEROGENEOUS_WALLET_TYPES"
96-
| "SMART_BACKEND_WALLETS";
96+
| "SMART_BACKEND_WALLETS"
97+
| "WALLET_CREDENTIALS";
9798

9899
interface EngineSystemHealth {
99100
status: string;
@@ -860,6 +861,10 @@ export type SetWalletConfigInput =
860861
gcpKmsKeyRingId: string;
861862
gcpApplicationCredentialEmail: string;
862863
gcpApplicationCredentialPrivateKey: string;
864+
}
865+
| {
866+
type: "circle";
867+
circleApiKey: string;
863868
};
864869

865870
export function useEngineSetWalletConfig(params: {
@@ -869,8 +874,8 @@ export function useEngineSetWalletConfig(params: {
869874
const { instanceUrl, authToken } = params;
870875
const queryClient = useQueryClient();
871876

872-
return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
873-
mutationFn: async (input) => {
877+
return useMutation({
878+
mutationFn: async (input: SetWalletConfigInput) => {
874879
invariant(instanceUrl, "instance is required");
875880

876881
const res = await fetch(`${instanceUrl}configuration/wallets`, {
@@ -884,7 +889,7 @@ export function useEngineSetWalletConfig(params: {
884889
throw new Error(json.error.message);
885890
}
886891

887-
return json.result;
892+
return json.result as WalletConfigResponse;
888893
},
889894
onSuccess: () => {
890895
return queryClient.invalidateQueries({
@@ -894,10 +899,17 @@ export function useEngineSetWalletConfig(params: {
894899
});
895900
}
896901

897-
export type CreateBackendWalletInput = {
898-
type: EngineBackendWalletType;
899-
label?: string;
900-
};
902+
export type CreateBackendWalletInput =
903+
| {
904+
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
905+
label?: string;
906+
}
907+
| {
908+
type: "circle" | "smart:circle";
909+
label?: string;
910+
credentialId: string;
911+
isTestnet: boolean;
912+
};
901913

902914
export function useEngineCreateBackendWallet(params: {
903915
instanceUrl: string;
@@ -1851,3 +1863,118 @@ export function useEngineDeleteNotificationChannel(engineId: string) {
18511863
},
18521864
});
18531865
}
1866+
1867+
export interface WalletCredential {
1868+
id: string;
1869+
type: string;
1870+
label: string;
1871+
isDefault: boolean | null;
1872+
createdAt: string;
1873+
updatedAt: string;
1874+
}
1875+
1876+
interface CreateWalletCredentialInput {
1877+
type: "circle";
1878+
label: string;
1879+
entitySecret?: string;
1880+
isDefault?: boolean;
1881+
}
1882+
1883+
export function useEngineWalletCredentials(params: {
1884+
instanceUrl: string;
1885+
authToken: string;
1886+
page?: number;
1887+
limit?: number;
1888+
}) {
1889+
const { instanceUrl, authToken, page = 1, limit = 100 } = params;
1890+
1891+
return useQuery({
1892+
queryKey: [...engineKeys.walletCredentials(instanceUrl), page, limit],
1893+
queryFn: async () => {
1894+
const res = await fetch(
1895+
`${instanceUrl}wallet-credentials?page=${page}&limit=${limit}`,
1896+
{
1897+
method: "GET",
1898+
headers: getEngineRequestHeaders(authToken),
1899+
},
1900+
);
1901+
1902+
const json = await res.json();
1903+
return (json.result as WalletCredential[]) || [];
1904+
},
1905+
enabled: !!instanceUrl,
1906+
});
1907+
}
1908+
1909+
export function useEngineCreateWalletCredential(params: {
1910+
instanceUrl: string;
1911+
authToken: string;
1912+
}) {
1913+
const { instanceUrl, authToken } = params;
1914+
const queryClient = useQueryClient();
1915+
1916+
return useMutation({
1917+
mutationFn: async (input: CreateWalletCredentialInput) => {
1918+
invariant(instanceUrl, "instance is required");
1919+
1920+
const res = await fetch(`${instanceUrl}wallet-credentials`, {
1921+
method: "POST",
1922+
headers: getEngineRequestHeaders(authToken),
1923+
body: JSON.stringify(input),
1924+
});
1925+
const json = await res.json();
1926+
1927+
if (json.error) {
1928+
throw new Error(json.error.message);
1929+
}
1930+
1931+
return json.result as WalletCredential;
1932+
},
1933+
onSuccess: () => {
1934+
return queryClient.invalidateQueries({
1935+
queryKey: engineKeys.walletCredentials(instanceUrl),
1936+
});
1937+
},
1938+
});
1939+
}
1940+
1941+
interface UpdateWalletCredentialInput {
1942+
label?: string;
1943+
isDefault?: boolean;
1944+
entitySecret?: string;
1945+
}
1946+
1947+
export function useEngineUpdateWalletCredential(params: {
1948+
instanceUrl: string;
1949+
authToken: string;
1950+
}) {
1951+
const { instanceUrl, authToken } = params;
1952+
const queryClient = useQueryClient();
1953+
1954+
return useMutation({
1955+
mutationFn: async ({
1956+
id,
1957+
...input
1958+
}: UpdateWalletCredentialInput & { id: string }) => {
1959+
invariant(instanceUrl, "instance is required");
1960+
1961+
const res = await fetch(`${instanceUrl}wallet-credentials/${id}`, {
1962+
method: "PUT",
1963+
headers: getEngineRequestHeaders(authToken),
1964+
body: JSON.stringify(input),
1965+
});
1966+
const json = await res.json();
1967+
1968+
if (json.error) {
1969+
throw new Error(json.error.message);
1970+
}
1971+
1972+
return json.result as WalletCredential;
1973+
},
1974+
onSuccess: () => {
1975+
return queryClient.invalidateQueries({
1976+
queryKey: engineKeys.walletCredentials(instanceUrl),
1977+
});
1978+
},
1979+
});
1980+
}

apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/_components/EnginePageLayout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
3838
pathId: "alerts",
3939
label: "Alerts",
4040
},
41+
{
42+
pathId: "wallet-credentials",
43+
label: "Wallet Credentials",
44+
},
4145
{
4246
pathId: "configuration",
4347
label: "Configuration",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
2+
import { Spinner } from "@/components/ui/Spinner/Spinner";
3+
import { Button } from "@/components/ui/button";
4+
import { Form } from "@/components/ui/form";
5+
import { Input } from "@/components/ui/input";
6+
import { TrackedLinkTW } from "@/components/ui/tracked-link";
7+
import {
8+
type EngineInstance,
9+
type SetWalletConfigInput,
10+
useEngineSetWalletConfig,
11+
} from "@3rdweb-sdk/react/hooks/useEngine";
12+
import { useTrack } from "hooks/analytics/useTrack";
13+
import { useForm } from "react-hook-form";
14+
import { toast } from "sonner";
15+
16+
interface CircleConfigProps {
17+
instance: EngineInstance;
18+
authToken: string;
19+
}
20+
21+
export const CircleConfig: React.FC<CircleConfigProps> = ({
22+
instance,
23+
authToken,
24+
}) => {
25+
const { mutate: setCircleConfig, isPending } = useEngineSetWalletConfig({
26+
instanceUrl: instance.url,
27+
authToken,
28+
});
29+
const trackEvent = useTrack();
30+
31+
const defaultValues: SetWalletConfigInput = {
32+
type: "circle" as const,
33+
circleApiKey: "",
34+
};
35+
36+
const form = useForm<SetWalletConfigInput>({
37+
defaultValues,
38+
values: defaultValues,
39+
resetOptions: {
40+
keepDirty: true,
41+
keepDirtyValues: true,
42+
},
43+
});
44+
45+
const onSubmit = (data: SetWalletConfigInput) => {
46+
setCircleConfig(data, {
47+
onSuccess: () => {
48+
toast.success("Configuration set successfully");
49+
trackEvent({
50+
category: "engine",
51+
action: "set-wallet-config",
52+
type: "circle",
53+
label: "success",
54+
});
55+
},
56+
onError: (error) => {
57+
toast.error("Failed to set configuration", {
58+
description: error.message,
59+
});
60+
trackEvent({
61+
category: "engine",
62+
action: "set-wallet-config",
63+
type: "circle",
64+
label: "error",
65+
error,
66+
});
67+
},
68+
});
69+
};
70+
71+
return (
72+
<div className="flex flex-col gap-6">
73+
<div className="flex flex-col gap-2">
74+
<p className="text-muted-foreground">
75+
Circle wallets require an API Key from your Circle account with
76+
sufficient permissions. Created wallets are stored in your AWS
77+
account. Configure your Circle API Key to use Circle wallets. Learn
78+
more about{" "}
79+
<TrackedLinkTW
80+
href="https://portal.thirdweb.com/engine/features/backend-wallets#circle-wallet"
81+
target="_blank"
82+
label="learn-more"
83+
category="engine"
84+
className="text-link-foreground hover:text-foreground"
85+
>
86+
how to get an API Key
87+
</TrackedLinkTW>
88+
.
89+
</p>
90+
</div>
91+
92+
<Form {...form}>
93+
<form
94+
className="flex flex-col gap-4"
95+
onSubmit={form.handleSubmit(onSubmit)}
96+
>
97+
<FormFieldSetup
98+
label="Circle API Key"
99+
errorMessage={
100+
form.getFieldState("circleApiKey", form.formState).error?.message
101+
}
102+
htmlFor="circle-api-key"
103+
isRequired
104+
tooltip={null}
105+
>
106+
<Input
107+
id="circle-api-key"
108+
placeholder="TEST_API_KEY:..."
109+
autoComplete="off"
110+
type="password"
111+
{...form.register("circleApiKey")}
112+
/>
113+
</FormFieldSetup>
114+
115+
<div className="flex items-center justify-end gap-4">
116+
<Button
117+
type="submit"
118+
className="min-w-28 gap-2"
119+
disabled={isPending}
120+
>
121+
{isPending && <Spinner className="size-4" />}
122+
Save
123+
</Button>
124+
</div>
125+
</form>
126+
</Form>
127+
</div>
128+
);
129+
};

0 commit comments

Comments
 (0)