@@ -10,17 +10,24 @@ import {
1010 ShuffleIcon ,
1111 WalletIcon ,
1212} from "lucide-react" ;
13- import { useMemo , useState } from "react" ;
13+ import { useCallback , useMemo , useState } from "react" ;
1414import { useForm } from "react-hook-form" ;
1515import { toast } from "sonner" ;
16- import { type ThirdwebClient , toWei } from "thirdweb" ;
16+ import {
17+ getContract ,
18+ readContract ,
19+ type ThirdwebClient ,
20+ toUnits ,
21+ } from "thirdweb" ;
1722import { useWalletBalance } from "thirdweb/react" ;
1823import { isAddress } from "thirdweb/utils" ;
1924import { z } from "zod" ;
2025import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens" ;
2126import type { Project } from "@/api/project/projects" ;
27+ import type { TokenMetadata } from "@/api/universal-bridge/types" ;
2228import { FundWalletModal } from "@/components/blocks/fund-wallets-modal" ;
2329import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors" ;
30+ import { TokenSelector } from "@/components/blocks/TokenSelector" ;
2431import { WalletAddress } from "@/components/blocks/wallet-address" ;
2532import { Badge } from "@/components/ui/badge" ;
2633import { Button } from "@/components/ui/button" ;
@@ -78,15 +85,96 @@ type ProjectWalletControlsProps = {
7885 client : ThirdwebClient ;
7986} ;
8087
88+ const STORAGE_KEY_PREFIX = "project-wallet-selection" ;
89+
90+ function getStorageKey ( projectId : string ) : string {
91+ return `${ STORAGE_KEY_PREFIX } -${ projectId } ` ;
92+ }
93+
94+ type StoredSelection = {
95+ chainId : number ;
96+ tokenAddress : string | undefined ;
97+ } ;
98+
99+ function readStoredSelection ( projectId : string ) : StoredSelection | null {
100+ if ( typeof window === "undefined" ) {
101+ return null ;
102+ }
103+ try {
104+ const stored = localStorage . getItem ( getStorageKey ( projectId ) ) ;
105+ if ( stored ) {
106+ return JSON . parse ( stored ) as StoredSelection ;
107+ }
108+ } catch {
109+ // Ignore parse errors
110+ }
111+ return null ;
112+ }
113+
114+ function saveStoredSelection ( projectId : string , selection : StoredSelection ) {
115+ if ( typeof window === "undefined" ) {
116+ return ;
117+ }
118+ try {
119+ localStorage . setItem ( getStorageKey ( projectId ) , JSON . stringify ( selection ) ) ;
120+ } catch {
121+ // Ignore storage errors
122+ }
123+ }
124+
81125export function ProjectWalletDetailsSection ( props : ProjectWalletControlsProps ) {
82126 const { projectWallet, project, defaultChainId } = props ;
83127 const [ isSendOpen , setIsSendOpen ] = useState ( false ) ;
84128 const [ isReceiveOpen , setIsReceiveOpen ] = useState ( false ) ;
85- const [ selectedChainId , setSelectedChainId ] = useState ( defaultChainId ?? 1 ) ;
86129 const [ isChangeWalletOpen , setIsChangeWalletOpen ] = useState ( false ) ;
87130
131+ // Initialize chain and token from localStorage or defaults
132+ const [ selectedChainId , setSelectedChainId ] = useState ( ( ) => {
133+ const stored = readStoredSelection ( project . id ) ;
134+ return stored ?. chainId ?? defaultChainId ?? 1 ;
135+ } ) ;
136+ const [ selectedTokenAddress , setSelectedTokenAddress ] = useState <
137+ string | undefined
138+ > ( ( ) => {
139+ const stored = readStoredSelection ( project . id ) ;
140+ if ( stored ) {
141+ return stored . tokenAddress ;
142+ }
143+ return undefined ;
144+ } ) ;
145+
88146 const chain = useV5DashboardChain ( selectedChainId ) ;
89147
148+ // Handle chain change - reset token to native when chain changes
149+ const handleChainChange = useCallback (
150+ ( newChainId : number ) => {
151+ setSelectedChainId ( ( prevChainId ) => {
152+ if ( prevChainId !== newChainId ) {
153+ // Reset token to native (undefined) when chain changes
154+ setSelectedTokenAddress ( undefined ) ;
155+ saveStoredSelection ( project . id , {
156+ chainId : newChainId ,
157+ tokenAddress : undefined ,
158+ } ) ;
159+ }
160+ return newChainId ;
161+ } ) ;
162+ } ,
163+ [ project . id ] ,
164+ ) ;
165+
166+ // Handle token change
167+ const handleTokenChange = useCallback (
168+ ( token : TokenMetadata ) => {
169+ setSelectedTokenAddress ( token . address ) ;
170+ saveStoredSelection ( project . id , {
171+ chainId : selectedChainId ,
172+ tokenAddress : token . address ,
173+ } ) ;
174+ } ,
175+ [ project . id , selectedChainId ] ,
176+ ) ;
177+
90178 const engineCloudService = useMemo (
91179 ( ) => project . services ?. find ( ( service ) => service . name === "engineCloud" ) ,
92180 [ project . services ] ,
@@ -117,6 +205,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
117205 address : projectWallet . address ,
118206 chain,
119207 client : props . client ,
208+ tokenAddress : selectedTokenAddress ,
120209 } ) ;
121210
122211 const canChangeWallet =
@@ -200,7 +289,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
200289 </ div >
201290 </ div >
202291
203- < div className = "p-5 border-t border-dashed flex justify-between items-center" >
292+ < div className = "p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm: justify-between sm: items-center" >
204293 < div >
205294 < p className = "text-sm text-foreground mb-1" > Balance</ p >
206295 < div className = "flex items-center gap-1" >
@@ -229,17 +318,36 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
229318 </ div >
230319 </ div >
231320
232- < SingleNetworkSelector
233- chainId = { selectedChainId }
234- className = "w-fit rounded-full bg-background hover:bg-accent/50"
235- client = { props . client }
236- disableDeprecated
237- disableChainId
238- onChange = { setSelectedChainId }
239- placeholder = "Select network"
240- popoverContentClassName = "!w-[320px] rounded-xl overflow-hidden"
241- align = "end"
242- />
321+ < div className = "flex flex-col gap-2 sm:flex-row sm:items-center" >
322+ < SingleNetworkSelector
323+ chainId = { selectedChainId }
324+ className = "w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
325+ client = { props . client }
326+ disableDeprecated
327+ disableChainId
328+ onChange = { handleChainChange }
329+ placeholder = "Select network"
330+ popoverContentClassName = "!w-[320px] rounded-xl overflow-hidden"
331+ align = "end"
332+ />
333+ < TokenSelector
334+ selectedToken = {
335+ selectedTokenAddress
336+ ? { chainId : selectedChainId , address : selectedTokenAddress }
337+ : undefined
338+ }
339+ onChange = { handleTokenChange }
340+ chainId = { selectedChainId }
341+ client = { props . client }
342+ enabled = { true }
343+ showCheck = { true }
344+ addNativeTokenIfMissing = { true }
345+ placeholder = "Native token"
346+ className = "w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
347+ popoverContentClassName = "!w-[320px] rounded-xl overflow-hidden"
348+ align = "end"
349+ />
350+ </ div >
243351 </ div >
244352 </ div >
245353
@@ -253,6 +361,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
253361 open = { isSendOpen }
254362 publishableKey = { project . publishableKey }
255363 teamId = { project . teamId }
364+ tokenAddress = { selectedTokenAddress }
256365 walletAddress = { projectWallet . address }
257366 />
258367
@@ -467,6 +576,7 @@ const createSendFormSchema = (secretKeyLabel: string) =>
467576 chainId : z . number ( {
468577 required_error : "Select a network" ,
469578 } ) ,
579+ tokenAddress : z . string ( ) . optional ( ) ,
470580 toAddress : z
471581 . string ( )
472582 . trim ( )
@@ -491,6 +601,7 @@ type SendProjectWalletModalProps = {
491601 publishableKey : string ;
492602 teamId : string ;
493603 chainId : number ;
604+ tokenAddress ?: string ;
494605 label : string ;
495606 client : ReturnType < typeof getClientThirdwebClient > ;
496607 isManagedVault : boolean ;
@@ -528,6 +639,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
528639 publishableKey,
529640 teamId,
530641 chainId,
642+ tokenAddress,
531643 label,
532644 client,
533645 isManagedVault,
@@ -539,6 +651,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
539651 defaultValues : {
540652 amount : "0" ,
541653 chainId,
654+ tokenAddress,
542655 secretKey : "" ,
543656 vaultAccessToken : "" ,
544657 toAddress : "" ,
@@ -548,10 +661,31 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
548661 } ) ;
549662
550663 const selectedChain = useV5DashboardChain ( form . watch ( "chainId" ) ) ;
664+ const selectedFormChainId = form . watch ( "chainId" ) ;
665+ const selectedFormTokenAddress = form . watch ( "tokenAddress" ) ;
666+
667+ // Track the selected token symbol for display
668+ const [ selectedTokenSymbol , setSelectedTokenSymbol ] = useState <
669+ string | undefined
670+ > ( undefined ) ;
551671
552672 const sendMutation = useMutation ( {
553673 mutationFn : async ( values : SendFormValues ) => {
554- const quantityWei = toWei ( values . amount ) . toString ( ) ;
674+ let decimals = 18 ;
675+ if ( values . tokenAddress ) {
676+ const decimalsRpc = await readContract ( {
677+ contract : getContract ( {
678+ address : values . tokenAddress ,
679+ chain : selectedChain ,
680+ client,
681+ } ) ,
682+ method : "function decimals() view returns (uint8)" ,
683+ params : [ ] ,
684+ } ) ;
685+ decimals = Number ( decimalsRpc ) ;
686+ console . log ( "decimals" , decimals ) ;
687+ }
688+ const quantityWei = toUnits ( values . amount , decimals ) . toString ( ) ;
555689 const secretKeyValue = values . secretKey . trim ( ) ;
556690 const vaultAccessTokenValue = values . vaultAccessToken . trim ( ) ;
557691
@@ -563,16 +697,16 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
563697 teamId,
564698 walletAddress,
565699 secretKey : secretKeyValue ,
700+ ...( values . tokenAddress ? { tokenAddress : values . tokenAddress } : { } ) ,
566701 ...( vaultAccessTokenValue
567702 ? { vaultAccessToken : vaultAccessTokenValue }
568703 : { } ) ,
569704 } ) ;
570705
571706 if ( ! result . ok ) {
572- const errorMessage =
573- typeof result . error === "string"
574- ? result . error
575- : "Failed to send funds" ;
707+ const errorMessage = result . error
708+ ? JSON . stringify ( result . error , null , 2 )
709+ : "Failed to send funds" ;
576710 throw new Error ( errorMessage ) ;
577711 }
578712
@@ -623,6 +757,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
623757 disableDeprecated
624758 onChange = { ( nextChainId ) => {
625759 field . onChange ( nextChainId ) ;
760+ // Reset token to native when chain changes
761+ form . setValue ( "tokenAddress" , undefined ) ;
762+ setSelectedTokenSymbol ( undefined ) ;
626763 } }
627764 placeholder = "Select network"
628765 />
@@ -632,6 +769,40 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
632769 ) }
633770 />
634771
772+ < FormField
773+ control = { form . control }
774+ name = "tokenAddress"
775+ render = { ( { field } ) => (
776+ < FormItem >
777+ < FormLabel > Token</ FormLabel >
778+ < FormControl >
779+ < TokenSelector
780+ selectedToken = {
781+ field . value
782+ ? {
783+ chainId : selectedFormChainId ,
784+ address : field . value ,
785+ }
786+ : undefined
787+ }
788+ onChange = { ( token ) => {
789+ field . onChange ( token . address ) ;
790+ setSelectedTokenSymbol ( token . symbol ) ;
791+ } }
792+ chainId = { selectedFormChainId }
793+ client = { client }
794+ enabled = { true }
795+ showCheck = { true }
796+ addNativeTokenIfMissing = { true }
797+ placeholder = "Native token"
798+ className = "w-full bg-card"
799+ />
800+ </ FormControl >
801+ < FormMessage />
802+ </ FormItem >
803+ ) }
804+ />
805+
635806 < FormField
636807 control = { form . control }
637808 name = "toAddress"
@@ -656,10 +827,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
656827 < Input inputMode = "decimal" min = "0" step = "any" { ...field } />
657828 </ FormControl >
658829 < FormDescription >
659- Sending native token
660- { selectedChain ?. nativeCurrency ?. symbol
661- ? ` (${ selectedChain . nativeCurrency . symbol } )`
662- : "" }
830+ { selectedFormTokenAddress
831+ ? `Sending ${ selectedTokenSymbol || "token" } `
832+ : `Sending native token${ selectedChain ?. nativeCurrency ?. symbol ? ` (${ selectedChain . nativeCurrency . symbol } )` : "" } ` }
663833 </ FormDescription >
664834 < FormMessage />
665835 </ FormItem >
0 commit comments