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, - }; }