[SDK] Allow passing activeWallet to SwapWidget (#8504)

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `SwapWidget` by allowing the passing of an `activeWallet` prop and improving the project wallet functionality in the dashboard. It also includes updates to the VSCode settings and introduces new components for swapping tokens.

### Detailed summary
- Added `activeWallet` prop to `SwapWidgetProps`.
- Modified `useActiveWalletInfo` to accept an `activeWalletOverride`.
- Introduced `SwapProjectWalletModalContent` for token swapping.
- Updated `ProjectWalletDetailsSection` to include swap functionality.
- Adjusted VSCode settings for formatting preferences.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added in-app token swap functionality for project wallets, allowing users to exchange tokens directly from the wallet interface with credentials management.
  * SwapWidget now supports wallet pre-selection, enabling faster and more streamlined token swap initiation.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
joaquim-verges
2025-12-05 00:03:49 +00:00
parent b0eccf365a
commit a704c3f2b0
7 changed files with 256 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---
Allow passing an activeWallet to SwapWidget
+5 -2
View File
@@ -1,4 +1,6 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
@@ -18,8 +20,9 @@
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.fixAll.biome": "explicit"
"quickfix.biome": "always",
"source.fixAll.biome": "always",
"source.organizeImports.biome": "always"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
@@ -13,6 +13,7 @@ import {
import {
THIRDWEB_BRIDGE_URL,
THIRDWEB_BUNDLER_DOMAIN,
THIRDWEB_ENGINE_CLOUD_URL,
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_INSIGHT_API_DOMAIN,
THIRDWEB_PAY_DOMAIN,
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
rpc: THIRDWEB_RPC_DOMAIN,
social: THIRDWEB_SOCIAL_API_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
});
}
+3
View File
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
export const THIRDWEB_BRIDGE_URL =
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ArrowLeftRightIcon,
EllipsisVerticalIcon,
RefreshCcwIcon,
@@ -10,17 +11,22 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
createThirdwebClient,
Engine,
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import type { Chain } from "thirdweb/chains";
import { SwapWidget, useWalletBalance } from "thirdweb/react";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { createWalletAdapter } from "thirdweb/wallets";
import { z } from "zod";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
@@ -69,6 +75,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
type GetProjectServerWallets = (params: {
@@ -127,6 +134,11 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
const [isSwapOpen, setIsSwapOpen] = useState(false);
// Persist swap credentials in memory so users don't have to re-enter them
const [swapSecretKey, setSwapSecretKey] = useState("");
const [swapVaultAccessToken, setSwapVaultAccessToken] = useState("");
// Initialize chain and token from localStorage or defaults
const [selectedChainId, setSelectedChainId] = useState(() => {
@@ -310,6 +322,15 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
<SendIcon className="size-4" />
Withdraw
</Button>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4" />
Swap
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -394,6 +415,24 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}>
<DialogContent className="gap-0 p-0 overflow-hidden max-w-md">
<SwapProjectWalletModalContent
chainId={selectedChainId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
chain={chain}
isManagedVault={isManagedVault}
publishableKey={project.publishableKey}
secretKey={swapSecretKey}
setSecretKey={setSwapSecretKey}
vaultAccessToken={swapVaultAccessToken}
setVaultAccessToken={setSwapVaultAccessToken}
onClose={() => setIsSwapOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
);
}
@@ -573,6 +612,189 @@ function ChangeProjectWalletDialogContent(props: {
);
}
type SwapProjectWalletModalContentProps = {
chainId: number;
tokenAddress: string | undefined;
walletAddress: string;
chain: Chain;
isManagedVault: boolean;
publishableKey: string;
secretKey: string;
setSecretKey: (value: string) => void;
vaultAccessToken: string;
setVaultAccessToken: (value: string) => void;
onClose: () => void;
};
function SwapProjectWalletModalContent(
props: SwapProjectWalletModalContentProps,
) {
const {
chainId,
tokenAddress,
walletAddress,
chain,
isManagedVault,
publishableKey,
secretKey,
setSecretKey,
vaultAccessToken,
setVaultAccessToken,
onClose,
} = props;
const [screen, setScreen] = useState<"credentials" | "swap">("credentials");
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
const hasRequiredCredentials = isManagedVault
? secretKey.trim().length > 0
: secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0;
const swapClient = useMemo(() => {
if (!secretKey.trim()) {
return null;
}
return createThirdwebClient({
clientId: publishableKey,
secretKey: secretKey.trim(),
});
}, [secretKey, publishableKey]);
const activeWallet = useMemo(() => {
if (!swapClient) {
return undefined;
}
const vaultAccessTokenValue = vaultAccessToken.trim();
return createWalletAdapter({
adaptedAccount: Engine.serverWallet({
client: swapClient,
address: walletAddress,
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
}),
chain: chain,
client: swapClient,
onDisconnect: () => {},
switchChain: () => {},
});
}, [swapClient, walletAddress, chain, vaultAccessToken]);
// Screen 1: Credentials
if (screen === "credentials") {
return (
<div>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Enter your credentials to swap tokens from your project wallet
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="swap-secret-key"
className="text-sm font-medium leading-none"
>
Project secret key
</label>
<Input
id="swap-secret-key"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">{secretKeyHelper}</p>
</div>
{!isManagedVault && (
<div className="space-y-2">
<label
htmlFor="swap-vault-access-token"
className="text-sm font-medium leading-none"
>
Vault access token
</label>
<Input
id="swap-vault-access-token"
type="password"
placeholder="Enter a vault access token"
value={vaultAccessToken}
onChange={(e) => setVaultAccessToken(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{vaultAccessTokenHelper}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button
onClick={() => setScreen("swap")}
type="button"
disabled={!hasRequiredCredentials}
>
Continue
</Button>
</div>
</div>
);
}
// Screen 2: Swap Widget
return (
<div className="w-full">
<DialogHeader className="p-4 lg:p-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto w-auto"
onClick={() => setScreen("credentials")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Swap tokens from your project wallet
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center">
{swapClient && activeWallet && (
<SwapWidget
client={swapClient}
prefill={{
sellToken: {
chainId: chainId,
tokenAddress: tokenAddress,
},
}}
activeWallet={activeWallet}
theme={getSDKTheme(t)}
/>
)}
</div>
</div>
);
}
const createSendFormSchema = (secretKeyLabel: string) =>
z.object({
chainId: z.number({
@@ -8,6 +8,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { getAddress } from "../../../../../utils/address.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js";
import type { Theme } from "../../../../core/design-system/index.js";
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
@@ -169,6 +170,10 @@ export type SwapWidgetProps = {
* Called when the user disconnects the active wallet
*/
onDisconnect?: () => void;
/**
* The wallet that should be pre-selected in the SwapWidget UI.
*/
activeWallet?: Wallet;
};
/**
@@ -320,7 +325,7 @@ function SwapWidgetContent(
},
) {
const [screen, setScreen] = useState<SwapWidgetScreen>({ id: "1:swap-ui" });
const activeWalletInfo = useActiveWalletInfo();
const activeWalletInfo = useActiveWalletInfo(props.activeWallet);
const isPersistEnabled = props.persistTokenSelections !== false;
const [amountSelection, setAmountSelection] = useState<{
@@ -1,21 +1,27 @@
import { useMemo } from "react";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js";
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js";
import type { ActiveWalletInfo } from "./types.js";
export function useActiveWalletInfo(): ActiveWalletInfo | undefined {
export function useActiveWalletInfo(
activeWalletOverride?: Wallet,
): ActiveWalletInfo | undefined {
const activeAccount = useActiveAccount();
const activeWallet = useActiveWallet();
const activeChain = useActiveWalletChain();
return useMemo(() => {
return activeAccount && activeWallet && activeChain
const wallet = activeWalletOverride || activeWallet;
const chain = activeWalletOverride?.getChain() || activeChain;
const account = activeWalletOverride?.getAccount() || activeAccount;
return wallet && chain && account
? {
activeChain,
activeWallet,
activeAccount,
activeChain: chain,
activeWallet: wallet,
activeAccount: account,
}
: undefined;
}, [activeAccount, activeWallet, activeChain]);
}, [activeAccount, activeWallet, activeChain, activeWalletOverride]);
}