|
3 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; |
4 | 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
5 | 5 | import { |
| 6 | + ArrowLeftIcon, |
6 | 7 | ArrowLeftRightIcon, |
7 | 8 | EllipsisVerticalIcon, |
8 | 9 | RefreshCcwIcon, |
9 | 10 | SendIcon, |
10 | 11 | ShuffleIcon, |
11 | 12 | WalletIcon, |
12 | 13 | } from "lucide-react"; |
| 14 | +import { useTheme } from "next-themes"; |
13 | 15 | import { useCallback, useMemo, useState } from "react"; |
14 | 16 | import { useForm } from "react-hook-form"; |
15 | 17 | import { toast } from "sonner"; |
16 | 18 | import { |
| 19 | + createThirdwebClient, |
| 20 | + Engine, |
17 | 21 | getContract, |
18 | 22 | readContract, |
19 | 23 | type ThirdwebClient, |
20 | 24 | toUnits, |
21 | 25 | } from "thirdweb"; |
22 | | -import { useWalletBalance } from "thirdweb/react"; |
| 26 | +import type { Chain } from "thirdweb/chains"; |
| 27 | +import { SwapWidget, useWalletBalance } from "thirdweb/react"; |
23 | 28 | import { isAddress, shortenAddress } from "thirdweb/utils"; |
| 29 | +import { createWalletAdapter } from "thirdweb/wallets"; |
24 | 30 | import { z } from "zod"; |
25 | 31 | import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens"; |
26 | 32 | import type { Project } from "@/api/project/projects"; |
@@ -69,6 +75,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; |
69 | 75 | import { useDashboardRouter } from "@/lib/DashboardRouter"; |
70 | 76 | import type { ProjectWalletSummary } from "@/lib/server/project-wallet"; |
71 | 77 | import { cn } from "@/lib/utils"; |
| 78 | +import { getSDKTheme } from "@/utils/sdk-component-theme"; |
72 | 79 | import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client"; |
73 | 80 |
|
74 | 81 | type GetProjectServerWallets = (params: { |
@@ -127,6 +134,11 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) { |
127 | 134 | const [isSendOpen, setIsSendOpen] = useState(false); |
128 | 135 | const [isReceiveOpen, setIsReceiveOpen] = useState(false); |
129 | 136 | const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false); |
| 137 | + const [isSwapOpen, setIsSwapOpen] = useState(false); |
| 138 | + |
| 139 | + // Persist swap credentials in memory so users don't have to re-enter them |
| 140 | + const [swapSecretKey, setSwapSecretKey] = useState(""); |
| 141 | + const [swapVaultAccessToken, setSwapVaultAccessToken] = useState(""); |
130 | 142 |
|
131 | 143 | // Initialize chain and token from localStorage or defaults |
132 | 144 | const [selectedChainId, setSelectedChainId] = useState(() => { |
@@ -310,6 +322,15 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) { |
310 | 322 | <SendIcon className="size-4" /> |
311 | 323 | Withdraw |
312 | 324 | </Button> |
| 325 | + <Button |
| 326 | + variant="outline" |
| 327 | + size="sm" |
| 328 | + className="gap-2 bg-background hover:bg-accent/50" |
| 329 | + onClick={() => setIsSwapOpen(true)} |
| 330 | + > |
| 331 | + <ArrowLeftRightIcon className="size-4" /> |
| 332 | + Swap |
| 333 | + </Button> |
313 | 334 | <DropdownMenu> |
314 | 335 | <DropdownMenuTrigger asChild> |
315 | 336 | <Button |
@@ -394,6 +415,24 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) { |
394 | 415 | /> |
395 | 416 | </DialogContent> |
396 | 417 | </Dialog> |
| 418 | + |
| 419 | + <Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}> |
| 420 | + <DialogContent className="gap-0 p-0 overflow-hidden max-w-md"> |
| 421 | + <SwapProjectWalletModalContent |
| 422 | + chainId={selectedChainId} |
| 423 | + tokenAddress={selectedTokenAddress} |
| 424 | + walletAddress={projectWallet.address} |
| 425 | + chain={chain} |
| 426 | + isManagedVault={isManagedVault} |
| 427 | + publishableKey={project.publishableKey} |
| 428 | + secretKey={swapSecretKey} |
| 429 | + setSecretKey={setSwapSecretKey} |
| 430 | + vaultAccessToken={swapVaultAccessToken} |
| 431 | + setVaultAccessToken={setSwapVaultAccessToken} |
| 432 | + onClose={() => setIsSwapOpen(false)} |
| 433 | + /> |
| 434 | + </DialogContent> |
| 435 | + </Dialog> |
397 | 436 | </div> |
398 | 437 | ); |
399 | 438 | } |
@@ -573,6 +612,189 @@ function ChangeProjectWalletDialogContent(props: { |
573 | 612 | ); |
574 | 613 | } |
575 | 614 |
|
| 615 | +type SwapProjectWalletModalContentProps = { |
| 616 | + chainId: number; |
| 617 | + tokenAddress: string | undefined; |
| 618 | + walletAddress: string; |
| 619 | + chain: Chain; |
| 620 | + isManagedVault: boolean; |
| 621 | + publishableKey: string; |
| 622 | + secretKey: string; |
| 623 | + setSecretKey: (value: string) => void; |
| 624 | + vaultAccessToken: string; |
| 625 | + setVaultAccessToken: (value: string) => void; |
| 626 | + onClose: () => void; |
| 627 | +}; |
| 628 | + |
| 629 | +function SwapProjectWalletModalContent( |
| 630 | + props: SwapProjectWalletModalContentProps, |
| 631 | +) { |
| 632 | + const { |
| 633 | + chainId, |
| 634 | + tokenAddress, |
| 635 | + walletAddress, |
| 636 | + chain, |
| 637 | + isManagedVault, |
| 638 | + publishableKey, |
| 639 | + secretKey, |
| 640 | + setSecretKey, |
| 641 | + vaultAccessToken, |
| 642 | + setVaultAccessToken, |
| 643 | + onClose, |
| 644 | + } = props; |
| 645 | + |
| 646 | + const [screen, setScreen] = useState<"credentials" | "swap">("credentials"); |
| 647 | + const { theme } = useTheme(); |
| 648 | + const t = theme === "light" ? "light" : "dark"; |
| 649 | + |
| 650 | + const hasRequiredCredentials = isManagedVault |
| 651 | + ? secretKey.trim().length > 0 |
| 652 | + : secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0; |
| 653 | + |
| 654 | + const swapClient = useMemo(() => { |
| 655 | + if (!secretKey.trim()) { |
| 656 | + return null; |
| 657 | + } |
| 658 | + return createThirdwebClient({ |
| 659 | + clientId: publishableKey, |
| 660 | + secretKey: secretKey.trim(), |
| 661 | + }); |
| 662 | + }, [secretKey, publishableKey]); |
| 663 | + |
| 664 | + const activeWallet = useMemo(() => { |
| 665 | + if (!swapClient) { |
| 666 | + return undefined; |
| 667 | + } |
| 668 | + const vaultAccessTokenValue = vaultAccessToken.trim(); |
| 669 | + return createWalletAdapter({ |
| 670 | + adaptedAccount: Engine.serverWallet({ |
| 671 | + client: swapClient, |
| 672 | + address: walletAddress, |
| 673 | + ...(vaultAccessTokenValue |
| 674 | + ? { vaultAccessToken: vaultAccessTokenValue } |
| 675 | + : {}), |
| 676 | + }), |
| 677 | + chain: chain, |
| 678 | + client: swapClient, |
| 679 | + onDisconnect: () => {}, |
| 680 | + switchChain: () => {}, |
| 681 | + }); |
| 682 | + }, [swapClient, walletAddress, chain, vaultAccessToken]); |
| 683 | + |
| 684 | + // Screen 1: Credentials |
| 685 | + if (screen === "credentials") { |
| 686 | + return ( |
| 687 | + <div> |
| 688 | + <DialogHeader className="p-4 lg:p-6"> |
| 689 | + <DialogTitle>Swap Tokens</DialogTitle> |
| 690 | + <DialogDescription> |
| 691 | + Enter your credentials to swap tokens from your project wallet |
| 692 | + </DialogDescription> |
| 693 | + </DialogHeader> |
| 694 | + |
| 695 | + <div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4"> |
| 696 | + <div className="space-y-2"> |
| 697 | + <label |
| 698 | + htmlFor="swap-secret-key" |
| 699 | + className="text-sm font-medium leading-none" |
| 700 | + > |
| 701 | + Project secret key |
| 702 | + </label> |
| 703 | + <Input |
| 704 | + id="swap-secret-key" |
| 705 | + type="password" |
| 706 | + placeholder="Enter your project secret key" |
| 707 | + value={secretKey} |
| 708 | + onChange={(e) => setSecretKey(e.target.value)} |
| 709 | + autoComplete="off" |
| 710 | + autoCorrect="off" |
| 711 | + spellCheck={false} |
| 712 | + /> |
| 713 | + <p className="text-xs text-muted-foreground">{secretKeyHelper}</p> |
| 714 | + </div> |
| 715 | + |
| 716 | + {!isManagedVault && ( |
| 717 | + <div className="space-y-2"> |
| 718 | + <label |
| 719 | + htmlFor="swap-vault-access-token" |
| 720 | + className="text-sm font-medium leading-none" |
| 721 | + > |
| 722 | + Vault access token |
| 723 | + </label> |
| 724 | + <Input |
| 725 | + id="swap-vault-access-token" |
| 726 | + type="password" |
| 727 | + placeholder="Enter a vault access token" |
| 728 | + value={vaultAccessToken} |
| 729 | + onChange={(e) => setVaultAccessToken(e.target.value)} |
| 730 | + autoComplete="off" |
| 731 | + autoCorrect="off" |
| 732 | + spellCheck={false} |
| 733 | + /> |
| 734 | + <p className="text-xs text-muted-foreground"> |
| 735 | + {vaultAccessTokenHelper} |
| 736 | + </p> |
| 737 | + </div> |
| 738 | + )} |
| 739 | + </div> |
| 740 | + |
| 741 | + <div className="flex justify-end gap-3 border-t bg-card p-4"> |
| 742 | + <Button onClick={onClose} type="button" variant="outline"> |
| 743 | + Cancel |
| 744 | + </Button> |
| 745 | + <Button |
| 746 | + onClick={() => setScreen("swap")} |
| 747 | + type="button" |
| 748 | + disabled={!hasRequiredCredentials} |
| 749 | + > |
| 750 | + Continue |
| 751 | + </Button> |
| 752 | + </div> |
| 753 | + </div> |
| 754 | + ); |
| 755 | + } |
| 756 | + |
| 757 | + // Screen 2: Swap Widget |
| 758 | + return ( |
| 759 | + <div className="w-full"> |
| 760 | + <DialogHeader className="p-4 lg:p-6"> |
| 761 | + <div className="flex items-center gap-2"> |
| 762 | + <Button |
| 763 | + variant="ghost" |
| 764 | + size="sm" |
| 765 | + className="p-1 h-auto w-auto" |
| 766 | + onClick={() => setScreen("credentials")} |
| 767 | + > |
| 768 | + <ArrowLeftIcon className="size-4" /> |
| 769 | + </Button> |
| 770 | + <div> |
| 771 | + <DialogTitle>Swap Tokens</DialogTitle> |
| 772 | + <DialogDescription> |
| 773 | + Swap tokens from your project wallet |
| 774 | + </DialogDescription> |
| 775 | + </div> |
| 776 | + </div> |
| 777 | + </DialogHeader> |
| 778 | + |
| 779 | + <div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center"> |
| 780 | + {swapClient && activeWallet && ( |
| 781 | + <SwapWidget |
| 782 | + client={swapClient} |
| 783 | + prefill={{ |
| 784 | + sellToken: { |
| 785 | + chainId: chainId, |
| 786 | + tokenAddress: tokenAddress, |
| 787 | + }, |
| 788 | + }} |
| 789 | + activeWallet={activeWallet} |
| 790 | + theme={getSDKTheme(t)} |
| 791 | + /> |
| 792 | + )} |
| 793 | + </div> |
| 794 | + </div> |
| 795 | + ); |
| 796 | +} |
| 797 | + |
576 | 798 | const createSendFormSchema = (secretKeyLabel: string) => |
577 | 799 | z.object({ |
578 | 800 | chainId: z.number({ |
|
0 commit comments