From 5ab59ff400cc32caa414354855fa07831ed90f3a Mon Sep 17 00:00:00 2001 From: MananTank Date: Thu, 22 May 2025 23:15:23 +0000 Subject: [PATCH] Dashboard: Add token selector in asset creation wizard (#7132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on enhancing the token sale functionality within the application. It introduces new properties, refines existing structures, and improves the token selection process to accommodate native tokens and better handle token sales. ### Detailed summary - Added `saleTokenAddress` to the form validation schema. - Updated `CreateTokenAssetPage` to handle native token address logic. - Changed `TokenSelector` to support selected tokens and improved token address handling. - Enhanced `TokenSaleSection` to include a currency field for token sales. - Refined state management in `PaymentLinkForm` for better token handling. - Modified `TokenInfoFieldset` to trigger updates on chain changes. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../blocks/TokenSelector.stories.tsx | 20 +++- .../src/@/components/blocks/TokenSelector.tsx | 112 ++++++++++++++---- .../assets/create/create-token-page-impl.tsx | 7 ++ .../create/create-token-page.client.tsx | 20 +++- .../distribution/token-distribution.tsx | 9 +- .../assets/create/distribution/token-sale.tsx | 59 ++++++--- .../[project_slug]/assets/create/form.ts | 1 + .../assets/create/launch/launch-token.tsx | 1 - .../assets/create/token-info-fieldset.tsx | 3 +- .../components/list-access-tokens.client.tsx | 4 +- .../client/PaymentLinkForm.client.tsx | 20 +++- apps/dashboard/src/hooks/tokens/tokens.ts | 99 +--------------- 12 files changed, 201 insertions(+), 154 deletions(-) diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx index 157d690b3b7..2a2a4770d0a 100644 --- a/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx @@ -35,14 +35,28 @@ function Variant(props: { label: string; selectedChainId?: number; }) { - const [tokenAddress, setTokenAddress] = useState(""); + const [token, setToken] = useState< + | { + address: string; + chainId: number; + } + | undefined + >(undefined); + return ( { + setToken({ + address: v.address, + chainId: v.chainId, + }); + }} /> ); diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx index 53e24896aca..de02913dcfa 100644 --- a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -1,9 +1,15 @@ import { useCallback, useMemo } from "react"; -import type { ThirdwebClient } from "thirdweb"; +import { + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, + getAddress, +} from "thirdweb"; import { shortenAddress } from "thirdweb/utils"; +import { useAllChainsData } from "../../../hooks/chains/allChains"; import { useTokensData } from "../../../hooks/tokens/tokens"; import { replaceIpfsUrl } from "../../../lib/sdk"; import { fallbackChainIcon } from "../../../utils/chain-icons"; +import type { TokenMetadata } from "../../api/universal-bridge/tokens"; import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { Img } from "./Img"; @@ -11,37 +17,87 @@ import { SelectWithSearch } from "./select-with-search"; type Option = { label: string; value: string }; +const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + export function TokenSelector(props: { - tokenAddress: string | undefined; - onChange: (tokenAddress: string) => void; + selectedToken: { chainId: number; address: string } | undefined; + onChange: (token: TokenMetadata) => void; className?: string; popoverContentClassName?: string; chainId?: number; side?: "left" | "right" | "top" | "bottom"; - disableChainId?: boolean; + disableAddress?: boolean; align?: "center" | "start" | "end"; placeholder?: string; client: ThirdwebClient; disabled?: boolean; enabled?: boolean; + showCheck: boolean; + addNativeTokenIfMissing: boolean; }) { - const { tokens, isFetching } = useTokensData({ + const tokensQuery = useTokensData({ chainId: props.chainId, enabled: props.enabled, }); + const { idToChain } = useAllChainsData(); + + const tokens = useMemo(() => { + if (!tokensQuery.data) { + return []; + } + + if (props.addNativeTokenIfMissing) { + const hasNativeToken = tokensQuery.data.some( + (token) => token.address === checksummedNativeTokenAddress, + ); + + if (!hasNativeToken && props.chainId) { + return [ + { + name: + idToChain.get(props.chainId)?.nativeCurrency.name ?? + "Native Token", + symbol: + idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH", + decimals: 18, + chainId: props.chainId, + address: checksummedNativeTokenAddress, + } satisfies TokenMetadata, + ...tokensQuery.data, + ]; + } + } + return tokensQuery.data; + }, [ + tokensQuery.data, + props.chainId, + props.addNativeTokenIfMissing, + idToChain, + ]); + + const addressChainToToken = useMemo(() => { + const value = new Map(); + for (const token of tokens) { + value.set(`${token.chainId}:${token.address}`, token); + } + return value; + }, [tokens]); + const options = useMemo(() => { - return tokens.allTokens.map((token) => { - return { - label: token.symbol, - value: `${token.chainId}:${token.address}`, - }; - }); - }, [tokens.allTokens]); + return ( + tokens.map((token) => { + return { + label: token.symbol, + value: `${token.chainId}:${token.address}`, + }; + }) || [] + ); + }, [tokens]); const searchFn = useCallback( (option: Option, searchValue: string) => { - const token = tokens.addressChainToToken.get(option.value); + const token = addressChainToToken.get(option.value); if (!token) { return false; } @@ -55,12 +111,12 @@ export function TokenSelector(props: { token.address.toLowerCase().includes(searchValue.toLowerCase()) ); }, - [tokens], + [addressChainToToken], ); const renderOption = useCallback( (option: Option) => { - const token = tokens.addressChainToToken.get(option.value); + const token = addressChainToToken.get(option.value); if (!token) { return option.label; } @@ -87,8 +143,8 @@ export function TokenSelector(props: { {token.symbol} - {!props.disableChainId && ( - + {!props.disableAddress && ( + Address {shortenAddress(token.address, 4)} @@ -96,27 +152,37 @@ export function TokenSelector(props: { ); }, - [tokens, props.disableChainId, props.client], + [addressChainToToken, props.disableAddress, props.client], ); + const selectedValue = props.selectedToken + ? `${props.selectedToken.chainId}:${props.selectedToken.address}` + : undefined; + return ( { - props.onChange(tokenAddress); + const token = addressChainToToken.get(tokenAddress); + if (!token) { + return; + } + props.onChange(token); }} closeOnSelect={true} - showCheck={false} + showCheck={props.showCheck} placeholder={ - isFetching ? "Loading Tokens..." : props.placeholder || "Select Token" + tokensQuery.isPending + ? "Loading Tokens..." + : props.placeholder || "Select Token" } overrideSearchFn={searchFn} renderOption={renderOption} className={props.className} popoverContentClassName={props.popoverContentClassName} - disabled={isFetching || props.disabled} + disabled={tokensQuery.isPending || props.disabled} side={props.side} align={props.align} /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx index ccb7f70418c..5c3e728cbad 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page-impl.tsx @@ -10,7 +10,9 @@ import { defineDashboardChain } from "lib/defineDashboardChain"; import { useRef } from "react"; import { toast } from "sonner"; import { + NATIVE_TOKEN_ADDRESS, type ThirdwebClient, + getAddress, getContract, sendAndConfirmTransaction, toUnits, @@ -286,6 +288,11 @@ export function CreateTokenAssetPage(props: { formValues.saleEnabled && salePercent > 0 ? formValues.salePrice : "0", + currencyAddress: + getAddress(formValues.saleTokenAddress) === + getAddress(NATIVE_TOKEN_ADDRESS) + ? undefined + : formValues.saleTokenAddress, startTime: new Date(), metadata: { name: diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx index 8724e7f4052..cd9c5630636 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/create-token-page.client.tsx @@ -1,12 +1,13 @@ "use client"; -import {} from "@/components/blocks/multi-step-status/multi-step-status"; -import {} from "@/components/ui/dialog"; import { zodResolver } from "@hookform/resolvers/zod"; -import {} from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; -import type { ThirdwebClient } from "thirdweb"; +import { + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, + getAddress, +} from "thirdweb"; import { TokenDistributionFieldset } from "./distribution/token-distribution"; import { type CreateAssetFormValues, @@ -27,6 +28,8 @@ export type CreateTokenFunctions = { airdropTokens: (values: CreateAssetFormValues) => Promise; }; +const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS); + export function CreateTokenAssetPageUI(props: { accountAddress: string; client: ThirdwebClient; @@ -64,6 +67,7 @@ export function CreateTokenAssetPageUI(props: { values: { // sale fieldset saleAllocationPercentage: "0", + saleTokenAddress: checksummedNativeTokenAddress, salePrice: "0.1", supply: "1000000", saleEnabled: false, @@ -78,6 +82,13 @@ export function CreateTokenAssetPageUI(props: {
{step === "token-info" && ( { + // if the chain is updated, set the sale token address to the native token address + tokenDistributionForm.setValue( + "saleTokenAddress", + checksummedNativeTokenAddress, + ); + }} client={props.client} form={tokenInfoForm} onNext={() => { @@ -88,6 +99,7 @@ export function CreateTokenAssetPageUI(props: { {step === "distribution" && ( void; form: TokenDistributionForm; chainId: string; + client: ThirdwebClient; }) { const { form } = props; @@ -60,7 +61,11 @@ export function TokenDistributionFieldset(props: {
- + diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx index 40c1e6c2f37..37d26407b2e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/distribution/token-sale.tsx @@ -1,22 +1,22 @@ "use client"; import { FormFieldSetup } from "@/components/blocks/FormFieldSetup"; +import { TokenSelector } from "@/components/blocks/TokenSelector"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { useAllChainsData } from "hooks/chains/allChains"; +import type { ThirdwebClient } from "thirdweb"; import type { TokenDistributionForm } from "../form"; export function TokenSaleSection(props: { form: TokenDistributionForm; chainId: string; + client: ThirdwebClient; }) { const totalSupply = Number(props.form.watch("supply")); const sellSupply = Math.floor( (totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100, ); - const { idToChain } = useAllChainsData(); - const selectedChainMeta = idToChain.get(Number(props.chainId)); const isEnabled = props.form.watch("saleEnabled"); return ( @@ -43,7 +43,7 @@ export function TokenSaleSection(props: { {isEnabled && ( -
+
- -
- + +
+ { + props.form.setValue("salePrice", value); + }} + /> +
+
+ + + { - props.form.setValue("salePrice", value); + props.form.setValue("saleTokenAddress", value.address); }} + client={props.client} + chainId={Number(props.chainId)} /> - - {selectedChainMeta?.nativeCurrency.symbol || "ETH"} - -
-
+ +
)} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts index d6f633c8c27..a15598059b1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/form.ts @@ -60,6 +60,7 @@ export const tokenDistributionFormSchema = z.object({ message: "Must be a number between 0 and 100", }, ), + saleTokenAddress: z.string(), salePrice: z.string().refine( (value) => { const number = Number(value); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx index 086f40ea0c8..ad307554c2a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/assets/create/launch/launch-token.tsx @@ -153,7 +153,6 @@ export function LaunchTokenStatus(props: { type: "custom", custom: ( void; form: TokenInfoForm; + onChainUpdated: () => void; }) { const { form } = props; return ( @@ -104,6 +104,7 @@ export function TokenInfoFieldset(props: { chainId={Number(form.watch("chain"))} onChange={(chain) => { form.setValue("chain", chain.toString()); + props.onChainUpdated(); }} disableChainId /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx index ecd867d4e94..79f2446d67f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/engine/cloud/vault/components/list-access-tokens.client.tsx @@ -1,9 +1,7 @@ "use client"; import type { Project } from "@/api/projects"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; -import {} from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import {} from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -14,11 +12,11 @@ import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { listAccessTokens, revokeAccessToken } from "@thirdweb-dev/vault-sdk"; +import { useTrack } from "hooks/analytics/useTrack"; import { Loader2Icon, LockIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { toDateTimeLocal } from "utils/date-utils"; -import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack"; import { SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE, createWalletAccessToken, diff --git a/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx b/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx index 201d1bf1d86..3e03494cf18 100644 --- a/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx +++ b/apps/dashboard/src/app/pay/components/client/PaymentLinkForm.client.tsx @@ -27,6 +27,7 @@ import { resolveEns } from "../../../../lib/ens"; export function PaymentLinkForm() { const [chainId, setChainId] = useState(); const [recipientAddress, setRecipientAddress] = useState(""); + // TODO - clean this up later const [tokenAddressWithChain, setTokenAddressWithChain] = useState(""); const [amount, setAmount] = useState(""); const [title, setTitle] = useState(""); @@ -171,6 +172,10 @@ export function PaymentLinkForm() { tokenAddressWithChain, ]); + const [selectedChainId, selectedTokenAddress] = tokenAddressWithChain + ? tokenAddressWithChain.split(":") + : []; + return ( @@ -203,9 +208,20 @@ export function PaymentLinkForm() { Token { + setTokenAddressWithChain(`${value.chainId}:${value.address}`); + }} className="w-full" client={payAppThirdwebClient} disabled={!chainId} diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts index 8bc03562c87..bfdbb381cb6 100644 --- a/apps/dashboard/src/hooks/tokens/tokens.ts +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -1,108 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { useEffect } from "react"; -import type { Address } from "thirdweb"; -import { - type TokenMetadata, - getUniversalBridgeTokens, -} from "../../@/api/universal-bridge/tokens"; -import { createStore, useStore } from "../../@/lib/reactive"; - -type StructuredTokensStore = { - allTokens: TokenMetadata[]; - nameToToken: Map; - symbolToTokens: Map; - chainToTokens: Map; - addressToToken: Map; - addressChainToToken: Map; -}; - -function createStructuredTokensStore() { - const store = createStore({ - allTokens: [], - nameToToken: new Map(), - symbolToTokens: new Map(), - chainToTokens: new Map(), - addressToToken: new Map(), - addressChainToToken: new Map(), - }); - - const dependencies = [tokensStore]; - for (const dep of dependencies) { - dep.subscribe(() => { - updateStructuredTokensStore(tokensStore.getValue()); - }); - } - - function updateStructuredTokensStore(tokens: TokenMetadata[]) { - // if original tokens are not loaded yet - ignore - if (tokens.length === 0) { - return; - } - - const allTokens: TokenMetadata[] = []; - const nameToToken: Map = new Map(); - const symbolToTokens: Map = new Map(); - const chainToTokens: Map = new Map(); - const addressToTokens: Map = new Map(); - const addressChainToToken: Map = - new Map(); - - for (const token of tokens) { - allTokens.push(token); - nameToToken.set(token.name, [ - ...(nameToToken.get(token.name) || []), - token, - ]); - symbolToTokens.set(token.symbol, [ - ...(symbolToTokens.get(token.symbol) || []), - token, - ]); - chainToTokens.set(token.chainId, [ - ...(chainToTokens.get(token.chainId) || []), - token, - ]); - addressToTokens.set(token.address as Address, token); - addressChainToToken.set(`${token.chainId}:${token.address}`, token); - } - - store.setValue({ - allTokens, - nameToToken, - symbolToTokens, - chainToTokens, - addressToToken: addressToTokens, - addressChainToToken: addressChainToToken, - }); - } - - return store; -} - -const tokensStore = /* @__PURE__ */ createStore([]); -const structuredTokensStore = /* @__PURE__ */ createStructuredTokensStore(); +import { getUniversalBridgeTokens } from "../../@/api/universal-bridge/tokens"; export function useTokensData({ chainId, enabled, }: { chainId?: number; enabled?: boolean }) { - const tokensQuery = useQuery({ + return useQuery({ queryKey: ["universal-bridge-tokens", chainId], queryFn: () => getUniversalBridgeTokens({ chainId }), enabled, }); - - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (!tokensQuery.data) { - return; - } - - tokensStore.setValue(tokensQuery.data); - }, [tokensQuery.data]); - - return { - tokens: useStore(structuredTokensStore), - isLoading: tokensQuery.isLoading, - isFetching: tokensQuery.isFetching, - }; }