Skip to content

[Dashboard] Add token request route to dashboard #7112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 27, 2025
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
32 changes: 32 additions & 0 deletions apps/dashboard/src/@/api/universal-bridge/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use server";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
import { UB_BASE_URL } from "./constants";

export type TokenMetadata = {
Expand Down Expand Up @@ -37,3 +39,33 @@ export async function getUniversalBridgeTokens(props: {
const json = await res.json();
return json.data as Array<TokenMetadata>;
}

export async function addUniversalBridgeTokenRoute(props: {
chainId: number;
tokenAddress: string;
project: ProjectResponse;
}) {
const authToken = await getAuthToken();
const url = new URL(`${UB_BASE_URL}/v1/tokens`);

const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
body: JSON.stringify({
chainId: props.chainId,
tokenAddress: props.tokenAddress,
}),
});

if (!res.ok) {
const text = await res.text();
throw new Error(text);
}

const json = await res.json();
return json.data as Array<TokenMetadata>;
}
Comment on lines +69 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify the response structure matches TokenMetadata array.

The function assumes the API response will match the expected structure and casts it to Array<TokenMetadata>. Consider adding a type guard or validation if the API response structure might vary.


🏁 Script executed:

#!/bin/bash
# Find any usages of this function to verify how the response is handled
rg -A 3 "addUniversalBridgeTokenRoute" --type ts

Length of output: 1138


Validate API Response Structure

The route helper in apps/dashboard/src/@/api/universal-bridge/tokens.ts (lines 66–68) currently does:

const json = await res.json();
return json.data as TokenMetadata[];

That cast assumes the external API will always return the exact shape of TokenMetadata[]. To guard against unexpected payloads (which could break downstream code in RouteDiscovery.tsx), add a runtime check or schema validation. For example:

  • Use a type-guard function or a runtime schema (e.g. Zod, io-ts) to assert that json.data truly matches TokenMetadata[].
  • Throw or handle a descriptive error if validation fails, rather than blindly casting.

This will ensure any API‐side changes surface immediately and can be handled gracefully.

🤖 Prompt for AI Agents
In apps/dashboard/src/@/api/universal-bridge/tokens.ts around lines 66 to 68,
the code casts the API response data directly to TokenMetadata[] without
validation, which risks runtime errors if the response shape changes. To fix
this, implement a runtime type guard or use a schema validation library like Zod
or io-ts to verify that json.data matches the TokenMetadata[] structure before
returning it. If validation fails, throw a descriptive error or handle it
gracefully to prevent downstream issues.

84 changes: 84 additions & 0 deletions apps/dashboard/src/@/components/blocks/RouteDiscoveryCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type React from "react";

