Skip to content

Commit 5541e1b

Browse files
[SDK] Allow passing NATIVE_TOKEN_ADDRESS to getWalletBalance()
1 parent 4f2dbcc commit 5541e1b

File tree

5 files changed

+210
-35
lines changed

5 files changed

+210
-35
lines changed

.changeset/chatty-rules-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Allow passing NATIVE_TOKEN_ADDRESS to getWalletBalance()

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx

Lines changed: 194 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
1414
import { useForm } from "react-hook-form";
1515
import { toast } from "sonner";
16-
import { type ThirdwebClient, toWei } from "thirdweb";
16+
import {
17+
getContract,
18+
readContract,
19+
type ThirdwebClient,
20+
toUnits,
21+
} from "thirdweb";
1722
import { useWalletBalance } from "thirdweb/react";
1823
import { isAddress } from "thirdweb/utils";
1924
import { z } from "zod";
2025
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
2126
import type { Project } from "@/api/project/projects";
27+
import type { TokenMetadata } from "@/api/universal-bridge/types";
2228
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
2329
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
30+
import { TokenSelector } from "@/components/blocks/TokenSelector";
2431
import { WalletAddress } from "@/components/blocks/wallet-address";
2532
import { Badge } from "@/components/ui/badge";
2633
import { 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+
81125
export 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>

apps/portal/src/app/x402/client/page.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ The client library wraps the native `fetch` API and handles:
2121
4. Creating and signing payment authorization
2222
5. Retrying the request with payment credentials
2323

24-
<Tabs defaultValue="typescript">
24+
<Tabs defaultValue="react">
2525
<TabsList>
26-
<TabsTrigger value="typescript" className="flex items-center [&>p]:mb-0">
27-
<TypeScriptIcon className="size-4 mr-1.5" />
28-
TypeScript
29-
</TabsTrigger>
3026
<TabsTrigger value="react" className="flex items-center [&>p]:mb-0">
3127
<ReactIcon className="size-4 mr-1.5" />
3228
React
29+
</TabsTrigger>
30+
<TabsTrigger value="typescript" className="flex items-center [&>p]:mb-0">
31+
<TypeScriptIcon className="size-4 mr-1.5" />
32+
TypeScript
3333
</TabsTrigger>
3434
<TabsTrigger value="http" className="flex items-center [&>p]:mb-0">
3535
<EngineIcon className="size-4 mr-1.5" />

apps/portal/src/app/x402/page.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@ The payment token must support either:
3333

3434
## Client Side
3535

36-
<Tabs defaultValue="typescript">
36+
<Tabs defaultValue="react">
3737
<TabsList>
38-
<TabsTrigger value="typescript" className="flex items-center [&>p]:mb-0">
39-
<TypeScriptIcon className="size-4 mr-1.5" />
40-
TypeScript
41-
</TabsTrigger>
4238
<TabsTrigger value="react" className="flex items-center [&>p]:mb-0">
4339
<ReactIcon className="size-4 mr-1.5" />
4440
React
4541
</TabsTrigger>
42+
<TabsTrigger value="typescript" className="flex items-center [&>p]:mb-0">
43+
<TypeScriptIcon className="size-4 mr-1.5" />
44+
TypeScript
45+
</TabsTrigger>
4646
</TabsList>
4747

4848
<TabsContent value="typescript">

0 commit comments

Comments
 (0)