Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
781a626461 Version Packages (#8217)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-09 01:27:39 -07:00
Joaquim VergesandGitHub f630912b9b [SDK] Export missing x402 types (#8218) 2025-10-09 01:14:37 -07:00
163ab8afe1 Project Wallet (Default Server Wallet) (#8212)
Co-authored-by: Joaquim Verges <[email protected]>
2025-10-08 20:57:32 +07:00
MananTank 62cfbb7441 SDK: Show error in BuyWidget and SwapWidget UI if fetching token details fails (#8215)
<!--

## 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 introduces error handling in the `BuyWidget` and `SwapWidget` UIs, displaying appropriate error messages when fetching token details fails.

### Detailed summary
- Added `isError` state to handle errors in token fetching.
- Updated `SelectedTokenButton` to check for errors before rendering.
- Enhanced `SwapUI` to show error messages based on different query states.
- Implemented error display in `FundWallet` for unsupported tokens.
- Modified balance display logic to accommodate new error handling.

> ✨ 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
  - Inline error banners in Buy and Swap widgets when token details fail to load.
  - Clear, specific messages for quote, buy-token, and sell-token fetch failures.
- Improvements
  - Selected token UI now falls back gracefully on errors instead of showing partial data.
  - Balance display uses richer token info (value, decimals, symbol, name) for accuracy.
  - More consistent handling of unsupported tokens across the flow.
- Bug Fixes
  - Prevents stale token icons/details from appearing during error states.
  - Disables auto-retry on token price fetches to reduce UI flicker.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-07 21:36:48 +00:00
Joaquim VergesandGitHub d7104f69f4 [Dashboard] Add engineCloud to ActiveStatus and reduce revalidation time to 30 seconds (#8216) 2025-10-07 14:22:32 -07:00
Joaquim VergesandGitHub 23545cb999 [Docs] Add ERC-7702 Smart EOA session keys documentation (#8214) 2025-10-07 13:35:46 -07:00
MananTank f9283f389b Portal: Add missing redirects for bridge pages (#8201)
<!--

## 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 updating the redirect paths in the `redirects.mjs` file to streamline payment-related navigation by adding new routes that point to a `bridge` structure.

### Detailed summary
- Added redirect for `"/payments/sell"` to `"/bridge/sell"`
- Added redirect for `"/payments/swap"` to `"/bridge/swap"`
- Added redirect for `"/payments/tokens"` to `"/bridge/tokens"`
- Added redirect for `"/payments/routes"` to `"/bridge/routes"`

> ✨ 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 redirects so payment URLs now route to corresponding bridge destinations:
    * /payments/sell → /bridge/sell
    * /payments/swap → /bridge/swap
    * /payments/tokens → /bridge/tokens
    * /payments/routes → /bridge/routes
  * Existing bookmarks and shared links to payment pages will continue working and automatically route to the updated locations, with no UI changes required.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-07 11:30:51 +00:00
42 changed files with 2672 additions and 93 deletions
@@ -0,0 +1,70 @@
"use server";
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
interface VaultWalletListItem {
id: string;
address: string;
metadata?: {
label?: string;
projectId?: string;
teamId?: string;
type?: string;
} | null;
}
export async function listProjectServerWallets(params: {
managementAccessToken: string;
projectId: string;
pageSize?: number;
}): Promise<ProjectWalletSummary[]> {
const { managementAccessToken, projectId, pageSize = 100 } = params;
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
return [];
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: 0,
// @ts-expect-error - Vault SDK expects snake_case pagination fields
page_size: pageSize,
},
},
});
if (!response.success || !response.data?.items) {
return [];
}
const items = response.data.items as VaultWalletListItem[];
return items
.filter((item) => {
return (
item.metadata?.projectId === projectId &&
(!item.metadata?.type || item.metadata.type === "server-wallet")
);
})
.map<ProjectWalletSummary>((item) => ({
id: item.id,
address: item.address,
label: item.metadata?.label ?? undefined,
}));
} catch (error) {
console.error("Failed to list project server wallets", error);
return [];
}
}
@@ -0,0 +1,74 @@
"use server";
import { configure, sendTokens } from "@thirdweb-dev/api";
import { THIRDWEB_API_HOST } from "@/constants/urls";
configure({
override: {
baseUrl: THIRDWEB_API_HOST,
},
});
export async function sendProjectWalletTokens(options: {
walletAddress: string;
recipientAddress: string;
chainId: number;
quantityWei: string;
publishableKey: string;
teamId: string;
tokenAddress?: string;
secretKey: string;
vaultAccessToken?: string;
}) {
const {
walletAddress,
recipientAddress,
chainId,
quantityWei,
publishableKey,
teamId,
tokenAddress,
secretKey,
vaultAccessToken,
} = options;
if (!secretKey) {
return {
error: "A project secret key is required to send funds.",
ok: false,
} as const;
}
const response = await sendTokens({
body: {
chainId,
from: walletAddress,
recipients: [
{
address: recipientAddress,
quantity: quantityWei,
},
],
...(tokenAddress ? { tokenAddress } : {}),
},
headers: {
"Content-Type": "application/json",
"x-client-id": publishableKey,
"x-secret-key": secretKey,
"x-team-id": teamId,
...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}),
},
});
if (response.error || !response.data) {
return {
error: response.error || "Failed to submit transfer request.",
ok: false,
} as const;
}
return {
ok: true,
transactionIds: response.data.result?.transactionIds ?? [],
} as const;
}
+3 -1
View File
@@ -448,6 +448,7 @@ type ActiveStatus = {
pay: boolean;
inAppWallet: boolean;
ecosystemWallet: boolean;
engineCloud: boolean;
};
export const isProjectActive = unstable_cache(
@@ -479,6 +480,7 @@ export const isProjectActive = unstable_cache(
pay: false,
rpc: false,
sdk: false,
engineCloud: false,
storage: false,
} as ActiveStatus;
}
@@ -488,7 +490,7 @@ export const isProjectActive = unstable_cache(
},
["isProjectActive"],
{
revalidate: 60 * 60, // 1 hour
revalidate: 30, // 30 seconds
},
);
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "@/hooks/useLocalStorage";
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
function AnnouncementBannerUI(props: {
href: string;
label: string;
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
const vaultTokens = await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
);
throw error;
});
const managementAccessToken = vaultTokens.managementToken?.accessToken;
if (!managementAccessToken) {
throw new Error("Missing management access token for project wallet");
}
return {
project: res.project,
secret: res.secret,
@@ -0,0 +1,21 @@
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
/**
* Builds the default label for a project's primary server wallet.
* Ensures a stable naming convention so the wallet can be identified across flows.
*/
export function getProjectWalletLabel(projectName: string | undefined) {
const baseName = projectName?.trim() || "Project";
const maxBaseLength = Math.max(
1,
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
);
const normalizedBase =
baseName.length > maxBaseLength
? baseName.slice(0, maxBaseLength).trimEnd()
: baseName;
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
}
@@ -0,0 +1,99 @@
import "server-only";
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getProjectWalletLabel } from "@/lib/project-wallet";
export type ProjectWalletSummary = {
id: string;
address: string;
label?: string;
};
type VaultWalletListItem = {
id: string;
address: string;
metadata?: {
label?: string;
projectId?: string;
teamId?: string;
type?: string;
};
};
export async function getProjectWallet(
project: Project,
): Promise<ProjectWalletSummary | undefined> {
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
engineCloudService?.managementAccessToken || undefined;
const projectWalletAddress = (
engineCloudService as { projectWalletAddress?: string } | undefined
)?.projectWalletAddress;
if (
!managementAccessToken ||
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
!projectWalletAddress
) {
return undefined;
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: 0,
// @ts-expect-error - SDK expects snake_case for pagination arguments
page_size: 100,
},
},
});
if (!response.success || !response.data) {
return undefined;
}
const items = response.data.items as VaultWalletListItem[] | undefined;
if (!items?.length) {
return undefined;
}
const defaultLabel = getProjectWalletLabel(project.name);
const serverWallets = items.filter(
(item) => item.metadata?.projectId === project.id,
);
const defaultWallet = serverWallets.find(
(item) =>
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
);
if (!defaultWallet) {
return undefined;
}
return {
id: defaultWallet.id,
address: defaultWallet.address,
label: defaultWallet.metadata?.label ?? defaultLabel,
};
} catch (error) {
console.error("Failed to load project wallet", error);
return undefined;
}
}
@@ -55,7 +55,7 @@ export function CreateProjectFormOnboarding(props: {
<CreateProjectForm
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
const vaultTokens = await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
@@ -65,6 +65,16 @@ export function CreateProjectFormOnboarding(props: {
);
throw error;
});
const managementAccessToken =
vaultTokens.managementToken?.accessToken;
if (!managementAccessToken) {
throw new Error(
"Missing management access token for project wallet",
);
}
return {
project: res.project,
secret: res.secret,
@@ -31,5 +31,6 @@ export const Default: Story = {
],
},
teamSlug: "bar",
managementAccessToken: undefined,
},
};
@@ -7,6 +7,7 @@ import {
import Link from "next/link";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertDialogFooter } from "@/components/ui/alert-dialog";
import { CodeServer } from "@/components/ui/code/code.server";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
@@ -18,13 +19,34 @@ import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
import { ContractIcon } from "@/icons/ContractIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { PayIcon } from "@/icons/PayIcon";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { getProjectWalletLabel } from "@/lib/project-wallet";
import {
getProjectWallet,
type ProjectWalletSummary,
} from "@/lib/server/project-wallet";
import CreateServerWallet from "../../transactions/server-wallets/components/create-server-wallet.client";
import { ClientIDSection } from "./ClientIDSection";
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
import { ProjectWalletControls } from "./ProjectWalletControls.client";
import { SecretKeySection } from "./SecretKeySection";
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
export async function ProjectFTUX(props: {
project: Project;
teamSlug: string;
wallet?: ProjectWalletSummary | undefined;
managementAccessToken: string | undefined;
}) {
const projectWallet = props.wallet ?? (await getProjectWallet(props.project));
return (
<div className="flex flex-col gap-10">
<ProjectWalletSection
project={props.project}
teamSlug={props.teamSlug}
wallet={projectWallet}
managementAccessToken={props.managementAccessToken}
/>
<IntegrateAPIKeySection
project={props.project}
teamSlug={props.teamSlug}
@@ -39,6 +61,76 @@ export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
);
}
export function ProjectWalletSection(props: {
project: Project;
teamSlug: string;
wallet: ProjectWalletSummary | undefined;
managementAccessToken: string | undefined;
}) {
const defaultLabel = getProjectWalletLabel(props.project.name);
const walletAddress = props.wallet?.address;
const label = props.wallet?.label ?? defaultLabel;
return (
<section>
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="rounded-full border border-border bg-background p-2">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
<div>
<p className="font-semibold text-lg tracking-tight">
Project Wallet
</p>
<p className="text-muted-foreground text-sm">
Default managed server wallet for this project. Use it for
deployments, payments, and API integrations.
</p>
</div>
</div>
{walletAddress ? (
<>
<ProjectWalletControls
label={label}
project={props.project}
walletAddress={walletAddress}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Link
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
href={`/team/${props.teamSlug}/${props.project.slug}/transactions`}
>
View Transactions
<ChevronRightIcon className="size-4" />
</Link>
</div>
</>
) : (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>No default project wallet set</AlertTitle>
<AlertDescription>
Set a default project wallet to use for dashboard and API
integrations.
</AlertDescription>
<AlertDialogFooter className="flex justify-start sm:justify-start pt-4">
<CreateServerWallet
managementAccessToken={props.managementAccessToken}
project={props.project}
teamSlug={props.teamSlug}
setAsProjectWallet={true}
/>
</AlertDialogFooter>
</Alert>
)}
</div>
</div>
</section>
);
}
// Integrate API key section ------------------------------------------------------------
function IntegrateAPIKeySection({
@@ -0,0 +1,725 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
EllipsisVerticalIcon,
RefreshCcwIcon,
SendIcon,
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { toWei } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import { isAddress } from "thirdweb/utils";
import { z } from "zod";
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/Spinner";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
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 { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
type ProjectWalletControlsProps = {
walletAddress: string;
label: string;
project: Pick<
Project,
"id" | "publishableKey" | "teamId" | "services" | "name"
>;
defaultChainId?: number;
};
export function ProjectWalletControls(props: ProjectWalletControlsProps) {
const { walletAddress, label, project, defaultChainId } = props;
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
undefined,
);
const client = useMemo(() => getClientThirdwebClient(), []);
const chain = useV5DashboardChain(selectedChainId);
const queryClient = useQueryClient();
const router = useDashboardRouter();
const engineCloudService = useMemo(
() => project.services?.find((service) => service.name === "engineCloud"),
[project.services],
);
const managementAccessToken =
engineCloudService?.managementAccessToken ?? undefined;
const isManagedVault = !!engineCloudService?.encryptedAdminKey;
const serverWalletsQuery = useQuery({
enabled: !!managementAccessToken,
queryFn: async () => {
if (!managementAccessToken) {
return [] as ProjectWalletSummary[];
}
return listProjectServerWallets({
managementAccessToken,
projectId: project.id,
});
},
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
staleTime: 60_000,
});
const serverWallets = serverWalletsQuery.data ?? [];
const currentWalletAddressLower = walletAddress.toLowerCase();
const otherWallets = useMemo(() => {
return serverWallets.filter((wallet) => {
return wallet.address.toLowerCase() !== currentWalletAddressLower;
});
}, [serverWallets, currentWalletAddressLower]);
const canChangeWallet = otherWallets.length > 0;
const handleOpenChangeWallet = () => {
const currentWallet = serverWallets.find(
(wallet) => wallet.address.toLowerCase() === currentWalletAddressLower,
);
const nextWalletId =
otherWallets[0]?.id ?? currentWallet?.id ?? serverWallets[0]?.id;
setSelectedWalletId(nextWalletId);
setIsChangeWalletOpen(true);
};
const handleCloseChangeWallet = () => {
setIsChangeWalletOpen(false);
setSelectedWalletId(undefined);
};
const selectedWallet = useMemo(() => {
if (!selectedWalletId) {
return undefined;
}
return serverWallets.find((wallet) => wallet.id === selectedWalletId);
}, [selectedWalletId, serverWallets]);
const isSelectionDifferent = Boolean(
selectedWallet &&
selectedWallet.address.toLowerCase() !== currentWalletAddressLower,
);
const changeWalletMutation = useMutation({
mutationFn: async (wallet: ProjectWalletSummary) => {
await updateDefaultProjectWallet({
project: project as Project,
projectWalletAddress: wallet.address,
});
},
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to update project wallet";
toast.error(message);
},
onSuccess: async (_, wallet) => {
const descriptionLabel = wallet.label ?? wallet.address;
toast.success("Default project wallet updated", {
description: `Now pointing to ${descriptionLabel}`,
});
await queryClient.invalidateQueries({
queryKey: [
"project",
project.id,
"server-wallets",
managementAccessToken,
],
});
handleCloseChangeWallet();
router.refresh();
},
});
const balanceQuery = useWalletBalance({
address: walletAddress,
chain,
client,
});
const balanceDisplay = balanceQuery.data
? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}`
: undefined;
return (
<div className="space-y-5">
<div className="rounded-lg border border-border bg-background p-3">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 lg:flex-row">
<div className="flex-1">
<p className="text-muted-foreground text-xs">Address</p>
<WalletAddress address={walletAddress} client={client} />
</div>
<div className="flex-1">
<p className="text-muted-foreground text-xs">Label</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="font-mono text-sm break-all py-2 sm:py-0">
{label}
</p>
{canChangeWallet && (
<Button
className="h-8 gap-1 sm:ml-4"
onClick={handleOpenChangeWallet}
size="sm"
disabled={serverWalletsQuery.isLoading}
variant="outline"
>
<ShuffleIcon className="size-3.5" />
Change default
</Button>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex-1 flex-col">
<p className="text-xs text-muted-foreground pb-2">Balance</p>
<div className="flex items-center gap-2">
<WalletIcon className="size-4 text-muted-foreground" />
{balanceQuery.isLoading ? (
<Spinner className="size-4" />
) : balanceDisplay ? (
<span className="text-md tracking-tight">
{balanceDisplay}
</span>
) : (
<span className="text-muted-foreground text-sm">N/A</span>
)}
<Button
aria-label="Refresh balance"
className="h-8 w-8 p-0"
disabled={balanceQuery.isFetching}
onClick={() => balanceQuery.refetch()}
size="icon"
variant="outline"
>
<RefreshCcwIcon
className={cn(
"size-4",
balanceQuery.isFetching && "animate-spin",
)}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Open wallet actions"
className="h-9 w-9"
size="icon"
variant="outline"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 rounded-xl">
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsSendOpen(true)}
>
<SendIcon className="size-4 text-muted-foreground" />
Send funds
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={() => setIsReceiveOpen(true)}
>
<WalletIcon className="size-4 text-muted-foreground" />
Receive funds
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex-col flex-1">
<p className="text-xs text-muted-foreground pb-2">Network</p>
<SingleNetworkSelector
chainId={selectedChainId}
className="max-w-xs rounded-lg"
client={client}
disableDeprecated
disableChainId
onChange={setSelectedChainId}
placeholder="Select network"
/>
</div>
</div>
</div>
</div>
<SendProjectWalletModal
chainId={selectedChainId}
client={client}
isManagedVault={isManagedVault}
label={label}
onClose={() => setIsSendOpen(false)}
onSuccess={() => balanceQuery.refetch()}
open={isSendOpen}
publishableKey={project.publishableKey}
teamId={project.teamId}
walletAddress={walletAddress}
/>
<FundWalletModal
checkoutWidgetTitle={`Fund ${label}`}
client={client}
defaultChainId={selectedChainId}
description="Use your card or crypto to deposit into this server wallet"
onOpenChange={setIsReceiveOpen}
open={isReceiveOpen}
recipientAddress={walletAddress}
title="Fund project wallet"
/>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleCloseChangeWallet();
}
}}
open={isChangeWalletOpen}
>
<DialogContent className="gap-0 p-0">
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Change default wallet</DialogTitle>
<DialogDescription>
Choose another server wallet to use as the default for this
project.
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-8 lg:px-6">
{serverWalletsQuery.isLoading ? (
<div className="flex justify-center py-6">
<Spinner className="size-5" />
</div>
) : serverWallets.length <= 1 ? (
<p className="text-sm text-muted-foreground">
You need at least two server wallets to pick a different
default.
</p>
) : (
<RadioGroup
className="space-y-3"
onValueChange={(value) => setSelectedWalletId(value)}
value={selectedWalletId}
>
{serverWallets.map((wallet) => {
const isCurrent =
wallet.address.toLowerCase() === currentWalletAddressLower;
const isSelected = wallet.id === selectedWalletId;
return (
<Label
key={wallet.id}
htmlFor={`project-wallet-${wallet.id}`}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition",
isSelected && "border-primary ring-2 ring-primary/20",
)}
>
<RadioGroupItem
id={`project-wallet-${wallet.id}`}
value={wallet.id}
className="mt-0.5"
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{wallet.label ?? "Unnamed server wallet"}
</span>
{isCurrent && (
<Badge variant="success" className="text-xs">
current
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground break-all">
{wallet.address}
</span>
</div>
</Label>
);
})}
</RadioGroup>
)}
</div>
<DialogFooter className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<Button
onClick={handleCloseChangeWallet}
type="button"
variant="outline"
disabled={changeWalletMutation.isPending}
>
Cancel
</Button>
<Button
disabled={
!selectedWallet ||
!isSelectionDifferent ||
changeWalletMutation.isPending
}
onClick={() => {
if (selectedWallet) {
changeWalletMutation.mutate(selectedWallet);
}
}}
type="button"
>
{changeWalletMutation.isPending && (
<Spinner className="mr-2 size-4" />
)}
Save default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
const createSendFormSchema = (secretKeyLabel: string) =>
z.object({
chainId: z.number({
required_error: "Select a network",
}),
toAddress: z
.string()
.trim()
.min(1, "Destination address is required")
.refine((value) => Boolean(isAddress(value)), {
message: "Enter a valid wallet address",
}),
amount: z.string().trim().min(1, "Amount is required"),
secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`),
vaultAccessToken: z.string().trim(),
});
const SECRET_KEY_LABEL = "Project secret key";
type SendFormValues = z.infer<ReturnType<typeof createSendFormSchema>>;
function SendProjectWalletModal(props: {
open: boolean;
onClose: () => void;
onSuccess: () => void;
walletAddress: string;
publishableKey: string;
teamId: string;
chainId: number;
label: string;
client: ReturnType<typeof getClientThirdwebClient>;
isManagedVault: boolean;
}) {
const {
open,
onClose,
onSuccess,
walletAddress,
publishableKey,
teamId,
chainId,
label,
client,
isManagedVault,
} = props;
const secretKeyLabel = SECRET_KEY_LABEL;
const secretKeyPlaceholder = "Enter your project secret key";
const secretKeyHelper =
"Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings.";
const vaultAccessTokenHelper =
"Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings.";
const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []);
const form = useForm<SendFormValues>({
defaultValues: {
amount: "0",
chainId,
secretKey: "",
vaultAccessToken: "",
toAddress: "",
},
mode: "onChange",
resolver: zodResolver(formSchema),
});
const selectedChain = useV5DashboardChain(form.watch("chainId"));
// eslint-disable-next-line no-restricted-syntax -- form submission chainId must track selector state
useEffect(() => {
form.setValue("chainId", chainId);
}, [chainId, form]);
// eslint-disable-next-line no-restricted-syntax -- reset cached inputs when modal closes to avoid leaking state
useEffect(() => {
if (!open) {
const currentValues = form.getValues();
form.reset({
amount: "0",
chainId,
secretKey: currentValues.secretKey ?? "",
vaultAccessToken: currentValues.vaultAccessToken ?? "",
toAddress: "",
});
}
}, [open, chainId, form]);
const sendMutation = useMutation({
mutationFn: async (values: SendFormValues) => {
const quantityWei = toWei(values.amount).toString();
const secretKeyValue = values.secretKey.trim();
const vaultAccessTokenValue = values.vaultAccessToken.trim();
const result = await sendProjectWalletTokens({
chainId: values.chainId,
publishableKey,
quantityWei,
recipientAddress: values.toAddress,
teamId,
walletAddress,
secretKey: secretKeyValue,
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
});
if (!result.ok) {
const errorMessage =
typeof result.error === "string"
? result.error
: "Failed to send funds";
throw new Error(errorMessage);
}
return result.transactionIds;
},
onError: (error) => {
toast.error(error.message);
},
onSuccess: (transactionIds) => {
toast.success("Transfer submitted", {
description:
transactionIds && transactionIds.length > 0
? `Transaction ID: ${transactionIds[0]}`
: undefined,
});
onSuccess();
onClose();
},
});
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
open={open}
>
<DialogContent className="gap-0 p-0">
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Send from {label}</DialogTitle>
<DialogDescription>
Execute a one-off transfer using your server wallet.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
void sendMutation.mutateAsync(values);
})}
>
<div className="space-y-4 px-4 pb-8 lg:px-6">
<FormField
control={form.control}
name="chainId"
render={({ field }) => (
<FormItem>
<FormLabel>Network</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
className="bg-card"
client={client}
disableDeprecated
onChange={(nextChainId) => {
field.onChange(nextChainId);
}}
placeholder="Select network"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="toAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Send to</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="0x..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<Input
inputMode="decimal"
min="0"
step="any"
{...field}
/>
</FormControl>
<FormDescription>
Sending native token
{selectedChain?.nativeCurrency?.symbol
? ` (${selectedChain.nativeCurrency.symbol})`
: ""}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>{secretKeyLabel}</FormLabel>
<FormControl>
<Input
autoComplete="off"
autoCorrect="off"
placeholder={secretKeyPlaceholder}
spellCheck={false}
type="password"
{...field}
/>
</FormControl>
<FormDescription>{secretKeyHelper}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isManagedVault && (
<FormField
control={form.control}
name="vaultAccessToken"
render={({ field }) => (
<FormItem>
<FormLabel>
Vault access token
<span className="text-muted-foreground">
{" "}
(optional)
</span>
</FormLabel>
<FormControl>
<Input
autoComplete="off"
autoCorrect="off"
placeholder="Enter a vault access token (optional)"
spellCheck={false}
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{vaultAccessTokenHelper}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button
className="gap-2"
disabled={!form.formState.isValid || sendMutation.isPending}
type="submit"
>
{sendMutation.isPending && <Spinner className="size-4" />}
Submit transfer
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -29,6 +29,7 @@ import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-fi
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
@@ -38,7 +39,10 @@ import type {
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import {
ProjectFTUX,
ProjectWalletSection,
} from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
@@ -64,6 +68,9 @@ type PageProps = {
searchParams: Promise<PageSearchParams>;
};
// Revalidate this page data every 30 seconds
export const revalidate = 30;
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
props.params,
@@ -104,6 +111,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
teamId: project.teamId,
});
const projectWallet = await getProjectWallet(project);
const managementAccessToken =
project.services?.find((service) => service.name === "engineCloud")
?.managementAccessToken ?? undefined;
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
@@ -125,6 +137,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
>
{isActive ? (
<div className="flex flex-col gap-4 md:gap-6">
<ProjectWalletSection
project={project}
teamSlug={params.team_slug}
wallet={projectWallet}
managementAccessToken={managementAccessToken}
/>
<ResponsiveTimeFilters defaultRange={defaultRange} />
<ProjectAnalytics
authToken={authToken}
@@ -137,7 +155,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
/>
</div>
) : (
<ProjectFTUX project={project} teamSlug={params.team_slug} />
<ProjectFTUX
project={project}
teamSlug={params.team_slug}
wallet={projectWallet}
managementAccessToken={managementAccessToken}
/>
)}
</ProjectPage>
</ResponsiveSearchParamsProvider>
@@ -15,6 +15,7 @@ export function QuickStartSection(props: {
projectSlug: string;
clientId: string;
teamId: string;
projectWalletAddress?: string;
}) {
return (
<section>
@@ -43,6 +44,7 @@ export function QuickStartSection(props: {
action={
<CreatePaymentLinkButton
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
teamId={props.teamId}
>
<Button
@@ -45,7 +45,11 @@ const formSchema = z.object({
});
export function CreatePaymentLinkButton(
props: PropsWithChildren<{ clientId: string; teamId: string }>,
props: PropsWithChildren<{
clientId: string;
teamId: string;
projectWalletAddress?: string;
}>,
) {
const [open, setOpen] = useState(false);
@@ -55,6 +59,7 @@ export function CreatePaymentLinkButton(
<DialogContent className="p-0 !max-w-lg">
<CreatePaymentLinkDialogContent
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
setOpen={setOpen}
teamId={props.teamId}
/>
@@ -67,6 +72,7 @@ function CreatePaymentLinkDialogContent(props: {
clientId: string;
teamId: string;
setOpen: (open: boolean) => void;
projectWalletAddress?: string;
}) {
const client = getClientThirdwebClient();
@@ -182,14 +188,41 @@ function CreatePaymentLinkDialogContent(props: {
render={({ field }) => (
<FormItem>
<FormLabel>Recipient Address</FormLabel>
<Input
className="w-full bg-card"
{...field}
onChange={field.onChange}
value={field.value}
placeholder="Address or ENS"
required
/>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
className="w-full bg-card sm:flex-1"
{...field}
onChange={field.onChange}
value={field.value}
placeholder="Address or ENS"
required
/>
{props.projectWalletAddress && (
<Button
className="sm:w-auto"
onClick={() => {
if (!props.projectWalletAddress) {
return;
}
form.setValue(
"recipient",
props.projectWalletAddress,
{
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
},
);
}}
size="sm"
type="button"
variant="outline"
>
Use Project Wallet
</Button>
)}
</div>
<FormMessage />
</FormItem>
)}
@@ -40,7 +40,11 @@ import { ErrorState } from "../../components/ErrorState";
import { formatTokenAmount } from "../../components/format";
import { CreatePaymentLinkButton } from "./CreatePaymentLinkButton.client";
export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
export function PaymentLinksTable(props: {
clientId: string;
teamId: string;
projectWalletAddress?: string;
}) {
return (
<section>
<div className="mb-4">
@@ -49,12 +53,20 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
The payments you have created in this project
</p>
</div>
<PaymentLinksTableInner clientId={props.clientId} teamId={props.teamId} />
<PaymentLinksTableInner
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
teamId={props.teamId}
/>
</section>
);
}
function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
function PaymentLinksTableInner(props: {
clientId: string;
teamId: string;
projectWalletAddress?: string;
}) {
const paymentLinksQuery = useQuery({
queryFn: async () => {
return getPaymentLinks({
@@ -120,6 +132,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
<CreatePaymentLinkButton
key="create-payment-link"
clientId={props.clientId}
projectWalletAddress={props.projectWalletAddress}
teamId={props.teamId}
>
<Button className="gap-2 rounded-full" variant="default" size="sm">
@@ -6,6 +6,7 @@ import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { PayIcon } from "@/icons/PayIcon";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { loginRedirect } from "@/utils/redirects";
import { AdvancedSection } from "./components/AdvancedSection.client";
import { QuickStartSection } from "./components/QuickstartSection.client";
@@ -29,6 +30,8 @@ export default async function Page(props: {
redirect(`/team/${params.team_slug}`);
}
const projectWallet = await getProjectWallet(project);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
@@ -51,6 +54,7 @@ export default async function Page(props: {
component: (
<CreatePaymentLinkButton
clientId={project.publishableKey}
projectWalletAddress={projectWallet?.address}
teamId={project.teamId}
>
<Button className="gap-1.5 rounded-full" size="sm">
@@ -141,12 +145,14 @@ export default async function Page(props: {
<div className="flex flex-col gap-12">
<PaymentLinksTable
clientId={project.publishableKey}
projectWalletAddress={projectWallet?.address}
teamId={project.teamId}
/>
<QuickStartSection
projectSlug={params.project_slug}
teamSlug={params.team_slug}
clientId={project.publishableKey}
projectWalletAddress={projectWallet?.address}
teamId={project.teamId}
/>
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { type Fee, updateFee } from "@/api/universal-bridge/developer";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
@@ -25,6 +26,7 @@ interface PayConfigProps {
teamId: string;
teamSlug: string;
fees: Fee;
projectWalletAddress?: string;
}
export const PayConfig: React.FC<PayConfigProps> = (props) => {
@@ -103,7 +105,37 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
<FormItem>
<FormLabel>Recipient address</FormLabel>
<FormControl>
<Input {...field} placeholder="0x..." />
<div className="flex flex-col gap-2 sm:flex-row">
<Input
{...field}
className="sm:flex-1"
placeholder="0x..."
/>
{props.projectWalletAddress && (
<Button
onClick={() => {
if (!props.projectWalletAddress) {
return;
}
form.setValue(
"payoutAddress",
props.projectWalletAddress,
{
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
},
);
}}
size="sm"
type="button"
variant="outline"
>
Use Project Wallet
</Button>
)}
</div>
</FormControl>
</FormItem>
)}
@@ -3,6 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { getFees } from "@/api/universal-bridge/developer";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { loginRedirect } from "@/utils/redirects";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
import { PayConfig } from "./PayConfig";
@@ -31,6 +32,8 @@ export default async function Page(props: {
redirect(`/team/${team_slug}`);
}
const projectWallet = await getProjectWallet(project);
let fees = await getFees({
clientId: project.publishableKey,
teamId: team.id,
@@ -64,6 +67,7 @@ export default async function Page(props: {
fees={fees}
project={project}
teamId={team.id}
projectWalletAddress={projectWallet?.address}
teamSlug={team_slug}
/>
</div>
@@ -3,14 +3,17 @@
import { encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createEoa,
createServiceAccount,
createVaultClient,
rotateServiceAccount,
type VaultClient,
} from "@thirdweb-dev/vault-sdk";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { updateProjectClient } from "@/hooks/useApi";
import { getProjectWalletLabel } from "@/lib/project-wallet";
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
"Access Token for All Server Wallets";
@@ -126,6 +129,92 @@ export async function createVaultAccountAndAccessToken(props: {
}
}
export async function createProjectServerWallet(props: {
project: Project;
managementAccessToken: string;
label?: string;
setAsProjectWallet?: boolean;
}) {
const vaultClient = await initVaultClient();
const walletLabel = props.label?.trim()
? props.label.trim()
: getProjectWalletLabel(props.project.name);
const eoa = await createEoa({
client: vaultClient,
request: {
auth: {
accessToken: props.managementAccessToken,
},
options: {
metadata: {
label: walletLabel,
projectId: props.project.id,
teamId: props.project.teamId,
type: "server-wallet",
},
},
},
});
if (!eoa.success) {
throw new Error(eoa.error?.message || "Failed to create server wallet");
}
engineCloudProxy({
body: JSON.stringify({
signerAddress: eoa.data.address,
}),
headers: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
},
method: "POST",
pathname: "/cache/smart-account",
}).catch((err) => {
console.warn("failed to cache server wallet", err);
});
if (props.setAsProjectWallet) {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: eoa.data.address,
});
}
return eoa.data;
}
export async function updateDefaultProjectWallet(props: {
project: Project;
projectWalletAddress: string;
}) {
const services = props.project.services.filter(
(service) => service.name !== "engineCloud",
);
const engineCloudService = props.project.services.find(
(service) => service.name === "engineCloud",
);
if (engineCloudService) {
const engineCloudServiceWithProjectWallet = {
...engineCloudService,
projectWalletAddress: props.projectWalletAddress,
};
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [...services, engineCloudServiceWithProjectWallet],
},
);
}
}
async function createAndEncryptVaultAccessTokens(props: {
project: Project;
vaultClient: VaultClient;
@@ -157,6 +246,13 @@ async function createAndEncryptVaultAccessTokens(props: {
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
// create a default project server wallet
const defaultProjectServerWallet = await createProjectServerWallet({
project,
managementAccessToken: managementToken.accessToken,
label: getProjectWalletLabel(project.name),
});
if (projectSecretKey) {
// verify that the project secret key is valid
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
@@ -182,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: {
},
{
services: [
...props.project.services,
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
@@ -191,6 +289,7 @@ async function createAndEncryptVaultAccessTokens(props: {
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
@@ -204,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: {
},
{
services: [
...props.project.services,
...props.project.services.filter(
(service) => service.name !== "engineCloud",
),
{
name: "engineCloud",
actions: [],
@@ -213,6 +314,7 @@ async function createAndEncryptVaultAccessTokens(props: {
encryptedAdminKey: null,
encryptedWalletAccessToken: null,
rotationCode: rotationCode,
projectWalletAddress: defaultProjectServerWallet.address,
},
],
},
@@ -1,10 +1,8 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { createEoa } from "@thirdweb-dev/vault-sdk";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import {
@@ -17,12 +15,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { initVaultClient } from "../../lib/vault.client";
import { createProjectServerWallet } from "../../lib/vault.client";
export default function CreateServerWallet(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
setAsProjectWallet?: boolean;
}) {
const router = useDashboardRouter();
const [label, setLabel] = useState("");
@@ -36,49 +35,17 @@ export default function CreateServerWallet(props: {
managementAccessToken: string;
label: string;
}) => {
const vaultClient = await initVaultClient();
const eoa = await createEoa({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
metadata: {
label,
projectId: props.project.id,
teamId: props.project.teamId,
type: "server-wallet",
},
},
},
});
if (!eoa.success) {
throw new Error("Failed to create eoa");
}
// no need to await this, it's not blocking
engineCloudProxy({
body: JSON.stringify({
signerAddress: eoa.data.address,
}),
headers: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
},
method: "POST",
pathname: "/cache/smart-account",
}).catch((err) => {
console.warn("failed to cache server wallet", err);
const wallet = await createProjectServerWallet({
label,
managementAccessToken,
project: props.project,
setAsProjectWallet: props.setAsProjectWallet,
});
router.refresh();
setModalOpen(false);
return eoa;
setLabel("");
return wallet;
},
onError: (error) => {
toast.error(error.message);
@@ -1,8 +1,9 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
CheckIcon,
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
@@ -10,6 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
@@ -20,6 +22,7 @@ import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -50,7 +53,9 @@ import {
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { updateDefaultProjectWallet } from "../../lib/vault.client";
import CreateServerWallet from "../components/create-server-wallet.client";
import type { Wallet } from "./types";
@@ -287,18 +292,34 @@ function ServerWalletTableRow(props: {
queryKey: ["smart-account-address", wallet.address, chainId],
});
// Get the default project wallet address
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const defaultWalletAddress = engineCloudService?.projectWalletAddress;
const isDefaultWallet =
defaultWalletAddress &&
wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase();
return (
<TableRow key={wallet.id}>
{/* Label */}
<TableCell>
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefaultWallet && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
>
{wallet.metadata.label || "N/A"}
</span>
</div>
</TableCell>
{/* Address */}
@@ -391,6 +412,25 @@ function WalletActionsDropdown(props: {
chainId: number;
}) {
const [showFundModal, setShowFundModal] = useState(false);
const router = useDashboardRouter();
const setAsDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: props.wallet.address,
});
},
onSuccess: () => {
toast.success("Wallet set as default project wallet");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to set default wallet",
);
},
});
return (
<>
@@ -420,6 +460,14 @@ function WalletActionsDropdown(props: {
Fund wallet
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setAsDefaultMutation.mutate()}
disabled={setAsDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+4
View File
@@ -738,6 +738,10 @@ const paymentRedirects = {
"/connect/pay/buy-with-fiat": "/payments",
"/connect/pay/enable-test-mode": "/payments",
"/connect/pay/guides/enable-test-mode": "/payments",
"/payments/sell": "/bridge/sell",
"/payments/swap": "/bridge/swap",
"/payments/tokens": "/bridge/tokens",
"/payments/routes": "/bridge/routes",
};
const contractRedirects = {
@@ -1,6 +1,16 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@doc";
# Session Keys Guide
Session keys enable secure transaction execution on behalf of smart accounts without requiring direct access to the main account's private key. This guide will walk you through creating and using session keys with the thirdweb TypeScript SDK.
Session keys enable secure transaction execution on behalf of accounts without requiring direct access to the main account's private key. This guide covers session keys for both ERC-4337 smart accounts and ERC-7702 smart EOAs (Externally Owned Accounts).
<Tabs defaultValue="erc4337">
<TabsList>
<TabsTrigger value="erc4337">ERC-4337 Smart Accounts</TabsTrigger>
<TabsTrigger value="erc7702">ERC-7702 Smart EOAs</TabsTrigger>
</TabsList>
<TabsContent value="erc4337">
## Prerequisites
@@ -323,8 +333,409 @@ try {
}
```
</TabsContent>
<TabsContent value="erc7702">
## Prerequisites
Before you begin, ensure you have:
- A thirdweb client configured
- An in-app wallet with ERC-7702 support
- A session key account address (can be generated or an existing wallet)
## Setup
First, let's set up the necessary imports and configuration:
```typescript
import {
createThirdwebClient,
getContract,
sendTransaction,
prepareTransaction
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
// Configure your client
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key" // Only use in server environments
});
// Your session key account address
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address
```
## Step 1: Create ERC-7702 Smart EOA
Create an in-app wallet configured with ERC-7702 execution mode:
```typescript
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true, // Enable gas sponsorship
},
});
// Connect the wallet (user authentication)
const account = await wallet.connect({
chain: sepolia,
client: client,
strategy: "google", // or "email", "guest", etc.
});
console.log("Smart EOA address:", account.address);
```
### Execution Modes
The `executionMode` option allows you to configure the wallet behavior:
- `mode`: Set to `"EIP7702"` for ERC-7702 smart EOA functionality
- `sponsorGas`: Enable gas sponsorship for gasless transactions
## Step 2: Deploy/Upgrade the Account
Send a transaction to trigger the account deployment or upgrade to ERC-7702:
```typescript
await sendTransaction({
account: account,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: account.address,
value: 0n, // Self-transfer to trigger deployment
}),
});
console.log("Smart EOA deployed/upgraded successfully");
```
## Step 3: Get Account Contract
Create a contract instance for the deployed smart EOA:
```typescript
const accountContract = getContract({
address: account.address,
chain: sepolia,
client: client,
// ABI will be automatically resolved since the contract is deployed
});
```
## Step 4: Create Session Key with Full Permissions
Add a session key with full execution permissions:
```typescript
const tx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: true, // Allow unrestricted access
}),
});
console.log("Session key created:", tx.transactionHash);
```
### Session Key Options
- `account`: The admin account that will create the session key
- `contract`: The smart EOA contract instance
- `sessionKeyAddress`: The address that will be granted session key permissions
- `durationInSeconds`: How long the session key should be valid (in seconds)
- `grantFullPermissions`: If `true`, grants unrestricted access. If `false`, you must specify policies.
## Step 5 (Advanced): Create Session Key with Granular Permissions
For more fine-grained control, you can specify call and transfer policies:
```typescript
import { Condition, LimitType } from "thirdweb/wallets/in-app";
const tx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: false, // Use granular permissions
callPolicies: [
{
target: "0x123...", // Target contract address
selector: "0xa9059cbb", // Function selector (e.g., ERC20 transfer)
maxValuePerUse: 0n, // Max ETH value per call
valueLimit: {
limitType: LimitType.Lifetime, // Lifetime limit
limit: 1000000n, // Max total value in wei
period: 0n, // Not used for lifetime limits
},
constraints: [
{
condition: Condition.Equal, // Constraint type
index: 0n, // Parameter index
refValue: "0x000000000000000000000000abc...", // Expected value
},
],
},
],
transferPolicies: [
{
target: "0x456...", // Recipient address for transfers
maxValuePerUse: 100000n, // Max wei per transfer
valueLimit: {
limitType: LimitType.Allowance, // Allowance-based limit
limit: 1000000n, // Total allowance in wei
period: 86400n, // Reset period in seconds
},
},
],
}),
});
console.log("Session key with policies created:", tx.transactionHash);
```
### Permission Types
#### Call Policies
Control which smart contract calls the session key can make:
- `target`: The contract address the session key can call
- `selector`: The function selector (4-byte identifier) that can be called
- `maxValuePerUse`: Maximum ETH value that can be sent per transaction
- `valueLimit`: Total value limits over time
- `constraints`: Parameter-level constraints for function calls
#### Transfer Policies
Control native token transfers the session key can make:
- `target`: The recipient address for transfers
- `maxValuePerUse`: Maximum wei per individual transfer
- `valueLimit`: Total transfer limits over time
#### Limit Types
```typescript
enum LimitType {
Unlimited = 0, // No limit
Lifetime = 1, // One-time total limit
Allowance = 2, // Renewable allowance based on period
}
```
#### Constraint Conditions
```typescript
enum Condition {
Unconstrained = 0, // No constraint
Equal = 1, // Must equal reference value
Greater = 2, // Must be greater than reference value
Less = 3, // Must be less than reference value
GreaterOrEqual = 4, // Must be >= reference value
LessOrEqual = 5, // Must be <= reference value
NotEqual = 6, // Must not equal reference value
}
```
## Step 6: Use the Session Key
Once created, the session key can be used with Engine or other server-side execution:
```typescript
import { Engine } from "thirdweb/engine";
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client: client,
vaultAccessToken: process.env.VAULT_TOKEN as string,
});
// Execute transactions with the session key
const tx = await sendTransaction({
account: serverWallet,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: "0x...", // Target address
value: 0n,
}),
});
console.log("Transaction executed:", tx.transactionHash);
```
## Complete Example
Here's a complete example for setting up and using a session key with ERC-7702:
```typescript
import {
createThirdwebClient,
getContract,
sendTransaction,
prepareTransaction
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
import { Engine } from "thirdweb/engine";
async function setupSessionKeyWith7702() {
// Configuration
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key"
});
const sessionKeyAccountAddress = "0x..."; // Your session key address
try {
// Step 1: Create 7702 Smart EOA
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true,
},
});
const account = await wallet.connect({
chain: sepolia,
client: client,
strategy: "google",
});
console.log("Smart EOA created:", account.address);
// Step 2: Deploy/upgrade the account
await sendTransaction({
account: account,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: account.address,
value: 0n,
}),
});
// Step 3: Get account contract
const accountContract = getContract({
address: account.address,
chain: sepolia,
client: client,
});
// Step 4: Create session key
const sessionTx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: true,
}),
});
console.log("Session key created:", sessionTx.transactionHash);
// Step 5: Use the session key with Engine
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client: client,
vaultAccessToken: process.env.VAULT_TOKEN as string,
});
// Execute a transaction with the session key
const tx = await sendTransaction({
account: serverWallet,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: "0x...", // Target address
value: 0n,
}),
});
console.log("Transaction successful:", tx.transactionHash);
return tx;
} catch (error) {
console.error("Error:", error);
throw error;
}
}
// Execute the function
setupSessionKeyWith7702()
.then((tx) => console.log("Done!", tx.transactionHash))
.catch((error) => console.error("Failed:", error));
```
## Security Considerations
- **Session Key Storage**: Store session keys securely, preferably in a vault system
- **Permission Scope**: Use granular permissions (`grantFullPermissions: false`) for production use cases
- **Time Limits**: Set appropriate `durationInSeconds` values based on your use case
- **Policy Design**: Carefully design call and transfer policies to minimize risk
- **Key Rotation**: Regularly rotate session keys and revoke unused ones
- **Monitoring**: Monitor session key usage for suspicious activity
## Troubleshooting
### Common Issues
1. **Account not deployed**: Ensure you've sent a transaction to trigger the account deployment/upgrade
2. **Invalid signature**: Make sure the admin account is properly authenticated and can sign typed data
3. **Permission denied**: Verify the session key has the necessary policies for the operation
4. **Expired session key**: Check that the current time is within the session key's validity period
### Error Handling
Always wrap your session key operations in try-catch blocks:
```typescript
try {
const tx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400,
grantFullPermissions: true,
}),
});
} catch (error) {
if (error.message.includes("signature")) {
console.error("Failed to sign session key creation");
} else if (error.message.includes("duration")) {
console.error("Invalid duration specified");
} else {
console.error("Session key creation failed:", error);
}
}
```
</TabsContent>
</Tabs>
## Next Steps
- Learn more about [Smart Wallets](/engine/v3/configure-wallets/server-wallets)
- Learn more about [In-App Wallets](/wallets/in-app-wallet/overview)
- Explore [Engine API Reference](https://engine.thirdweb.com/reference)
- Check out the [TypeScript SDK](/references/typescript/v5/serverWallet) documentation
- Check out the [TypeScript SDK](/references/typescript/v5) documentation
- Read about [ERC-7702](https://eips.ethereum.org/EIPS/eip-7702) specification
@@ -0,0 +1,663 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@doc";
# Session Keys Guide
Session keys enable secure transaction execution on behalf of accounts without requiring direct access to the main account's private key. This guide covers session keys for both ERC-4337 smart accounts and ERC-7702 smart EOAs (Externally Owned Accounts).
<Tabs defaultValue="erc7702">
<TabsList>
<TabsTrigger value="erc7702">EIP-7702 Upgraded EOAs</TabsTrigger>
<TabsTrigger value="erc4337">ERC-4337 Smart Accounts</TabsTrigger>
</TabsList>
<TabsContent value="erc4337">
## Prerequisites
Before you begin, ensure you have:
- A thirdweb client configured
- Access to a session key account address
## Setup
First, let's set up the necessary imports and configuration:
```typescript
import {
generateAccount,
smartWallet,
sendTransaction,
getContract,
createThirdwebClient
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { getAllActiveSigners } from "thirdweb/extensions/erc4337";
import { Engine } from "thirdweb/engine";
// Configure your client
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key" // Only use in server environments
});
// Your session key account address
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address (server wallet)
// Target address for transactions
const targetAddress = "0x..."; // Replace with your target address
```
## Step 1: Configure User Smart Wallet with Session Key
The first step is to add our session key address as a signer to the user's smart account. This is typically done on the client side since it needs explicit user approval. This can be done by configuring the smart wallet with the session key address and permissions.
In a React application, this can be done by using the `ConnectButton` or `ConnectEmbed` component. This will automatically configure the smart wallet with the session key address and permissions.
```typescript
<ConnectButton
accountAbstraction={{
chain: sepolia,
sessionKey: {
address: sessionKeyAccountAddress,
permissions: {
// "*" allows all targets, or specify specific contract addresses
approvedTargets: "*",
},
},
sponsorGas: true,
}}
/>
```
This can also be done in pure TypeScript by using the `smartWallet` function and connecting it to a personal account.
For this guide, we'll generate a random personal account that will be used to create the smart wallet:
```typescript
// this would be the user's personal account
const personalAccount = await generateAccount({
client: client,
});
// wrapped in a smart wallet with session key permissions
const smart = smartWallet({
chain: sepolia,
sessionKey: {
address: sessionKeyAccountAddress,
permissions: {
// "*" allows all targets, or specify specific contract addresses
approvedTargets: "*",
},
},
sponsorGas: true, // Enable gas sponsorship
});
console.log("Personal account created:", personalAccount.address);
```
### Session Key Permissions
The `permissions` object allows you to control what the session key can do:
- `approvedTargets`: Specify which contract addresses the session key can interact with
- Use `"*"` for all targets
- Use an array of addresses for specific contracts: `["0x123...", "0x456..."]`
## Step 2: Connect Smart Account
Connect the smart wallet using the personal account:
```typescript
const smartAccount = await smart.connect({
client: client,
personalAccount: personalAccount,
});
console.log("Smart account address:", smartAccount.address);
```
Note that in a React application, this would be done automatically by the `ConnectButton` or `ConnectEmbed` component.
## Step 3 (Optional): Verify Session Key Registration
Check that the session key is properly registered as an active signer:
```typescript
const signers = await getAllActiveSigners({
contract: getContract({
address: smartAccount.address,
chain: sepolia,
client: client,
}),
});
// Verify the session key is in the list of active signers
const isSessionKeyActive = signers
.map((s) => s.signer)
.includes(sessionKeyAccountAddress);
console.log("Session key is active:", isSessionKeyActive);
console.log("All active signers:", signers.map((s) => s.signer));
```
## Step 4: Create Engine Server Wallet
Set up an Engine server wallet using the session key for transaction execution:
```typescript
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client: client,
executionOptions: {
entrypointVersion: "0.6", // ERC-4337 entrypoint version
signerAddress: sessionKeyAccountAddress,
smartAccountAddress: smartAccount.address,
type: "ERC4337",
},
});
```
### Execution Options
- `entrypointVersion`: The ERC-4337 entrypoint version to use
- `signerAddress`: The session key address that will sign transactions
- `smartAccountAddress`: The smart account address that will execute transactions
- `type`: The account abstraction type (ERC4337)
## Step 5: Execute Transactions
Now you can execute transactions using the session key, here we are sending 0 ETH to the target address for testing purposes:
```typescript
const tx = await sendTransaction({
account: serverWallet,
transaction: {
chain: sepolia,
client: client,
to: targetAddress,
value: 0n, // Amount in wei (0 for no ETH transfer)
// data: "0x...", // Optional: contract call data
},
});
console.log("Transaction sent:", tx.transactionHash);
```
## Complete Example
Here's a complete example putting it all together:
```typescript
import {
generateAccount,
smartWallet,
sendTransaction,
getContract,
createThirdwebClient
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { getAllActiveSigners } from "thirdweb/extensions/erc4337";
import { Engine } from "thirdweb/engine";
async function executeTransactionWithSessionKey() {
// Configuration
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key"
});
const sessionKeyAccountAddress = "0x..."; // Your session key address (server wallet)
const targetAddress = "0x..."; // Target address for the final transaction
try {
// Step 1: Create personal account
const personalAccount = await generateAccount({ client });
// Step 2: Configure smart wallet
const smart = smartWallet({
chain: sepolia,
sessionKey: {
address: sessionKeyAccountAddress,
permissions: {
approvedTargets: "*",
},
},
sponsorGas: true,
});
// Step 3: Connect smart account
const smartAccount = await smart.connect({
client,
personalAccount,
});
// Step 4: Verify session key
const signers = await getAllActiveSigners({
contract: getContract({
address: smartAccount.address,
chain: sepolia,
client,
}),
});
const isSessionKeyActive = signers
.map((s) => s.signer)
.includes(sessionKeyAccountAddress);
if (!isSessionKeyActive) {
throw new Error("Session key is not active");
}
// Step 5: Create server wallet
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client,
executionOptions: {
entrypointVersion: "0.6",
signerAddress: sessionKeyAccountAddress,
smartAccountAddress: smartAccount.address,
type: "ERC4337",
},
});
// Step 6: Execute transaction
const tx = await sendTransaction({
account: serverWallet,
transaction: {
chain: sepolia,
client,
to: targetAddress,
value: 0n,
},
});
console.log("Transaction successful:", tx.transactionHash);
return tx;
} catch (error) {
console.error("Error executing transaction:", error);
throw error;
}
}
// Execute the function
executeTransactionWithSessionKey()
.then((tx) => console.log("Done!", tx.transactionHash))
.catch((error) => console.error("Failed:", error));
```
## Security Considerations
- **Session Key Storage**: Store session keys securely, preferably in a vault system
- **Permission Scope**: Limit session key permissions to only necessary targets
- **Key Rotation**: Regularly rotate session keys for enhanced security
- **Monitoring**: Monitor session key usage for suspicious activity
## Troubleshooting
### Common Issues
1. **Session key not active**: Ensure the session key is properly registered with the smart account
2. **Permission denied**: Check that the target address is included in `approvedTargets`
3. **Gas estimation failed**: Verify that gas sponsorship is properly configured
</TabsContent>
<TabsContent value="erc7702">
## Prerequisites
Before you begin, ensure you have:
- A thirdweb client configured
- An in-app wallet with ERC-7702 support
- A session key account address (can be generated or an existing wallet)
## Setup
First, let's set up the necessary imports and configuration:
```typescript
import {
createThirdwebClient,
getContract,
sendTransaction,
prepareTransaction
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
// Configure your client
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key" // Only use in server environments
});
// Your session key account address
const sessionKeyAccountAddress = "0x..."; // Replace with your session key address (server wallet)
```
## Step 1: Create ERC-7702 Smart EOA
Create an in-app wallet configured with ERC-7702 execution mode:
```typescript
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true, // Enable gas sponsorship
},
});
// Connect the wallet (user authentication)
const account = await wallet.connect({
chain: sepolia,
client: client,
strategy: "google", // or "email", "guest", etc.
});
console.log("Upgraded EOA address:", account.address);
```
### Execution Modes
The `executionMode` option allows you to configure the wallet behavior:
- `mode`: Set to `"EIP7702"` for EIP-7702 upgraded EOA functionality
- `sponsorGas`: Enable gas sponsorship for gasless transactions
## Step 2: Get Account Contract
Create a contract instance for the deployed smart EOA:
```typescript
const accountContract = getContract({
address: account.address,
chain: sepolia,
client: client,
});
```
## Step 3: Create Session Key with Full Permissions
Add a session key with full execution permissions:
```typescript
const tx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: true, // Allow unrestricted access
}),
});
console.log("Session key created:", tx.transactionHash);
```
### Session Key Options
- `account`: The admin account that will create the session key
- `contract`: The smart EOA contract instance
- `sessionKeyAddress`: The address that will be granted session key permissions
- `durationInSeconds`: How long the session key should be valid (in seconds)
- `grantFullPermissions`: If `true`, grants unrestricted access. If `false`, you must specify policies.
## Step 4 (Advanced): Create Session Key with Granular Permissions
For more fine-grained control, you can specify call and transfer policies:
```typescript
import { Condition, LimitType } from "thirdweb/wallets/in-app";
const tx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: false, // Use granular permissions
callPolicies: [
{
target: "0x123...", // Target contract address
selector: "0xa9059cbb", // Function selector (e.g., ERC20 transfer)
maxValuePerUse: 0n, // Max ETH value per call
valueLimit: {
limitType: LimitType.Lifetime, // Lifetime limit
limit: 1000000n, // Max total value in wei
period: 0n, // Not used for lifetime limits
},
constraints: [
{
condition: Condition.Equal, // Constraint type
index: 0n, // Parameter index
refValue: "0x000000000000000000000000abc...", // Expected value
},
],
},
],
transferPolicies: [
{
target: "0x456...", // Recipient address for transfers
maxValuePerUse: 100000n, // Max wei per transfer
valueLimit: {
limitType: LimitType.Allowance, // Allowance-based limit
limit: 1000000n, // Total allowance in wei
period: 86400n, // Reset period in seconds
},
},
],
}),
});
console.log("Session key with policies created:", tx.transactionHash);
```
### Permission Types
#### Call Policies
Control which smart contract calls the session key can make:
- `target`: The contract address the session key can call
- `selector`: The function selector (4-byte identifier) that can be called
- `maxValuePerUse`: Maximum ETH value that can be sent per transaction
- `valueLimit`: Total value limits over time
- `constraints`: Parameter-level constraints for function calls
#### Transfer Policies
Control native token transfers the session key can make:
- `target`: The recipient address for transfers
- `maxValuePerUse`: Maximum wei per individual transfer
- `valueLimit`: Total transfer limits over time
#### Limit Types
```typescript
enum LimitType {
Unlimited = 0, // No limit
Lifetime = 1, // One-time total limit
Allowance = 2, // Renewable allowance based on period
}
```
#### Constraint Conditions
```typescript
enum Condition {
Unconstrained = 0, // No constraint
Equal = 1, // Must equal reference value
Greater = 2, // Must be greater than reference value
Less = 3, // Must be less than reference value
GreaterOrEqual = 4, // Must be >= reference value
LessOrEqual = 5, // Must be <= reference value
NotEqual = 6, // Must not equal reference value
}
```
## Step 5: Use the Session Key
Once created, the session key can be used with Engine or other server-side execution:
```typescript
import { Engine } from "thirdweb/engine";
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client: client,
executionOptions: {
type: "EIP7702",
sessionKeyAddress: sessionKeyAccountAddress,
accountAddress: account.address,
},
});
// Execute transactions with the session key
const tx = await sendTransaction({
account: serverWallet,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: "0x...", // Target address
value: 0n,
}),
});
console.log("Transaction executed:", tx.transactionHash);
```
## Complete Example
Here's a complete example for setting up and using a session key with ERC-7702:
```typescript
import {
createThirdwebClient,
getContract,
sendTransaction,
prepareTransaction
} from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { inAppWallet, createSessionKey } from "thirdweb/wallets/in-app";
import { Engine } from "thirdweb/engine";
async function setupSessionKeyWith7702() {
// Configuration
const client = createThirdwebClient({
clientId: "your-client-id",
secretKey: "your-secret-key"
});
const sessionKeyAccountAddress = "0x..."; // Your session key address (server wallet)
try {
// Step 1: Create 7702 Smart EOA
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true,
},
});
const account = await wallet.connect({
chain: sepolia,
client: client,
strategy: "google",
});
console.log("Upgraded EOA created:", account.address);
// Step 2: Get account contract
const accountContract = getContract({
address: account.address,
chain: sepolia,
client: client,
});
// Step 3: Create session key
const sessionTx = await sendTransaction({
account: account,
transaction: createSessionKey({
account: account,
contract: accountContract,
sessionKeyAddress: sessionKeyAccountAddress,
durationInSeconds: 86400, // 1 day
grantFullPermissions: true,
}),
});
console.log("Session key created:", sessionTx.transactionHash);
// Step 4: Use the session key with Engine
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: sepolia,
client: client,
executionOptions: {
type: "EIP7702",
sessionKeyAddress: sessionKeyAccountAddress,
accountAddress: account.address,
},
});
// Execute a transaction with the session key
const tx = await sendTransaction({
account: serverWallet,
transaction: prepareTransaction({
chain: sepolia,
client: client,
to: "0x...", // Target address
value: 0n,
}),
});
console.log("Transaction successful:", tx.transactionHash);
return tx;
} catch (error) {
console.error("Error:", error);
throw error;
}
}
// Execute the function
setupSessionKeyWith7702()
.then((tx) => console.log("Done!", tx.transactionHash))
.catch((error) => console.error("Failed:", error));
```
## Security Considerations
- **Session Key Storage**: Store session keys securely, preferably in a vault system
- **Permission Scope**: Use granular permissions (`grantFullPermissions: false`) for production use cases
- **Time Limits**: Set appropriate `durationInSeconds` values based on your use case
- **Policy Design**: Carefully design call and transfer policies to minimize risk
- **Key Rotation**: Regularly rotate session keys and revoke unused ones
- **Monitoring**: Monitor session key usage for suspicious activity
## Troubleshooting
### Common Issues
1. **Account not deployed**: Ensure you've sent a transaction to trigger the account deployment/upgrade
2. **Invalid signature**: Make sure the admin account is properly authenticated and can sign typed data
3. **Permission denied**: Verify the session key has the necessary policies for the operation
4. **Expired session key**: Check that the current time is within the session key's validity period
</TabsContent>
</Tabs>
## Next Steps
- Learn more about [In-App Wallets](/wallets/users)
- Explore the [API Reference](/reference)
- Check out the [TypeScript SDK](/references/typescript/v5) documentation
+4
View File
@@ -65,6 +65,10 @@ export const sidebar: SideBar = {
href: `${walletSlug}/export-private-key`,
name: "Export Private Keys",
},
{
href: `${walletSlug}/session-keys`,
name: "Session Keys",
},
{
href: `${walletSlug}/adapters`,
name: "Usage with other libraries",
+8
View File
@@ -1,5 +1,13 @@
# wagmi-inapp
## 0.0.5
### Patch Changes
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf), [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc)]:
- thirdweb@5.108.10
- @thirdweb-dev/wagmi-adapter@0.2.160
## 0.0.4
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wagmi-inapp",
"private": true,
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"scripts": {
"dev": "vite",
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/engine
## 3.2.2
### Patch Changes
- [#8218](https://github.com/thirdweb-dev/js/pull/8218) [`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update to latest API spec
## 3.2.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/engine",
"version": "3.2.1",
"version": "3.2.2",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
+92 -2
View File
@@ -148,6 +148,9 @@ export type BatchResultItemEncodeResultSuccessItemEngineError =
message: string;
type: "THIRDWEB_ERROR";
}
| (SerialisableAwsSignerError & {
type: "AWS_KMS_SIGNER_ERROR";
})
| {
message: string;
type: "INTERNAL_ERROR";
@@ -235,6 +238,9 @@ export type BatchResultItemReadResultSuccessItemEngineError =
message: string;
type: "THIRDWEB_ERROR";
}
| (SerialisableAwsSignerError & {
type: "AWS_KMS_SIGNER_ERROR";
})
| {
message: string;
type: "INTERNAL_ERROR";
@@ -331,6 +337,9 @@ export type BatchResultItemSignResultDataEngineError =
message: string;
type: "THIRDWEB_ERROR";
}
| (SerialisableAwsSignerError & {
type: "AWS_KMS_SIGNER_ERROR";
})
| {
message: string;
type: "INTERNAL_ERROR";
@@ -472,13 +481,34 @@ export type ContractWrite = ContractCall & {
/**
* EIP-7702 Execution Options
*/
export type Eip7702ExecutionOptions = {
export type Eip7702ExecutionOptions =
| Eip7702OwnerExecution
| Eip7702SessionKeyExecution;
/**
* EIP-7702 Owner Execution
*/
export type Eip7702OwnerExecution = {
/**
* The EOA address that will sign the EIP-7702 transaction
* The delegated EOA address
*/
from: AddressDef;
};
/**
* EIP-7702 Session Key Execution
*/
export type Eip7702SessionKeyExecution = {
/**
* The session key address is your server wallet, which has been granted a session key to the `account_address`
*/
sessionKeyAddress: AddressDef;
/**
* The account address is the address of a delegated account you want to execute the transaction on. This account has granted a session key to the `session_key_address`
*/
accountAddress: AddressDef;
};
export type EmptyIdempotencySetResponse = {
queueName: string;
message: string;
@@ -889,6 +919,64 @@ export type SendTransactionRequest = {
webhookOptions?: Array<WebhookOptions>;
};
export type SerialisableAwsSdkError =
| {
message: string;
type: "CONSTRUCTION_FAILURE";
}
| {
message: string;
type: "TIMEOUT_ERROR";
}
| {
message: string;
type: "DISPATCH_FAILURE";
}
| {
message: string;
type: "RESPONSE_ERROR";
}
| {
message: string;
type: "SERVICE_ERROR";
}
| {
message: string;
type: "OTHER";
};
export type SerialisableAwsSignerError =
| {
aws_sdk_error: SerialisableAwsSdkError;
type: "SIGN";
}
| {
aws_sdk_error: SerialisableAwsSdkError;
type: "GET_PUBLIC_KEY";
}
| {
message: string;
type: "K256";
}
| {
message: string;
type: "SPKI";
}
| {
message: string;
type: "HEX";
}
| {
type: "SIGNATURE_NOT_FOUND";
}
| {
type: "PUBLIC_KEY_NOT_FOUND";
}
| {
message: string;
type: "UNKNOWN";
};
export type SerializableReqwestError =
| {
Builder: {
@@ -1552,6 +1640,7 @@ export type GetTransactionsResponses = {
}
| Array<unknown>;
transactionHash: string | null;
status: string | null;
confirmedAt: string | null;
confirmedAtBlockNumber: string | null;
enrichedData:
@@ -1712,6 +1801,7 @@ export type SearchTransactionsResponses = {
}
| Array<unknown>;
transactionHash: string | null;
status: string | null;
confirmedAt: string | null;
confirmedAtBlockNumber: string | null;
enrichedData:
+7
View File
@@ -1,5 +1,12 @@
# @thirdweb-dev/nebula
## 0.2.63
### Patch Changes
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf), [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc)]:
- thirdweb@5.108.10
## 0.2.62
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.62"
"version": "0.2.63"
}
+1
View File
@@ -241,6 +241,7 @@ export type ProjectService =
rotationCode?: string | null;
encryptedAdminKey?: string | null;
encryptedWalletAccessToken?: string | null;
projectWalletAddress?: string | null;
}
| ProjectBundlerService
| ProjectEmbeddedWalletsService;
+11
View File
@@ -1,5 +1,16 @@
# thirdweb
## 5.108.10
### Patch Changes
- [#8218](https://github.com/thirdweb-dev/js/pull/8218) [`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Export missing x402 types
- [#8215](https://github.com/thirdweb-dev/js/pull/8215) [`62cfbb7`](https://github.com/thirdweb-dev/js/commit/62cfbb7441e66156c55c76d73f5b481f5ea576bc) Thanks [@MananTank](https://github.com/MananTank)! - Show error in BuyWidget and SwapWidget UI if fetching token details fails
- Updated dependencies [[`f630912`](https://github.com/thirdweb-dev/js/commit/f630912b9b0e1d8ebe22b0b52c650f5e40e12baf)]:
- @thirdweb-dev/engine@3.2.2
## 5.108.9
### Patch Changes
+1 -1
View File
@@ -430,5 +430,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.108.9"
"version": "5.108.10"
}
@@ -189,8 +189,8 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet {
};
case "EIP7702":
return {
...executionOptions,
chainId,
from: address,
type: "EIP7702",
};
}
+1
View File
@@ -1,3 +1,4 @@
export type { Money, PaymentMiddlewareConfig } from "x402/types";
export { decodePayment, encodePayment } from "../x402/encode.js";
export {
facilitator,
@@ -251,11 +251,14 @@ export function FundWallet(props: FundWalletProps) {
? tokenQuery.data.token
: undefined,
isFetching: tokenQuery.isFetching,
isError:
tokenQuery.isError ||
tokenQuery.data?.type === "unsupported_token",
}
: undefined
}
balance={{
data: tokenBalanceQuery.data?.value,
data: tokenBalanceQuery.data,
isFetching: tokenBalanceQuery.isFetching,
}}
client={props.client}
@@ -279,6 +282,22 @@ export function FundWallet(props: FundWalletProps) {
<Spacer y="md" />
{(tokenQuery.isError ||
tokenQuery.data?.type === "unsupported_token") && (
<div
style={{
border: `1px solid ${theme.colors.borderColor}`,
borderRadius: radius.full,
padding: spacing.xs,
marginBottom: spacing.md,
}}
>
<Text size="sm" color="danger" center>
Failed to fetch token details
</Text>
</div>
)}
{/* Continue Button */}
{activeWalletInfo ? (
<Button
@@ -373,6 +392,7 @@ function TokenSection(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
currency: SupportedFiatCurrency;
@@ -381,7 +401,14 @@ function TokenSection(props: {
title: string;
isConnected: boolean;
balance: {
data: bigint | undefined;
data:
| {
value: bigint;
decimals: number;
symbol: string;
name: string;
}
| undefined;
isFetching: boolean;
};
onWalletClick: () => void;
@@ -565,17 +592,16 @@ function TokenSection(props: {
<Text size="xs" color="secondaryText">
Current Balance
</Text>
{props.balance.data === undefined ||
props.selectedToken.data === undefined ? (
{props.balance.data === undefined ? (
<Skeleton height={fontSize.xs} width="100px" />
) : (
<Text size="xs" color="primaryText">
{formatTokenAmount(
props.balance.data,
props.selectedToken.data.decimals,
props.balance.data.value,
props.balance.data.decimals,
5,
)}{" "}
{props.selectedToken.data.symbol}
{props.balance.data.symbol}
</Text>
)}
</div>
@@ -21,6 +21,7 @@ export function SelectedTokenButton(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
client: ThirdwebClient;
@@ -47,7 +48,7 @@ export function SelectedTokenButton(props: {
{/* icons */}
<Container relative color="secondaryText">
{/* token icon */}
{props.selectedToken ? (
{props.selectedToken && !props.selectedToken.isError ? (
<Img
key={props.selectedToken?.data?.iconUri}
src={
@@ -112,7 +113,7 @@ export function SelectedTokenButton(props: {
</Container>
{/* token symbol and chain name */}
{props.selectedToken ? (
{props.selectedToken && !props.selectedToken.isError ? (
<Container flex="column" style={{ gap: "3px" }}>
{props.selectedToken?.isFetching ? (
<Skeleton width="60px" height={fontSize.md} />
@@ -96,6 +96,7 @@ function useTokenPrice(options: {
);
},
refetchOnMount: false,
retry: false,
refetchOnWindowFocus: false,
});
}
@@ -320,6 +321,7 @@ export function SwapUI(props: SwapUIProps) {
? {
data: sellTokenQuery.data,
isFetching: sellTokenQuery.isFetching,
isError: sellTokenQuery.isError,
}
: undefined
}
@@ -372,6 +374,7 @@ export function SwapUI(props: SwapUIProps) {
? {
data: buyTokenQuery.data,
isFetching: buyTokenQuery.isFetching,
isError: buyTokenQuery.isError,
}
: undefined
}
@@ -389,7 +392,9 @@ export function SwapUI(props: SwapUIProps) {
/>
{/* error message */}
{preparedResultQuery.error ? (
{preparedResultQuery.error ||
buyTokenQuery.isError ||
sellTokenQuery.isError ? (
<Text
size="sm"
color="danger"
@@ -398,7 +403,13 @@ export function SwapUI(props: SwapUIProps) {
paddingBlock: spacing.md,
}}
>
Failed to get a quote
{preparedResultQuery.error
? "Failed to get a quote"
: buyTokenQuery.isError
? "Failed to fetch buy token details"
: sellTokenQuery.isError
? "Failed to fetch sell token details"
: "Failed to get a quote"}
</Text>
) : (
<Spacer y="md" />
@@ -626,6 +637,7 @@ function TokenSection(props: {
| {
data: TokenWithPrices | undefined;
isFetching: boolean;
isError: boolean;
}
| undefined;
currency: SupportedFiatCurrency;
+2
View File
@@ -1,5 +1,7 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.160
## 0.2.159
## 0.2.158
+1 -1
View File
@@ -56,5 +56,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.159"
"version": "0.2.160"
}