export function RouteDiscoveryCard(
props: React.PropsWithChildren<{
bottomText: React.ReactNode;
header?: {
description: string | undefined;
title: string;
};
errorText: string | undefined;
noPermissionText: string | undefined;
saveButton?: {
onClick?: () => void;
disabled: boolean;
isPending: boolean;
type?: "submit";
variant?:
| "ghost"
| "default"
| "primary"
| "destructive"
| "outline"
| "secondary";
className?: string;
label?: string;
};
}>,
) {
return (
<div className="relative rounded-lg border border-border bg-card">
<div
className={cn(
"relative border-border border-b px-4 py-6 lg:px-6",
props.noPermissionText && "cursor-not-allowed",
)}
>
{props.header && (
<>
<h3 className="font-semibold text-xl tracking-tight">
{props.header.title}
</h3>
{props.header.description && (
<p className="mt-1.5 mb-4 text-foreground text-sm">
{props.header.description}
</p>
)}
</>
)}

{props.children}
</div>

<div className="flex min-h-[60px] items-center justify-between gap-2 px-4 py-3 lg:px-6">
{props.noPermissionText ? (
<p className="text-muted-foreground text-sm">
{props.noPermissionText}
</p>
) : props.errorText ? (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't need these if you make the error and success messages toasts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to toasts .

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it pushed? Code looks the same

<p className="text-destructive-text text-sm">{props.errorText}</p>
) : (
<p className="text-muted-foreground text-sm">{props.bottomText}</p>
)}

{props.saveButton && !props.noPermissionText && (
<Button
size="sm"
className={cn("gap-2", props.saveButton.className)}
onClick={props.saveButton.onClick}
disabled={props.saveButton.disabled || props.saveButton.isPending}
variant={props.saveButton.variant || "outline"}
type={props.saveButton.type}
>
{props.saveButton.isPending && <Spinner className="size-3" />}
{props.saveButton.label ||
(props.saveButton.isPending ? "Submit" : "Submit Token")}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Submit vs Submit Token? Is there any difference?

</Button>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getFees } from "@/api/universal-bridge/developer";
import { PayConfig } from "components/pay/PayConfig";
import { RouteDiscovery } from "components/pay/RouteDiscovery";

import { redirect } from "next/navigation";

export default async function Page(props: {
Expand Down Expand Up @@ -47,11 +49,17 @@ export default async function Page(props: {
}

return (
<PayConfig
project={project}
teamId={team.id}
teamSlug={team_slug}
fees={fees}
/>
<div className="flex flex-col p-5">
<PayConfig
project={project}
teamId={team.id}
teamSlug={team_slug}
fees={fees}
/>

<div className="flex pt-5">
<RouteDiscovery project={project} />
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -349,40 +349,33 @@ export function ProjectGeneralSettingsPageUI(props: {
isUpdatingProject={updateProject.isPending}
handleSubmit={handleSubmit}
/>

<ProjectImageSetting
updateProjectImage={props.updateProjectImage}
avatar={project.image || null}
client={props.client}
/>

<ProjectKeyDetails
project={project}
rotateSecretKey={props.rotateSecretKey}
/>

<ProjectIdCard project={project} />

<AllowedDomainsSetting
form={form}
isUpdatingProject={updateProject.isPending}
handleSubmit={handleSubmit}
/>

<AllowedBundleIDsSetting
form={form}
isUpdatingProject={updateProject.isPending}
handleSubmit={handleSubmit}
/>

<EnabledServicesSetting
form={form}
isUpdatingProject={updateProject.isPending}
handleSubmit={handleSubmit}
paths={paths}
showNebulaSettings={props.showNebulaSettings}
/>

<TransferProject
isOwnerAccount={props.isOwnerAccount}
client={props.client}
Expand All @@ -391,7 +384,6 @@ export function ProjectGeneralSettingsPageUI(props: {
currentTeamId={project.teamId}
transferProject={props.transferProject}
/>

<DeleteProject
projectName={project.name}
deleteProject={props.deleteProject}
Expand Down
165 changes: 165 additions & 0 deletions apps/dashboard/src/components/pay/RouteDiscovery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"use client";
import { addUniversalBridgeTokenRoute } from "@/api/universal-bridge/tokens"; // Adjust the import path
import { RouteDiscoveryCard } from "@/components/blocks/RouteDiscoveryCard";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
import {
type RouteDiscoveryValidationSchema,
routeDiscoveryValidationSchema,
} from "components/settings/ApiKeys/validations";
import { useTrack } from "hooks/analytics/useTrack";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

const TRACKING_CATEGORY = "token_discovery";

export const RouteDiscovery = ({ project }: { project: ProjectResponse }) => {
const form = useForm<RouteDiscoveryValidationSchema>({
resolver: zodResolver(routeDiscoveryValidationSchema),
defaultValues: {
chainId: 1,
tokenAddress: undefined,
},
});

const trackEvent = useTrack();

const submitDiscoveryMutation = useMutation({
mutationFn: async (values: {
chainId: number;
tokenAddress: string;
}) => {
// Call the API to add the route
const result = await addUniversalBridgeTokenRoute({
chainId: values.chainId,
tokenAddress: values.tokenAddress,
project,
});

return result;
},
});

const handleSubmit = form.handleSubmit(
({ chainId, tokenAddress }) => {
submitDiscoveryMutation.mutate(
{
chainId,
tokenAddress,
},
{
onSuccess: (data) => {
toast.success("Token submitted successfully!", {
description:
"Thank you for your submission. Contact support if your token doesn't appear after some time.",
});
trackEvent({
category: TRACKING_CATEGORY,
action: "token-discovery-submit",
label: "success",
data: {
tokenAddress,
tokenCount: data?.length || 0,
},
});
},
onError: () => {
toast.error("Token submission failed!", {
description:
"Please double check the network and token address. If issues persist, please reach out to our support team.",
});

// Get appropriate error message
const errorMessage = "An unknown error occurred";

trackEvent({
category: TRACKING_CATEGORY,
action: "token-discovery-submit",
label: "error",
error: errorMessage,
});
},
},
);
},
() => {
toast.error("Please fix the form errors before submitting");
},
);

return (
<Form {...form}>
<form onSubmit={handleSubmit} autoComplete="off">
<RouteDiscoveryCard
bottomText=""
errorText={form.getFieldState("tokenAddress").error?.message}
saveButton={
// Only show the submit button in the default state
{
type: "submit",
disabled: !form.formState.isDirty,
isPending: submitDiscoveryMutation.isPending,
variant: "outline",
}
}
noPermissionText={undefined}
>
<div>
<h3 className="font-semibold text-xl tracking-tight">
Don't see your token listed?
</h3>
<p className="mt-1.5 mb-4 text-foreground text-sm">
Select your chain and input the token address to automatically
kick-off the token route discovery process. Please check back on
this page within 20-40 minutes of submitting this form.
</p>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="chainId"
render={({ field }) => (
<FormItem>
<FormLabel>Blockchain</FormLabel>
<FormControl>
<NetworkSelectorButton
onSwitchChain={(chain) => {
// Update the form field value
field.onChange(chain.chainId);
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Token Address</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input {...field} placeholder="0x..." />
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</RouteDiscoveryCard>
</form>
</Form>
);
};
Loading
Loading