Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,6 @@ export const engineKeys = {
[...engineKeys.all, engineId, "alerts"] as const,
notificationChannels: (engineId: string) =>
[...engineKeys.all, engineId, "notificationChannels"] as const,
walletCredentials: (instance: string) =>
[...engineKeys.all, instance, "walletCredentials"] as const,
};
143 changes: 135 additions & 8 deletions apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ type EngineFeature =
| "CONTRACT_SUBSCRIPTIONS"
| "IP_ALLOWLIST"
| "HETEROGENEOUS_WALLET_TYPES"
| "SMART_BACKEND_WALLETS";
| "SMART_BACKEND_WALLETS"
| "WALLET_CREDENTIALS";

interface EngineSystemHealth {
status: string;
Expand Down Expand Up @@ -860,6 +861,10 @@ export type SetWalletConfigInput =
gcpKmsKeyRingId: string;
gcpApplicationCredentialEmail: string;
gcpApplicationCredentialPrivateKey: string;
}
| {
type: "circle";
circleApiKey: string;
};

export function useEngineSetWalletConfig(params: {
Expand All @@ -869,8 +874,8 @@ export function useEngineSetWalletConfig(params: {
const { instanceUrl, authToken } = params;
const queryClient = useQueryClient();

return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
mutationFn: async (input) => {
return useMutation({
mutationFn: async (input: SetWalletConfigInput) => {
invariant(instanceUrl, "instance is required");

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

return json.result;
return json.result as WalletConfigResponse;
},
onSuccess: () => {
return queryClient.invalidateQueries({
Expand All @@ -894,10 +899,17 @@ export function useEngineSetWalletConfig(params: {
});
}

export type CreateBackendWalletInput = {
type: EngineBackendWalletType;
label?: string;
};
export type CreateBackendWalletInput =
| {
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
label?: string;
}
| {
type: "circle" | "smart:circle";
label?: string;
credentialId: string;
isTestnet: boolean;
};

export function useEngineCreateBackendWallet(params: {
instanceUrl: string;
Expand Down Expand Up @@ -1851,3 +1863,118 @@ export function useEngineDeleteNotificationChannel(engineId: string) {
},
});
}

export interface WalletCredential {
id: string;
type: string;
label: string;
isDefault: boolean | null;
createdAt: string;
updatedAt: string;
}

interface CreateWalletCredentialInput {
type: "circle";
label: string;
entitySecret?: string;
isDefault?: boolean;
}

export function useEngineWalletCredentials(params: {
instanceUrl: string;
authToken: string;
page?: number;
limit?: number;
}) {
const { instanceUrl, authToken, page = 1, limit = 100 } = params;

return useQuery({
queryKey: [...engineKeys.walletCredentials(instanceUrl), page, limit],
queryFn: async () => {
const res = await fetch(
`${instanceUrl}wallet-credentials?page=${page}&limit=${limit}`,
{
method: "GET",
headers: getEngineRequestHeaders(authToken),
},
);

const json = await res.json();
return (json.result as WalletCredential[]) || [];
},
enabled: !!instanceUrl,
});
}

export function useEngineCreateWalletCredential(params: {
instanceUrl: string;
authToken: string;
}) {
const { instanceUrl, authToken } = params;
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (input: CreateWalletCredentialInput) => {
invariant(instanceUrl, "instance is required");

const res = await fetch(`${instanceUrl}wallet-credentials`, {
method: "POST",
headers: getEngineRequestHeaders(authToken),
body: JSON.stringify(input),
});
const json = await res.json();

if (json.error) {
throw new Error(json.error.message);
}

return json.result as WalletCredential;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: engineKeys.walletCredentials(instanceUrl),
});
},
});
}

interface UpdateWalletCredentialInput {
label?: string;
isDefault?: boolean;
entitySecret?: string;
}

export function useEngineUpdateWalletCredential(params: {
instanceUrl: string;
authToken: string;
}) {
const { instanceUrl, authToken } = params;
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({
id,
...input
}: UpdateWalletCredentialInput & { id: string }) => {
invariant(instanceUrl, "instance is required");

const res = await fetch(`${instanceUrl}wallet-credentials/${id}`, {
method: "PUT",
headers: getEngineRequestHeaders(authToken),
body: JSON.stringify(input),
});
const json = await res.json();

if (json.error) {
throw new Error(json.error.message);
}

return json.result as WalletCredential;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: engineKeys.walletCredentials(instanceUrl),
});
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
pathId: "alerts",
label: "Alerts",
},
{
pathId: "wallet-credentials",
label: "Wallet Credentials",
},
{
pathId: "configuration",
label: "Configuration",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
type EngineInstance,
type SetWalletConfigInput,
useEngineSetWalletConfig,
} from "@3rdweb-sdk/react/hooks/useEngine";
import { useTrack } from "hooks/analytics/useTrack";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

interface CircleConfigProps {
instance: EngineInstance;
authToken: string;
}

export const CircleConfig: React.FC<CircleConfigProps> = ({
instance,
authToken,
}) => {
const { mutate: setCircleConfig, isPending } = useEngineSetWalletConfig({
instanceUrl: instance.url,
authToken,
});
const trackEvent = useTrack();

const defaultValues: SetWalletConfigInput = {
type: "circle" as const,
circleApiKey: "",
};

const form = useForm<SetWalletConfigInput>({
defaultValues,
values: defaultValues,
resetOptions: {
keepDirty: true,
keepDirtyValues: true,
},
});

const onSubmit = (data: SetWalletConfigInput) => {
setCircleConfig(data, {
onSuccess: () => {
toast.success("Configuration set successfully");
trackEvent({
category: "engine",
action: "set-wallet-config",
type: "circle",
label: "success",
});
},
onError: (error) => {
toast.error("Failed to set configuration", {
description: error.message,
});
trackEvent({
category: "engine",
action: "set-wallet-config",
type: "circle",
label: "error",
error,
});
},
});
};

return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground">
Circle wallets require an API Key from your Circle account with
sufficient permissions. Created wallets are stored in your AWS
account. Configure your Circle API Key to use Circle wallets. Learn
more about{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/engine/features/backend-wallets#circle-wallet"
target="_blank"
label="learn-more"
category="engine"
className="text-link-foreground hover:text-foreground"
>
how to get an API Key
</TrackedLinkTW>
.
</p>
</div>

<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormFieldSetup
label="Circle API Key"
errorMessage={
form.getFieldState("circleApiKey", form.formState).error?.message
}
htmlFor="circle-api-key"
isRequired
tooltip={null}
>
<Input
id="circle-api-key"
placeholder="TEST_API_KEY:..."
autoComplete="off"
type="password"
{...form.register("circleApiKey")}
/>
</FormFieldSetup>

<div className="flex items-center justify-end gap-4">
<Button
type="submit"
className="min-w-28 gap-2"
disabled={isPending}
>
{isPending && <Spinner className="size-4" />}
Save
</Button>
</div>
</form>
</Form>
</div>
);
};
Loading
Loading