Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f0f8da8fab Version Packages (#7032)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 19:50:56 -07:00
Joaquim VergesandGitHub da3fc99c0b [SDK] Default to in-memory storage for inAppWallet outside browser (#7038) 2025-05-13 18:32:37 -07:00
fb4c190eb3 [Dashboard] Feature: Default analytics range to 30 days and save preference (#7039)
Co-authored-by: Claude <[email protected]>
2025-05-13 15:25:09 -07:00
FirekeeperandGitHub 1c31a1e4df [Portal] .NET ThirdwebBridge Onramping & InAppWallet 7702 Account Abstraction (#7017) 2025-05-13 14:14:45 -07:00
MananTank 04c10f82fd [TOOL-4471] GatedSwitch updates, Make In-app wallet branding gated by starter+ (#7021)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the billing and plan management features in the dashboard. It introduces new props for handling plan highlights and open plan sheets, modifies tooltip styles, and adjusts billing card components to improve user experience.

### Detailed summary
- Updated `tooltip.tsx` to change padding in tooltip content.
- Changed `requiredPlan` from `authRequiredPlan` to `brandingRequiredPlan` in `Configure/index.tsx`.
- Added `openPlanSheetButtonByDefault` and `highlightPlan` props in various components.
- Modified `PlanInfoCardClient` and `Billing` components to utilize new props.
- Updated `TeamPlanBadge` to include an optional postfix.
- Enhanced `BillingPricing` logic to handle plan highlights.
- Adjusted `GatedSwitch` tooltip content for better user guidance.

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

<!-- end pr-codex -->
2025-05-13 20:22:10 +00:00
MananTank ab173886ac [TOOL-4483] Move changelog to team landing page from notifications (#7037)
<!--

## 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 refactoring notification handling in the application, removing unused functions, and enhancing the UI components related to notifications and alerts.

### Detailed summary
- Removed `getChangelogNotifications` and related props from several components.
- Introduced `DismissibleAlert` component for better alert management.
- Updated `NotificationButtonUI` to streamline notification handling.
- Adjusted UI elements for consistency and clarity.

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

<!-- end pr-codex -->
2025-05-13 19:19:55 +00:00
Joe-Thirdweb df06bec699 [Dashboard]Adding zkCandy + Onyx chain updates (#7035)
Adding custom chain info to

- Onyx Mainnet
- zkCandy Mainnet

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and updates the `chainMetaRecord` object to include information for the `Onyx` and `zkCandy` blockchains, enhancing the dashboard with additional details and visuals.

### Detailed summary
- Added imports for `onyxBanner`, `onyxCTA`, and `zkCandyBanner` images.
- Introduced `Onyx` blockchain entry in `chainMetaRecord` with header image, description, and CTA.
- Added `zkCandy` blockchain entry in `chainMetaRecord` with header image, description, and CTA.
- Updated comment for `zkCandy` entry to specify it as "zkCandy Testnet".

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

<!-- end pr-codex -->
2025-05-13 15:42:08 +00:00
Joaquim VergesandGitHub 6000dacfbb [Docs] Add Engine v2 to v3 migration guide (#7036) 2025-05-13 04:12:27 -07:00
Joaquim VergesandGitHub fc6535069b [SDK] Preload wallet balances on pay embed (#7034) 2025-05-13 17:08:26 +12:00
Joaquim VergesandGitHub b15c3c0747 Add contract availability documentation to ERC methods (#7033) 2025-05-12 17:47:28 -07:00
Joaquim VergesandGitHub 1985de89aa [SDK] Expose inMemoryStorage for inAppWallet backend usage (#7016) 2025-05-12 16:04:22 -07:00
MananTank 2b87b7bb75 [NEB-246] Nebula: handle aborted session with no messages (#7025)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `ChatSidebar`, `useSessionsWithLocalOverrides`, and `ChatPageContent` components. It improves session handling, updates the chat sidebar title, and adds a user-friendly message for sessions without messages.

### Detailed summary
- Updated `title` prop in `<ChatSidebarLink>` to default to "Untitled Chat".
- Merged new sessions with existing ones in `useSessionsWithLocalOverrides`.
- Introduced a message and icon for sessions with no messages in `ChatPageContent`.
- Conditional rendering of `<Chats>` based on message availability.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank d99a985ab4 [NEB-238] Show ENS name in selected account badge (#7024)
<!--

## 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 rendering of the `AccountName` component in the `ChatBar.tsx` file to improve the styling and structure of the address display.

### Detailed summary
- Replaced `span` with `AccountName` component for displaying the selected address.
- Added `className="text-xs"` to the `AccountName` component.
- Updated `loadingComponent` and `fallbackComponent` to use a consistent `span` with `className="text-xs"`.
- Changed `className` of `span` in `WalletSelector` from `font-mono text-sm` to `text-sm` for a consistent style.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank e74574ccf5 [NEB-239] Fix chain search not working properly for chain name starting with number (#7023)
<!--

## 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 improving the code by replacing `Number.parseInt` with `Number` for checking if a value is an integer across multiple files, enhancing readability and consistency.

### Detailed summary
- In `TokenSelector.tsx`, changed `Number.parseInt(searchValue)` to `Number(searchValue)`.
- In `single-network-selector.tsx`, modified the same integer check.
- In `ConfigureCustomChain.tsx`, updated integer check for `chainSlug`.
- In `NetworkSelectors.tsx`, replaced the integer check similarly.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank 98d05d6fb6 [NEB-247] Show error event in chat UI (#7022)
<!--

## 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 for chat responses in the `ChatPageContent` and `chat.ts` files. It adds functionality to display error messages when an error event is received.

### Detailed summary
- Added handling for `"error"` case in `ChatPageContent.tsx` to update messages with error details.
- Implemented parsing of error data in `chat.ts` to extract error code and message.
- Updated `ChatStreamedEvent` type to include an `"error"` event with relevant data structure.

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

<!-- end pr-codex -->
2025-05-13 02:09:03 +05:30
MananTank f4ac3c2a3a [NEB-245] Limit image format types for upload (#7019)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `ImageUploadButton` component by allowing it to accept specific image file types and updating the `ChatBar` component to utilize this feature.

### Detailed summary
- In `ChatBar.tsx`, added `accept` attribute with value `"image/jpeg,image/png,image/webp"` to the file input.
- In `image-upload-button.tsx`, changed `accept` property from a static value `"image/*"` to a dynamic value using `props.accept`.
- Updated `ImageUploadButton` to include `accept` in its props definition.

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

<!-- end pr-codex -->
2025-05-12 20:26:53 +00:00
MananTank a3a4bf4361 Nebula: Fix User Image message type (#7018)
<!--

## 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 image handling in the `types.ts` and related components by modifying the structure of image objects, allowing for optional `image_url` and `b64` properties.

### Detailed summary
- In `types.ts`, changed the `image` type to include `image_url: string | null` and `b64: string | null`.
- In `ChatBar.tsx`, added `image_url: null` when pushing the image object to `userMessage.content`.
- In `Chats.tsx`, updated the condition to check if `msg.b64` is a string and used `msg.image_url ?? ""` for the URL.
- In `NebulaImage.tsx`, adjusted the skeleton div to have a minimum height of 300px.

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

<!-- end pr-codex -->
2025-05-12 17:15:52 +00:00
101 changed files with 1302 additions and 546 deletions
@@ -71,7 +71,7 @@ export function MultiNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
@@ -181,7 +181,7 @@ export function SingleNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
@@ -137,7 +137,7 @@ function RenderSidebarGroup(props: {
}
if ("separator" in link) {
return <SidebarSeparator className="my-1" />;
return <SidebarSeparator key={Math.random()} className="my-1" />;
}
return (
@@ -46,7 +46,7 @@ export function TokenSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(token.chainId).startsWith(searchValue);
}
return (
@@ -0,0 +1,36 @@
"use client";
import { XIcon } from "lucide-react";
import { useLocalStorage } from "../../../hooks/useLocalStorage";
import { Button } from "../ui/button";
export function DismissibleAlert(props: {
title: React.ReactNode;
description: React.ReactNode;
localStorageId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
props.localStorageId,
true,
false,
);
if (!isVisible) return null;
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
<div className="text-muted-foreground text-sm">{props.description}</div>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ interface ImageUploadProps {
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
multiple?: boolean;
accept: string;
}
export function ImageUploadButton(props: ImageUploadProps) {
@@ -34,7 +35,7 @@ export function ImageUploadButton(props: ImageUploadProps) {
ref={fileInputRef}
type="file"
multiple={props.multiple}
accept="image/*"
accept={props.accept}
onChange={handleFileChange}
className="hidden"
aria-label="Upload image"
@@ -54,11 +54,11 @@ export function ToolTipLabel(props: {
align={props.align}
sideOffset={10}
className={cn(
"max-w-[400px] whitespace-normal leading-relaxed",
"max-w-[400px] whitespace-normal p-0 leading-relaxed",
props.contentClassName,
)}
>
<div className="flex items-center gap-1.5 p-2 text-sm">
<div className="flex items-center gap-1.5 p-4 text-sm">
{props.leftIcon}
{props.label}
{props.rightIcon}
@@ -11,7 +11,7 @@ export function ConfigureCustomChain(props: {
chainSlug: string;
}) {
const { chainSlug } = props;
const isSlugNumber = Number.isInteger(Number.parseInt(chainSlug));
const isSlugNumber = Number.isInteger(Number(chainSlug));
const [isConfigured, setIsConfigured] = useState(false);
if (isConfigured) {
@@ -29,7 +29,7 @@ export function SingleNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -45,6 +45,8 @@ import flowBanner from "./temp-assets/flowBanner.png";
import flowCTA from "./temp-assets/flowCTA.png";
import mantleBanner from "./temp-assets/mantle.png";
import metalBanner from "./temp-assets/metalBanner.png";
import onyxBanner from "./temp-assets/onyxBanner.png";
import onyxCTA from "./temp-assets/onyxCTA.png";
import plumeBanner from "./temp-assets/plumeBanner.png";
import plumeCTA from "./temp-assets/plumeCTA.png";
@@ -73,6 +75,7 @@ import worldChainCTA from "./temp-assets/worldChainCTA.jpg";
import xaiBanner from "./temp-assets/xai-banner.jpg";
import zetachainBanner from "./temp-assets/zetachainBanner.png";
import zetachainCTA from "./temp-assets/zetachainCTA.png";
import zkCandyBanner from "./temp-assets/zkCandyBanner.jpg";
import zytronBanner from "./temp-assets/zytronBanner.png";
import zytronCTA from "./temp-assets/zytronCTA.jpg";
// END TEMPORARY
@@ -554,6 +557,18 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//Onyx Mainnet
80888: {
headerImgUrl: onyxBanner.src,
about:
"Onyx is a layer 3 blockchain secured by Ethereum and Base providing developers and institutions a secure financial-purposed decentralized blockchain for banking and securities based applications. Powered by XCN.",
cta: {
backgroundImageUrl: onyxCTA.src,
title: "Unlock the potential of your application with Onyx Connect",
buttonLink: "https://onyx.org/",
buttonText: "Learn More",
},
},
//Plume Testnet
98864: {
headerImgUrl: plumeBanner.src,
@@ -718,7 +733,7 @@ const chainMetaRecord = {
buttonText: "Learn More",
},
},
//zkCandy
//zkCandy Testnet
302: {
headerImgUrl: thirdwebBanner.src,
about:
@@ -730,6 +745,19 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//zkCandy Mainnet
320: {
headerImgUrl: zkCandyBanner.src,
about:
"ZKcandy is an AI-powered Layer-2 aiming to redefine what’s possible in gaming and entertainment. As a ZK Chain built on ZKsync’s Elastic Network, it focuses on building immersive gaming ecosystems where players make the most of today’s AI capabilities. Emerged from a groundbreaking collaboration between ZKsync and the award-winning game studio iCandy Interactive, It's the first L2 ZK chain for gaming in the ZKsync ecosystem.",
cta: {
backgroundImageUrl: zkCandyBanner.src,
title: " ",
buttonLink: "https://litepaper.zkcandy.io/dev-guide/",
buttonText: "Learn More",
},
},
//zkSync
324: {
headerImgUrl: thirdwebBanner.src,
@@ -11,7 +11,6 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../../team/components/NotificationButton/fetch-notifications";
@@ -59,7 +58,6 @@ export function AccountHeader(props: {
account: props.account,
client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
@@ -64,7 +64,6 @@ function Variants(props: {
email: "[email protected]",
}}
client={storybookThirdwebClient}
getChangelogNotifications={() => Promise.resolve([])}
getInboxNotifications={() => Promise.resolve([])}
markNotificationAsRead={() => Promise.resolve()}
/>
@@ -24,7 +24,6 @@ export type AccountHeaderCompProps = {
account: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
@@ -78,7 +77,6 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -126,7 +124,6 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -15,7 +15,6 @@ export function SecondaryNav(props: {
connectButton: React.ReactNode;
client: ThirdwebClient;
accountAddress: string;
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
@@ -24,7 +23,6 @@ export function SecondaryNav(props: {
<SecondaryNavLinks />
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogs}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -8,8 +8,8 @@ const teamPlanToBadgeVariant: Record<
> = {
// gray
free: "secondary",
starter: "secondary",
// yellow
starter: "warning",
starter_legacy: "warning",
growth_legacy: "warning",
// green
@@ -20,7 +20,7 @@ const teamPlanToBadgeVariant: Record<
pro: "default",
};
function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
if (plan === "growth_legacy") {
return "Growth - Legacy";
}
@@ -33,13 +33,14 @@ function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function TeamPlanBadge(props: {
plan: Team["billingPlan"];
className?: string;
postfix?: string;
}) {
return (
<Badge
variant={teamPlanToBadgeVariant[props.plan]}
className={cn("px-1.5 capitalize", props.className)}
>
{getTeamPlanBadgeLabel(props.plan)}
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
</Badge>
);
}
@@ -0,0 +1,64 @@
import { formatDistance } from "date-fns";
import { ArrowRightIcon } from "lucide-react";
import { unstable_cache } from "next/cache";
import Link from "next/link";
type ChangelogItem = {
published_at: string;
title: string;
url: string;
};
export async function Changelog() {
const changelog = await getChangelog();
return (
<div className="relative flex flex-col gap-6 border-border border-l py-2">
{changelog.map((item) => (
<div className="flex flex-row gap-2" key={item.title}>
<div className="-translate-x-1/2 size-2.5 shrink-0 translate-y-1/2 rounded-full bg-border" />
<div className="flex flex-col">
<Link
target="_blank"
href={`${item.url}?utm_source=thirdweb&utm_campaign=changelog`}
role="group"
className="line-clamp-2 text-foreground text-sm hover:underline"
>
{item.title}
</Link>
<div className="mt-1 text-muted-foreground text-xs">
{formatDistance(new Date(item.published_at), Date.now(), {
addSuffix: true,
})}
</div>
</div>
</div>
))}
<Link
href="https://blog.thirdweb.com/changelog?utm_source=thirdweb&utm_campaign=changelog"
target="_blank"
className="flex items-center gap-2 pl-5 text-foreground text-sm hover:underline"
>
View More <ArrowRightIcon className="size-4" />
</Link>
</div>
);
}
const getChangelog = unstable_cache(
async () => {
const res = await fetch(
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=10",
);
if (!res.ok) {
return [];
}
const json = await res.json();
return json.posts as ChangelogItem[];
},
["changelog"],
{
revalidate: 60 * 60, // 1 hour
},
);
@@ -1,13 +1,13 @@
import { getWalletConnections } from "@/api/analytics";
import { type Project, getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { subDays } from "date-fns";
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { Changelog } from "./_components/Changelog";
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
import {
type ProjectWithAnalytics,
@@ -52,20 +52,30 @@ export default async function Page(props: {
</div>
</div>
<div className="container flex grow flex-col gap-6 pt-8 pb-20">
<Alert variant={"info"}>
<CircleAlertIcon className="h-4 w-4" />
<AlertTitle>Looking for Engines?</AlertTitle>
<AlertDescription>
Engines, contracts, project settings, and more are now managed
within projects. Open or create a project to access them.
</AlertDescription>
</Alert>
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={client}
/>
<div className="container flex grow flex-col gap-4 lg:flex-row">
{/* left */}
<div className="flex grow flex-col gap-6 pt-8 lg:pb-20">
<DismissibleAlert
title="Looking for Engines?"
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
localStorageId={`${team.id}-engines-alert`}
/>
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={client}
/>
</div>
{/* right */}
<div className="w-full px-4 py-8 lg:w-[300px]">
<h2 className="mb-3 font-semibold text-2xl tracking-tight">
Changelog
</h2>
<Changelog />
</div>
</div>
</div>
);
@@ -26,7 +26,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { formatDate } from "date-fns";
import { PlusIcon, SearchIcon } from "lucide-react";
import { ArrowDownNarrowWideIcon, PlusIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
@@ -251,10 +251,10 @@ function SearchInput(props: {
return (
<div className="relative grow">
<Input
placeholder="Search Projects by name or Client ID"
placeholder="Project name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="bg-background pl-9 lg:w-[400px]"
className="bg-background pl-9 lg:w-[320px]"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
@@ -298,8 +298,8 @@ function SelectBy(props: {
}}
>
<SelectTrigger className="min-w-[200px] bg-background capitalize">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Sort by</span>
<div className="flex items-center gap-2">
<ArrowDownNarrowWideIcon className="size-4 text-muted-foreground" />
{valueToLabel[props.value]}
</div>
</SelectTrigger>
@@ -7,9 +7,12 @@ import { PlanInfoCardUI } from "./PlanInfoCard";
export function PlanInfoCardClient(props: {
subscriptions: TeamSubscription[];
team: Team;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}) {
return (
<PlanInfoCardUI
openPlanSheetButtonByDefault={props.openPlanSheetButtonByDefault}
team={props.team}
subscriptions={props.subscriptions}
getTeam={async () => {
@@ -26,6 +29,7 @@ export function PlanInfoCardClient(props: {
return res.data.result;
}}
highlightPlan={props.highlightPlan}
/>
);
}
@@ -118,6 +118,8 @@ function Story(props: {
team={team}
subscriptions={zeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -129,6 +131,8 @@ function Story(props: {
}}
subscriptions={zeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -137,6 +141,8 @@ function Story(props: {
team={team}
subscriptions={trialPlanZeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -145,6 +151,8 @@ function Story(props: {
team={team}
subscriptions={subsWith1Usage}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -153,6 +161,8 @@ function Story(props: {
team={team}
subscriptions={subsWith4Usage}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
</div>
@@ -28,11 +28,15 @@ export function PlanInfoCardUI(props: {
subscriptions: TeamSubscription[];
team: Team;
getTeam: () => Promise<Team>;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}) {
const { subscriptions, team } = props;
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
const validPlan = getValidTeamPlan(team);
const isActualFreePlan = team.billingPlan === "free";
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(false);
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(
openPlanSheetButtonByDefault,
);
const planSub = subscriptions.find(
(subscription) => subscription.type === "PLAN",
@@ -54,6 +58,7 @@ export function PlanInfoCardUI(props: {
isOpen={isPlanSheetOpen}
onOpenChange={setIsPlanSheetOpen}
getTeam={props.getTeam}
highlightPlan={props.highlightPlan}
/>
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
@@ -298,6 +303,7 @@ function ViewPlansSheet(props: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
getTeam: () => Promise<Team>;
highlightPlan: Team["billingPlan"] | undefined;
}) {
return (
<Sheet open={props.isOpen} onOpenChange={props.onOpenChange}>
@@ -309,6 +315,7 @@ function ViewPlansSheet(props: {
team={props.team}
trialPeriodEndedAt={props.trialPeriodEndedAt}
getTeam={props.getTeam}
highlightPlan={props.highlightPlan}
/>
</SheetContent>
</Sheet>
@@ -1,4 +1,4 @@
import { getTeamBySlug } from "@/api/team";
import { type Team, getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { Billing } from "components/settings/Account/Billing";
@@ -10,8 +10,13 @@ export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<{
showPlans?: string | string[];
highlight?: string | string[];
}>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const pagePath = `/team/${params.team_slug}/settings/billing`;
const [account, team, authToken] = await Promise.all([
@@ -41,6 +46,12 @@ export default async function Page(props: {
return (
<Billing
highlightPlan={
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
: undefined
}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
team={team}
subscriptions={subscriptions}
twAccount={account}
@@ -50,9 +50,13 @@ export function ProjectSidebarLayout(props: {
tracking: tracking("account-abstraction"),
},
{
label: "Universal Bridge",
href: `${layoutPath}/connect/universal-bridge`,
icon: PayIcon,
label: (
<span className="flex items-center gap-2">
Universal Bridge <Badge>New</Badge>
</span>
),
tracking: tracking("universal-bridge"),
},
{
@@ -1,5 +1,6 @@
"use client";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import {
@@ -7,9 +8,7 @@ import {
TrackedUnderlineLink,
} from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ArrowRightIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import { AAFooter } from "./AAFooterSection";
const TRACKING_CATEGORY = "smart-wallet";
@@ -100,27 +99,12 @@ function GasCreditAlert(props: {
teamSlug: string;
projectId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
`gas-credit-${props.projectId}`,
true,
false,
);
if (!isVisible) return null;
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">OP Gas Credit Program</h2>
<p className="text-muted-foreground text-sm">
<DismissibleAlert
localStorageId={`${props.projectId}-gas-credit-alert`}
title="OP Gas Credit Program"
description={
<>
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
@@ -130,22 +114,26 @@ function GasCreditAlert(props: {
>
Learn More
</TrackedUnderlineLink>
</p>
<div className="mt-4 flex items-center gap-4">
<Button asChild variant="outline" size="sm" className="bg-background">
<TrackedLinkTW
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
className="gap-2"
category={TRACKING_CATEGORY}
label="claim-credits"
<div className="mt-4 flex items-center gap-4">
<Button
asChild
variant="outline"
size="sm"
className="bg-background"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
</div>
<TrackedLinkTW
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
className="gap-2"
category={TRACKING_CATEGORY}
label="claim-credits"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</>
}
/>
);
}
@@ -32,6 +32,7 @@ export default async function Page(props: {
from: searchParams.from,
to: searchParams.to,
interval: searchParams.interval,
defaultRange: "last-30",
});
return (
@@ -67,114 +67,46 @@ function randomNotifications(count: number): NotificationMetadata[] {
export const AllUnread: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30),
isPending: false,
},
},
notifications: randomNotifications(10),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const Loading: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: true,
},
changelogs: {
notifications: [],
isPending: true,
},
},
notifications: [],
isPending: true,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: false,
},
changelogs: {
notifications: [],
isPending: false,
},
},
notifications: [],
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const AllRead: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
},
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const MixedNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoUnreadInboxNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(5).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
@@ -8,12 +8,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TabButtons } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { formatDistance } from "date-fns";
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
import { BellIcon, InboxIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { cleanupReadNotifications } from "./fetch-notifications";
export type NotificationMetadata = {
@@ -24,19 +24,7 @@ export type NotificationMetadata = {
createdAt: string;
};
type NotificationTabs = {
changelogs: {
notifications: NotificationMetadata[];
isPending: boolean;
};
inbox: {
notifications: NotificationMetadata[];
isPending: boolean;
};
};
export function NotificationButtonUI(props: {
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
@@ -45,22 +33,10 @@ export function NotificationButtonUI(props: {
queryFn: props.getInboxNotifications,
});
const changelogsQuery = useQuery({
queryKey: ["changelogs"],
queryFn: props.getChangelogs,
});
const [readNotifications, setReadNotifications] = useState<Set<string>>(
new Set(),
);
const changelogNotifications = useMemo(() => {
return (changelogsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [changelogsQuery.data, readNotifications]);
const inboxNotifications = useMemo(() => {
return (inboxNotificationsQuery.data || []).map((notification) => ({
...notification,
@@ -68,58 +44,30 @@ export function NotificationButtonUI(props: {
}));
}, [inboxNotificationsQuery.data, readNotifications]);
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
cleanupReadNotifications({
changelogs: changelogNotifications,
updates: inboxNotifications,
});
}
}, [changelogNotifications, inboxNotifications]);
const markAsRead = useCallback(
async (id: string) => {
setReadNotifications((prev) => new Set([...prev, id]));
await props.markNotificationAsRead(id);
await cleanupReadNotifications(inboxNotifications);
},
[props.markNotificationAsRead],
[props.markNotificationAsRead, inboxNotifications],
);
const notificationTabs: NotificationTabs = {
inbox: {
notifications: inboxNotifications,
isPending: inboxNotificationsQuery.isPending,
},
changelogs: {
notifications: changelogNotifications,
isPending: changelogsQuery.isPending,
},
};
return (
<NotificationButtonInner
notificationTabs={notificationTabs}
notifications={inboxNotifications}
isPending={inboxNotificationsQuery.isPending}
markNotificationAsRead={markAsRead}
/>
);
}
export function NotificationButtonInner(props: {
notificationTabs: NotificationTabs;
notifications: NotificationMetadata[];
isPending: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const hasUnreadInboxNotifications =
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
const unreadNotifications = {
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
changelogs: props.notificationTabs.changelogs.notifications.filter(
(x) => !x.isRead,
),
};
const unreadCount = props.notifications.filter((x) => !x.isRead).length;
return (
<Popover>
@@ -128,9 +76,16 @@ export function NotificationButtonInner(props: {
className="relative size-10 rounded-full bg-background p-0"
variant="outline"
>
<BellIcon className="size-4 text-muted-foreground" />
{hasUnreadInboxNotifications && (
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
<BellIcon
className={cn(
"size-4 text-muted-foreground",
unreadCount > 0 && "text-foreground",
)}
/>
{unreadCount > 0 && (
<div className="-top-1.5 -right-1.5 absolute flex items-center justify-center rounded-full bg-[#e1002d] px-1 py-0.5 text-white text-xs">
{unreadCount > 9 ? "9+" : unreadCount}
</div>
)}
</Button>
</PopoverTrigger>
@@ -140,64 +95,28 @@ export function NotificationButtonInner(props: {
sideOffset={10}
side="bottom"
>
<TabButtons
tabClassName="!text-sm"
tabContainerClassName="px-2 pt-1.5"
tabs={[
{
isActive: tab === "inbox",
name: (
<BadgeTab
name="Inbox"
unreadCount={unreadNotifications.inbox.length}
/>
),
onClick: () => setTab("inbox"),
},
{
isActive: tab === "changelogs",
name: (
<BadgeTab
name="Changelog"
unreadCount={unreadNotifications.changelogs.length}
/>
),
onClick: () => setTab("changelogs"),
},
]}
<div className="flex gap-2 border-b px-4 py-4">
<span className="font-semibold">Notifications</span>
<BadgeTab unreadCount={unreadCount} />
</div>
<NotificationContent
notifications={props.notifications}
isPending={props.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
{tab === "inbox" && (
<NotificationContent
notifications={props.notificationTabs.inbox.notifications}
isPending={props.notificationTabs.inbox.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
{tab === "changelogs" && (
<NotificationContent
notifications={props.notificationTabs.changelogs.notifications}
isPending={props.notificationTabs.changelogs.isPending}
icon={ActivityIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
</PopoverContent>
</Popover>
);
}
function BadgeTab(props: {
name: string;
unreadCount: number;
}) {
return (
<span className="flex items-center gap-2">
{props.name}
{props.unreadCount > 0 && (
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
<span className="rounded-xl border bg-card px-1.5 py-0.5 text-xs">
{props.unreadCount}
</span>
)}
@@ -30,18 +30,13 @@ async function fetchGhostPosts(tag: string) {
// Clean up the indexedDB storage of read notification IDs
// This is to prevent the storage from growing indefinitely
// Remove notification IDs from storage that are no longer shown to the user
export async function cleanupReadNotifications(params: {
changelogs: NotificationMetadata[];
updates: NotificationMetadata[];
}) {
const { changelogs, updates } = params;
export async function cleanupReadNotifications(
notifications: NotificationMetadata[],
) {
const readIds = await getReadNotificationIds();
// Get all current notification IDs
const currentIds = new Set([
...changelogs.map((item) => item.id),
...updates.map((item) => item.id),
]);
const currentIds = new Set([...notifications.map((item) => item.id)]);
// remove ids that are no longer being displayed
const usedIds = new Set(
@@ -59,23 +54,6 @@ export async function markNotificationAsRead(id: string) {
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
}
export async function getChangelogNotifications(): Promise<
NotificationMetadata[]
> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("changelog"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("update"),
@@ -147,7 +147,6 @@ function Variant(props: {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
const getChangelogsStub = () => Promise.resolve([]);
const getInboxNotificationsStub = () => Promise.resolve([]);
const markNotificationAsReadStub = () => Promise.resolve();
@@ -166,7 +165,6 @@ function Variant(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={storybookThirdwebClient}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
@@ -31,7 +31,6 @@ export type TeamHeaderCompProps = {
createProject: (team: Team) => void;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
@@ -117,7 +116,6 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -202,7 +200,6 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -11,7 +11,6 @@ import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../NotificationButton/fetch-notifications";
@@ -64,7 +63,6 @@ export function TeamHeaderLoggedIn(props: {
},
client: props.client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
@@ -152,6 +152,24 @@ export async function promptNebula(params: {
break;
}
case "error": {
const data = JSON.parse(event.data) as {
code: number;
error: {
message: string;
};
};
params.handleStream({
event: "error",
data: {
code: data.code,
errorMessage: data.error.message,
},
});
break;
}
case "init": {
const data = JSON.parse(event.data);
params.handleStream({
@@ -242,6 +260,13 @@ type ChatStreamedResponse =
chain_ids: number[];
networks: NebulaContext["networks"];
};
}
| {
event: "error";
data: {
code: number;
errorMessage: string;
};
};
type ChatStreamedEvent =
@@ -269,4 +294,8 @@ type ChatStreamedEvent =
| {
event: "context";
data: string;
}
| {
event: "error";
data: string;
};
@@ -6,11 +6,8 @@ type SessionContextFilter = {
type NebulaUserMessageContentItem =
| {
type: "image";
image_url: string;
}
| {
type: "image";
b64: string;
image_url: string | null;
b64: string | null;
}
| {
type: "text";
@@ -82,7 +82,11 @@ export function ChatBar(props: {
if (images.length > 0) {
for (const image of images) {
if (image.b64) {
userMessage.content.push({ type: "image", b64: image.b64 });
userMessage.content.push({
type: "image",
b64: image.b64,
image_url: null,
});
}
}
}
@@ -250,6 +254,7 @@ export function ChatBar(props: {
<ImageUploadButton
multiple
value={undefined}
accept="image/jpeg,image/png,image/webp"
onChange={(files) => {
const totalFiles = files.length + images.length;
@@ -479,7 +484,19 @@ function WalletSelector(props: {
<AccountBlobbie className="size-3 rounded-full" />
}
/>
{shortenAddress(props.selectedAddress)}
<AccountName
className="text-xs"
loadingComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
fallbackComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
/>
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
</AccountProvider>
</Button>
@@ -528,12 +545,12 @@ function WalletSelector(props: {
<AccountName
className="text-sm"
loadingComponent={
<span className="font-mono text-sm">
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
fallbackComponent={
<span className="font-mono text-sm">
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
@@ -9,7 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { ArrowRightIcon } from "lucide-react";
import { ArrowRightIcon, MessageSquareXIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -263,8 +263,11 @@ export function ChatPageContent(props: {
const showEmptyState =
!userHasSubmittedMessage &&
messages.length === 0 &&
!props.session &&
!props.initialParams?.q;
const sessionWithNoMessages = props.session && messages.length === 0;
const connectedWalletsMeta: WalletMeta[] = connectedWallets.map((x) => ({
address: x.getAccount()?.address || "",
walletId: x.id,
@@ -318,17 +321,35 @@ export function ChatPageContent(props: {
</div>
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
sendMessage={handleSendMessage}
/>
{sessionWithNoMessages && (
<div className="container flex max-h-full max-w-[800px] flex-1 flex-col justify-center py-8">
<div className="flex flex-col items-center justify-center p-4">
<div className="mb-5 rounded-full border bg-card p-3">
<MessageSquareXIcon className="size-6 text-muted-foreground" />
</div>
<p className="mb-1 text-center text-foreground">
No messages found
</p>
<p className="text-balance text-center text-muted-foreground text-sm">
This session was aborted before receiving any messages
</p>
</div>
</div>
)}
{messages.length > 0 && (
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
sendMessage={handleSendMessage}
/>
)}
<div className="container max-w-[800px]">
<ChatBar
@@ -578,6 +599,20 @@ export async function handleNebulaPrompt(params: {
});
return;
}
case "error": {
hasReceivedResponse = true;
setMessages((prev) => {
return [
...prev,
{
text: res.data.errorMessage,
type: "error",
},
];
});
return;
}
}
},
context: contextFilters,
@@ -87,7 +87,7 @@ export function ChatSidebar(props: {
return (
<ChatSidebarLink
sessionId={session.id}
title={session.title || session.id}
title={session.title || "Untitled Chat"}
key={session.id}
authToken={props.authToken}
/>
@@ -202,11 +202,11 @@ function RenderMessage(props: {
<NebulaImage
type="submitted"
url={
"b64" in msg
typeof msg.b64 === "string"
? msg.b64.startsWith("data:image")
? msg.b64
: `data:image/png;base64,${msg.b64}`
: msg.image_url
: (msg.image_url ?? "")
}
client={props.client}
/>
@@ -55,7 +55,9 @@ export function NebulaImage(
height={props.type === "response" ? props.height : undefined}
src={src}
className="w-full rounded-lg border hover:border-active-border"
skeleton={<div className="animate-skeleton bg-muted" />}
skeleton={
<div className="min-h-[300px] animate-skeleton bg-muted" />
}
/>
</DialogTrigger>
@@ -7,7 +7,19 @@ export function useSessionsWithLocalOverrides(
) {
const newAddedSessions = useStore(newSessionsStore);
const deletedSessions = useStore(deletedSessionsStore);
return [...newAddedSessions, ..._sessions].filter((s) => {
const mergedSessions = [..._sessions];
for (const session of newAddedSessions) {
// if adding a new session that has same id as existing session, update the existing session
const index = mergedSessions.findIndex((s) => s.id === session.id);
if (index !== -1) {
mergedSessions[index] = session;
} else {
mergedSessions.push(session);
}
}
return mergedSessions.filter((s) => {
return !deletedSessions.some((d) => d === s.id);
});
}
@@ -80,7 +80,7 @@ export function DateRangeSelector(props: {
export function getLastNDaysRange(id: DurationId) {
const durationInfo = durationPresets.find((preset) => preset.id === id);
if (!durationInfo) {
throw new Error("Invalid duration id");
throw new Error(`Invalid duration id: ${id}`);
}
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
@@ -179,6 +179,7 @@ export const InAppWalletSettingsUI: React.FC<
!!config.applicationImageUrl?.length || !!config.applicationName?.length;
const authRequiredPlan = "accelerate";
const brandingRequiredPlan = "starter";
// accelerate or higher plan required
const canEditSmsCountries =
@@ -275,7 +276,7 @@ export const InAppWalletSettingsUI: React.FC<
form={form}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
requiredPlan={authRequiredPlan}
requiredPlan={brandingRequiredPlan}
/>
<NativeAppsFieldset form={form} />
@@ -1,7 +1,11 @@
"use client";
import { normalizeTimeISOString } from "@/lib/time";
import { DateRangeSelector } from "components/analytics/date-range-selector";
import { useQuery } from "@tanstack/react-query";
import {
DateRangeSelector,
type DurationId,
} from "components/analytics/date-range-selector";
import { IntervalSelector } from "components/analytics/interval-selector";
import { getUniversalBridgeFiltersFromSearchParams } from "lib/time";
import {
@@ -9,28 +13,91 @@ import {
useSetResponsiveSearchParams,
} from "responsive-rsc";
const STORAGE_KEY = "thirdweb:ub-analytics-range";
type SavedRange = {
rangeType: string;
interval: "day" | "week";
};
type SearchParams = {
from?: string;
to?: string;
interval?: "day" | "week";
};
export function PayAnalyticsFilter() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
// Load saved range from localStorage using useQuery
useQuery<SavedRange | null>({
queryKey: [
"savedRange",
responsiveSearchParams.from,
responsiveSearchParams.to,
],
queryFn: () => {
const savedRangeString = localStorage.getItem(STORAGE_KEY);
if (savedRangeString) {
try {
const savedRange = JSON.parse(savedRangeString) as SavedRange;
// Get the current range based on the saved range type
const { range } = getUniversalBridgeFiltersFromSearchParams({
from: undefined,
to: undefined,
interval: savedRange.interval,
defaultRange: (savedRange.rangeType || "last-30") as DurationId,
});
setResponsiveSearchParams((v) => ({
...v,
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
interval: savedRange.interval,
}));
} catch (e) {
localStorage.removeItem(STORAGE_KEY);
console.error("Failed to parse saved range:", e);
}
}
return null;
},
enabled: !responsiveSearchParams.from && !responsiveSearchParams.to,
});
const { range, interval } = getUniversalBridgeFiltersFromSearchParams({
from: responsiveSearchParams.from,
to: responsiveSearchParams.to,
interval: responsiveSearchParams.interval,
defaultRange: "last-30",
});
const saveToLocalStorage = (params: {
rangeType: string;
interval: "day" | "week";
}) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(params));
};
return (
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
// Save to localStorage
saveToLocalStorage({
rangeType: newRange.type || "last-30",
interval: newParams.interval || "day",
});
return newParams;
});
}}
/>
@@ -38,11 +105,17 @@ export function PayAnalyticsFilter() {
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
interval: newInterval,
};
// Save to localStorage
saveToLocalStorage({
rangeType: range.type || "last-30",
interval: newInterval,
});
return newParams;
});
}}
/>
@@ -1,9 +1,14 @@
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
import { TeamPlanBadge } from "../../../../app/(app)/components/TeamPlanBadge";
import { ExternalLinkIcon } from "lucide-react";
import {
TeamPlanBadge,
getTeamPlanBadgeLabel,
} from "../../../../app/(app)/components/TeamPlanBadge";
import { planToTierRecordForGating } from "./planToTierRecord";
type SwitchProps = React.ComponentProps<typeof Switch>;
@@ -26,26 +31,40 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
return (
<ToolTipLabel
hoverable
contentClassName="max-w-[300px]"
label={
isUpgradeRequired ? (
<span>
To access this feature, <br /> Upgrade to the{" "}
<TrackedLinkTW
target="_blank"
href={`/team/${props.teamSlug}/~/settings/billing`}
category="advancedFeature"
label={props.trackingLabel}
className="text-link-foreground capitalize hover:text-foreground"
>
{props.requiredPlan} plan
</TrackedLinkTW>
</span>
<div className="w-full min-w-[280px]">
<h3 className="font-medium text-base">
<span className="capitalize">
{getTeamPlanBadgeLabel(props.requiredPlan)}+
</span>{" "}
plan required
</h3>
<p className="mb-3.5 text-muted-foreground">
Upgrade your plan to use this feature
</p>
<div className="flex w-full flex-col gap-2">
<Button asChild size="sm" className="justify-start gap-2">
<TrackedLinkTW
target="_blank"
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
category="advancedFeature"
label="checkout"
>
{`Upgrade to ${getTeamPlanBadgeLabel(props.requiredPlan)} plan`}
<ExternalLinkIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
) : undefined
}
>
<div className="inline-flex items-center gap-2">
{isUpgradeRequired && <TeamPlanBadge plan={props.requiredPlan} />}
{isUpgradeRequired && (
<TeamPlanBadge plan={props.requiredPlan} postfix="+" />
)}
<Switch
{...props.switchProps}
checked={props.switchProps?.checked && !isUpgradeRequired}
@@ -29,6 +29,7 @@ interface BillingPricingProps {
team: Team;
trialPeriodEndedAt: string | undefined;
getTeam: () => Promise<Team>;
highlightPlan: Team["billingPlan"] | undefined;
}
type CtaLink =
@@ -49,6 +50,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
team,
trialPeriodEndedAt,
getTeam,
highlightPlan,
}) => {
const validTeamPlan = getValidTeamPlan(team);
const [isPending, startTransition] = useTransition();
@@ -65,17 +67,28 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
const isCurrentPlanScheduledToCancel = team.planCancellationDate !== null;
const highlightGrowthPlan =
!isCurrentPlanScheduledToCancel &&
(validTeamPlan === "free" ||
validTeamPlan === "starter" ||
validTeamPlan === "growth_legacy");
highlightPlan === "growth" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
(validTeamPlan === "free" ||
validTeamPlan === "starter" ||
validTeamPlan === "growth_legacy"));
const highlightStarterPlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "starter_legacy";
highlightPlan === "starter" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "starter_legacy");
const highlightAcceleratePlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "growth";
highlightPlan === "accelerate" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "growth");
const highlightScalePlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "accelerate";
highlightPlan === "scale" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "accelerate");
return (
<div>
@@ -13,6 +13,8 @@ interface BillingProps {
subscriptions: TeamSubscription[];
twAccount: Account;
client: ThirdwebClient;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}
export const Billing: React.FC<BillingProps> = ({
@@ -20,6 +22,8 @@ export const Billing: React.FC<BillingProps> = ({
subscriptions,
twAccount,
client,
openPlanSheetButtonByDefault,
highlightPlan,
}) => {
const validPayment =
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
@@ -27,7 +31,12 @@ export const Billing: React.FC<BillingProps> = ({
return (
<div className="flex flex-col gap-12">
<div>
<PlanInfoCardClient team={team} subscriptions={subscriptions} />
<PlanInfoCardClient
team={team}
subscriptions={subscriptions}
openPlanSheetButtonByDefault={openPlanSheetButtonByDefault}
highlightPlan={highlightPlan}
/>
</div>
<CreditsInfoCard
+3 -1
View File
@@ -1,4 +1,5 @@
import { getFiltersFromSearchParams } from "@/lib/time";
import type { DurationId } from "../components/analytics/date-range-selector";
export function getNebulaFiltersFromSearchParams(params: {
from: string | undefined | string[];
@@ -17,11 +18,12 @@ export function getUniversalBridgeFiltersFromSearchParams(params: {
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
defaultRange: DurationId;
}) {
return getFiltersFromSearchParams({
from: params.from,
to: params.to,
interval: params.interval,
defaultRange: "last-120",
defaultRange: params.defaultRange,
});
}
+1 -1
View File
@@ -81,7 +81,7 @@ const walletActions: SidebarLink = (() => {
href: `${parentSlug}/switchnetwork`,
},
{
name: "SignAuthorization (Experimental)",
name: "SignAuthorization",
href: `${parentSlug}/signauthorization`,
},
],
@@ -17,6 +17,7 @@ The design is akin to letting us know what your intent is.
- Buy: "I want to buy x USDC on y Chain using z Token"
- Sell: "I want to sell x USDC on y Chain for z Token"
- Transfer: "Just transfer all my money to vitalik"
- Onramp: "I want to buy x USDC on y Chain by paying with card"
We will return the transactions needed to achieve whatever you desire.
You may then handle execution yourself or use our extensions.
@@ -127,4 +128,43 @@ Console.WriteLine($"Sell hashes: {JsonConvert.SerializeObject(sellHashes, Format
var transferResult = await bridge.Execute(myWallet, preparedTransfer);
var transferHashes = transferResult.Select(receipt => receipt.TransactionHash).ToList();
Console.WriteLine($"Transfer hashes: {JsonConvert.SerializeObject(transferHashes, Formatting.Indented)}");
```
## Onramping with Fiat
The onramp flow will return a link for you to display/open as you please. You may poll the status of that onramp by its ID.
In some cases, you may receive an additional set of onchain steps required to get to your destination token post on-ramp, in such cases, you may use our extension `IsSwapRequiredPostOnramp` to check, and if a swap is indeed required, you may use our `Execute` extensions to execute the transactions, or manually execute them by going through each `Step`.
```csharp
// Onramp - Get a quote for buying crypto with Fiat
var preparedOnramp = await bridge.Onramp_Prepare(
onramp: OnrampProvider.Coinbase,
chainId: 8453,
tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
amount: "10000000",
receiver: await myWallet.GetAddress()
);
Console.WriteLine($"Onramp link: {preparedOnramp.Link}");
Console.WriteLine($"Full onramp quote and steps data: {JsonConvert.SerializeObject(preparedOnramp, Formatting.Indented)}");
while (true)
{
var onrampStatus = await bridge.Onramp_Status(id: preparedOnramp.Id);
Console.WriteLine($"Full Onramp Status: {JsonConvert.SerializeObject(onrampStatus, Formatting.Indented)}");
if (onrampStatus.StatusType is StatusType.COMPLETED or StatusType.FAILED)
{
break;
}
await ThirdwebTask.Delay(5000);
}
if (preparedOnramp.IsSwapRequiredPostOnramp())
{
// Execute additional steps that are required post-onramp to get to your token, manually or via the Execute extension
var receipts = await bridge.Execute(myWallet, preparedOnramp);
Console.WriteLine($"Onramp receipts: {JsonConvert.SerializeObject(receipts, Formatting.Indented)}");
}
else
{
Console.WriteLine("No additional steps required post-onramp, you can use the tokens directly!");
}
```
@@ -5,96 +5,37 @@ export const metadata = createMetadata({
description: "Sign an EIP-7702 Payload to Set Code to your EOA.",
});
# [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) Integration (Experimental)
# [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) Integration (Low-level API)
Integrates `authorizationList` for any transactions.
This EIP essentially allows you to set code to an EOA, unlocking a world of possibilities to enhance their functionality.
The best way to understand it outside of reading the EIP is looking at the example below; to preface it: we sign an authorization using the wallet we want to set code to. Another wallet sends a transaction with said authorization passed in, essentially activating it. The authority wallet now has code set to it pointing to an (insecure) [Delegation](https://thirdweb.com/odyssey-911867/0x654F42b74885EE6803F403f077bc0409f1066c58) contract in this case, which allows any wallet to execute any call through it on behalf of the authority. In this example, we call the wallet executing both the authorization and the claim transaction afterwards, the exectuor.
An authority may execute its own authorization, the only difference is internal whereby the authorization nonce is incremented by 1.
You almost never need to use this API directly, but it's available for low-level access. Use higher level [Account Abstraction](/dotnet/wallets/providers/account-abstraction) APIs instead.
## Usage
```csharp
// Chain and contract addresses
var chainWith7702 = 911867;
var erc20ContractAddress = "0xAA462a5BE0fc5214507FDB4fB2474a7d5c69065b"; // Fake ERC20
var delegationContractAddress = "0x654F42b74885EE6803F403f077bc0409f1066c58"; // BatchCallDelegation
// Initialize contracts normally
var erc20Contract = await ThirdwebContract.Create(client: client, address: erc20ContractAddress, chain: chainWith7702);
var delegationContract = await ThirdwebContract.Create(client: client, address: delegationContractAddress, chain: chainWith7702);
// Initialize a (to-be) 7702 EOA
var eoaWallet = await PrivateKeyWallet.Generate(client);
var eoaWalletAddress = await eoaWallet.GetAddress();
Console.WriteLine($"EOA address: {eoaWalletAddress}");
// Initialize another wallet, the "executor" that will hit the eoa's (to-be) execute function
var executorWallet = await PrivateKeyWallet.Generate(client);
var executorWalletAddress = await executorWallet.GetAddress();
Console.WriteLine($"Executor address: {executorWalletAddress}");
// Fund the executor wallet
var fundingWallet = await PrivateKeyWallet.Create(client, privateKey);
var fundingHash = (await fundingWallet.Transfer(chainWith7702, executorWalletAddress, BigInteger.Parse("0.001".ToWei()))).TransactionHash;
Console.WriteLine($"Funded Executor Wallet: {fundingHash}");
// Sign the authorization to make it point to the delegation contract
var authorization = await eoaWallet.SignAuthorization(chainId: chainWith7702, contractAddress: delegationContractAddress, willSelfExecute: false);
Console.WriteLine($"Authorization: {JsonConvert.SerializeObject(authorization, Formatting.Indented)}");
// Execute the delegation
var tx = await ThirdwebTransaction.Create(executorWallet, new ThirdwebTransactionInput(chainId: chainWith7702, to: executorWalletAddress, authorization: authorization));
var hash = (await ThirdwebTransaction.SendAndWaitForTransactionReceipt(tx)).TransactionHash;
Console.WriteLine($"Authorization execution transaction hash: {hash}");
// Prove that code has been deployed to the eoa
var rpc = ThirdwebRPC.GetRpcInstance(client, chainWith7702);
var code = await rpc.SendRequestAsync<string>("eth_getCode", eoaWalletAddress, "latest");
Console.WriteLine($"EOA code: {code}");
// Log erc20 balance of executor before the claim
var executorBalanceBefore = await erc20Contract.ERC20_BalanceOf(executorWalletAddress);
Console.WriteLine($"Executor balance before: {executorBalanceBefore}");
// Prepare the claim call
var claimCallData = erc20Contract.CreateCallData(
"claim",
new object[]
{
executorWalletAddress, // receiver
100, // quantity
Constants.NATIVE_TOKEN_ADDRESS, // currency
0, // pricePerToken
new object[] { Array.Empty<byte>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }, // allowlistProof
Array.Empty<byte>() // data
}
);
// Embed the claim call in the execute call
var executeCallData = delegationContract.CreateCallData(
method: "execute",
parameters: new object[]
{
new List<Thirdweb.Console.Call>
{
new()
{
Data = claimCallData.HexToBytes(),
To = erc20ContractAddress,
Value = BigInteger.Zero
}
}
}
);
// Execute from the executor wallet targeting the eoa which is pointing to the delegation contract
var tx2 = await ThirdwebTransaction.Create(executorWallet, new ThirdwebTransactionInput(chainId: chainWith7702, to: eoaWalletAddress, data: executeCallData));
var hash2 = (await ThirdwebTransaction.SendAndWaitForTransactionReceipt(tx2)).TransactionHash;
Console.WriteLine($"Token claim transaction hash: {hash2}");
// Log erc20 balance of executor after the claim
var executorBalanceAfter = await erc20Contract.ERC20_BalanceOf(executorWalletAddress);
Console.WriteLine($"Executor balance after: {executorBalanceAfter}");
var authorization = await eoaWallet.SignAuthorization(chainId: chainWith7702Support, contractAddress: delegationContractAddress, willSelfExecute: false);
```
_Note that for the time being this only works on 7702-enabled chains such as [Odyssey](https://thirdweb.com/odyssey-911867) and the feature has only been integrated with `PrivateKeyWallet`._
<Details summary="Parameters">
### chainId (required)
Your chain ID as a `BigInteger` value.
### contractAddress (required)
The address of the contract you want to authorize. Your EOA will essentially become a smart contract if this authorization is executed.
### willSelfExecute (required)
Whether the authorization will be executed by the EOA itself or by a third-party. It will affect the nonce of the authorization.
</Details>
<Details summary="Return Value">
### string
The method returns an `EIP7702Authorization` object that can be passed to our `ThirdwebTransaction`/`ThirdwebTransactionInput` related APIs and broadcast as such.
</Details>
@@ -6,7 +6,49 @@ export const metadata = createMetadata({
"Instantiate a SmartWallet for enhanced user interactions with blockchain applications.",
});
# SmartWallet.Create
# Native Account Abstraction (via EIP-7702 Smart EOAs)
With the recent Ethereum upgrade Pectra, EIP-7702 allows you to upgrade your EOA and get SmartWallet-like functionality with:
- Much cheaper gas costs, batching functionality
- No account separation - your wallet address does not change, not even on zksync chains (once they implement EIP-7702)
- Much faster execution, with the option of paying for gas yourself or having thirdweb manage gas sponsorship, similar to SmartWallet.
The API is also drastically simplified!
### ExecutionMode.EIP7702Sponsored
Upgrade to an EIP7702 smart account, unlocking all functionality of 4337 without the downsides, and thirdweb handles the execution and gas sponsorship for you!
```csharp
var smartEoa = await InAppWallet.Create(
client: thirdwebClient,
authProvider: AuthProvider.Google,
executionMode: ExecutionMode.EIP7702Sponsored
);
```
### ExecutionMode.EIP7702
Upgrade to an EIP7702 smart account, unlocking all functionality of 4337 without the downsides, but sponsoring gas yourself.
```csharp
var smartEoa = await InAppWallet.Create(
client: thirdwebClient,
authProvider: AuthProvider.Google,
executionMode: ExecutionMode.EIP7702
);
```
### ExecutionMode.EOA
"Normal" EOA Execution, no smart account functionality
```csharp
var basicEoa = await InAppWallet.Create(
client: thirdwebClient,
authProvider: AuthProvider.Google,
// does not need to be explicitly passed, is the default but we're showing it here
executionMode: ExecutionMode.EOA
);
```
_When using EIP-7702 execution modes - if not already delegated to a smart account - an EIP-7702 authorization will be signed and bundled with your first transaction, similar to how 4337 works with initcode, but without the large gas costs, slower execution and chain specific requirements._
# SmartWallet (via EIP-4337 Bundlers)
Instantiate a `SmartWallet` to enable advanced blockchain interactions, including gasless transactions through account abstraction. This wallet type is especially useful for creating a user-friendly experience in decentralized applications.
@@ -0,0 +1,243 @@
# Migrating from Engine v2 to v3
This guide outlines the key changes required to migrate your applications from Engine v2 to Engine v3. We'll cover common operations and highlight the differences in API requests and authentication.
## Writing to a contract
Migrating your contract write operations from v2 to v3 involves changes to the endpoint, authentication mechanism, and request payload structure.
#### OLD (v2)
The v2 approach used your dedicated engine url, with a specific endpoint for each chain and contract and backend wallet address in the header.
```bash
curl -X POST "<engine_url>/contract/<chain>/<contract_address>/write" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-H "x-backend-wallet-address: <backend_wallet_address>" \
-d '{
"functionName": "function transferFrom(address from, address to, uint256 amount)",
"args": [
"0x1946267d81Fb8aDeeEa28e6B98bcD446c8248473",
"0x3EcDBF3B911d0e9052b64850693888b008e18373",
"0"
]
}'
```
#### NEW (v3)
The v3 approach uses a generic POST endpoint, with authentication via secret key and vault access token. Contract, chain, and execution details are now part of a structured JSON payload.
```bash
curl -X POST "https://engine.thirdweb.com/v1/write/contract" \
-H "Content-Type: application/json" \
-H "x-secret-key: <your-project-secret-key>" \
-H "x-vault-access-token: <your-vault-access-token>" \
-d '{
"executionOptions": {
"from": "<your-server-wallet-address>",
"chainId": "84532"
},
"params": [
{
"contractAddress": "0x...",
"method": "function transferFrom(address from, address to, uint256 amount)",
"params": [
"0x1946267d81Fb8aDeeEa28e6B98bcD446c8248473",
"0x3EcDBF3B911d0e9052b64850693888b008e18373",
"0"
]
}
]
}'
```
#### Key Differences:
* **API Endpoint:** The endpoint has changed from a specific path per chain and contract (`<engine_url>/contract/<chain>/<contract_address>/write`) to a general endpoint (`https://engine.thirdweb.com/v1/write/contract`).
* **Authentication:**
* V2 used an `Authorization: Bearer <access_token>` header for general API access and an `x-backend-wallet-address` header to specify the sender.
* V3 uses an `x-secret-key` header (your project's secret key, found in your thirdweb dashboard) for authentication. Your backend wallet is managed by thirdweb Vault, an `x-vault-access-token` header is required for all write operations.
* **Request Structure:**
* **Sender & Chain:** In V2, the sender wallet was a header, and the chain was part of the URL. In V3, these are specified in the request body within an `executionOptions` object: `from` for the sender wallet address and `chainId` for the target chain.
* **Contract Call:** V2 took `functionName` and `args` directly in the body. V3 uses a `params` array in the request body. Each element in this array is an object defining the target `contractAddress`, the partial or full `method` signature (e.g., `"function mintTo(address to, uint256 amount)"`), and the `params` (arguments) for that method.
* **Function Specification:** While V3 supports using just the method name (e.g., "transferFrom") in the `method` field, providing the full function signature (e.g., "function transferFrom(address from, address to, uint256 amount)") is highly recommended for optimal performance as it avoids an ABI lookup. V2 only required the `functionName`.
---
## Check the status of a transaction
Querying transaction status has also been updated, moving from a direct ID lookup via GET to a POST request with a filter-based search.
#### OLD (v2)
In v2, transaction status was fetched using a GET request with the `queue_id` in the URL.
```bash
curl -X GET "<engine_url>/transaction/status/<queue_id>" \
-H "Authorization: Bearer <access_token>"
```
#### NEW (v3)
V3 uses a search endpoint with a POST request, where transaction identifiers are passed in the request body using filters.
```bash
curl --location 'https://engine.thirdweb.com/v1/transactions/search' \
--header 'x-secret-key: <your-project-secret-key>' \
--header 'Content-Type: application/json' \
--data '{
"filters": [
{
"field": "id",
"values": [
"750cb3eb-d297-4d8e-8d29-76dea99ba294" // transaction id
],
"operation": "OR"
}
]
}'
```
#### Key Differences:
* **API Endpoint & Method:** The V2 `GET` endpoint (`<engine_url>/transaction/status/<queue_id>`) is replaced by a V3 `POST` endpoint (`https://engine.thirdweb.com/v1/transactions/search`).
* **Authentication:**
* V2 used an `Authorization: Bearer <access_token>` header.
* V3 uses an `x-secret-key` header (your project's secret key).
* **Querying Transaction Status:**
* In V2, the `queue_id` was passed directly in the URL path.
* In V3, you send a `POST` request with a JSON body containing a `filters` array. To find a specific transaction, you filter by its `id` (the equivalent of V2's `queue_id`). This filter-based approach is more flexible for querying transactions based on various criteria.
---
## Reading from a contract
Reading data from a smart contract has also been updated in v3, shifting from URL-based parameters to a structured JSON payload over POST.
#### OLD (v2)
In v2, contract reads were GET requests. The chain, contract address, function name, and arguments were specified in the URL path and query parameters.
```bash
curl -X GET "<engine_url>/contract/<chain>/<contract_address>/read?functionName=balanceOf&args=0x3EcDBF3B911d0e9052b64850693888b008e18373" \
-H "Authorization: Bearer <access_token>"
```
#### NEW (v3)
V3 uses a POST request to a dedicated read endpoint. All parameters, including chain, contract details, method signature, and arguments, are provided in the JSON body.
```bash
curl https://engine.thirdweb.com/v1/read/contract \
--request POST \
--header 'Content-Type: application/json' \
--header 'x-secret-key: <your-project-secret-key>' \
--data '{
"params": [
{
"contractAddress": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"method": "function balanceOf(address account) returns (uint256)",
"params": [
"0xeb0effdfb4dc5b3d5d3ac6ce29f3ed213e95d675"
]
}
],
"readOptions": {
"chainId": "84532"
}
}'
```
#### Key Differences:
* **API Endpoint & Method:**
* V2 used a `GET` request to `<engine_url>/contract/<chain>/<contract_address>/read` with function details as query parameters.
* V3 uses a `POST` request to the generic endpoint `https://engine.thirdweb.com/v1/read/contract`.
* **Authentication:**
* V2 used an `Authorization: Bearer <access_token>` header.
* V3 uses an `x-secret-key: <your-project-secret-key>` header.
* **Request Structure:**
* In V2, the chain and contract address were part of the URL path, and the function name (`functionName`) and its arguments (`args`) were URL query parameters.
* In V3, the request body is a JSON object. It includes a `params` array (where each object specifies `contractAddress`, the partial or full `method` signature, and its `params`) and a `readOptions` object to specify the `chainId`.
* **Function Specification:** Similar to contract writes, while V3 supports using just the method name (e.g., "balanceOf") in the `method` field, providing the full function signature (e.g., "function balanceOf(address account) returns (uint256)") is highly recommended for optimal performance as it avoids an ABI lookup. V2 primarily used just the function name in the `functionName` query parameter.
---
## Extension functions
Engine v2 has a large number of endpoints to do specific contract calls, like mintTo, claimTo, etc.
Engine v3 does not have these endpoints yet, but they are coming soon. In the meantime, if your backend is written in typescript, we recommend using the [thirdweb SDK](/references/typescript/v5/functions#extensions) to do these operations:
##### OLD (v2)
```ts
const response = await fetch(
"<engine_url>/contract/<chain_id>/<nft_contract_address>/erc1155/mint-to",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer <thirdweb_secret_key>",
"x-backend-wallet-address": "<backend_wallet_address>",
},
body: JSON.stringify({
receiver: "0x...",
metadataWithSupply: {
metadata: {
name: "Acme Inc. Superfan",
description: "Created with thirdweb Engine",
image:
"ipfs://QmciR3WLJsf2BgzTSjbG5zCxsrEQ8PqsHK7JWGWsDSNo46/nft.png",
},
supply: "1",
},
}),
},
);
const data = await response.json();
const queueId = data.queueId;
```
#### NEW (v3) - using the thirdweb SDK
```ts
// Create a thirdweb client
const client = createThirdwebClient({
secretKey: "<your-project-secret-key>",
});
// Create a server wallet
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// instead of a dedicated endpoint, you can use the thirdweb SDK to do the operation
const transaction = mintTo({
contract: getContract({
client,
address: "<nft_contract_address>", // Address of the ERC1155 token contract
chain: baseSepolia, // Chain of the ERC1155 token contract
}),
to: "0x...", // The address of the user to mint to
supply: 1n, // The quantity of NFTs to mint
nft: {
name: "Acme Inc. Superfan",
description: "Created with thirdweb Engine",
image:
"ipfs://QmciR3WLJsf2BgzTSjbG5zCxsrEQ8PqsHK7JWGWsDSNo46/nft.png",
},
});
// Enqueue the transaction via Engine
const { transactionId } =
await serverWallet.enqueueTransaction({
transaction,
});
```
The entire thirdweb SDK is available to use with engine v3 in this manner using the [`Engine` namespace](/references/typescript/v5/functions#engine). Check the [thirdweb SDK docs](/references/typescript/v5/functions#extensions) for all available functions.
@@ -67,6 +67,11 @@ export const sidebar: SideBar = {
href: "/vault/security",
icon: <ShieldQuestionIcon />,
},
{
name: "Migrate from v2",
href: `${engineV3Slug}/migrate`,
icon: <WrenchIcon />,
},
{
name: "Troubleshoot",
href: `${engineV3Slug}/troubleshoot`,
@@ -7,7 +7,31 @@ export const metadata = createMetadata({
"Instantiate a SmartWallet to sign transactions and messages.",
});
# SmartWallet
# Native Account Abstraction (via EIP-7702 Smart EOAs)
Native Account Abstraction is a system that allows you to set code to an EOA, unlocking a world of possibilities to enhance their functionality. It is available since the Pectra upgrade on various chains.
Enabling it is as simple as creating an `InAppWallet` and passing the `ExecutionMode.EIP7702Sponsored` flag during creation.
```csharp
// Turn your boring EOAs into Smart EOAs!
var smartIaw = await ConnectWallet(
new WalletOptions(
provider: WalletProvider.InAppWallet,
chainId: 11155111, // Sepolia supports EIP-7702
inAppWalletOptions: new InAppWalletOptions(
authprovider: AuthProvider.Google,
executionMode: ExecutionMode.EIP7702Sponsored // new!
)
)
);
ThirdwebDebug.Log("Connected to InAppWallet: " + await smartIaw.GetAddress());
// Execute a transaction as usual, execution is managed by thirdweb seamlessly!
var receipt = await smartIaw.Transfer(11155111, await smartIaw.GetAddress(), 0);
ThirdwebDebug.Log($"Transfer receipt: https://sepolia.etherscan.io/tx/{receipt.TransactionHash}");
```
# SmartWallet (via EIP-4337 Bundlers)
Instantiate or upgrade any other wallet to a `SmartWallet` to enable advanced blockchain interactions, including gasless transactions through Account Abstraction (ERC4337 as well as ZkSync Native AA).
+10
View File
@@ -1,5 +1,15 @@
# thirdweb
## 5.99.1
### Patch Changes
- [#7016](https://github.com/thirdweb-dev/js/pull/7016) [`1985de8`](https://github.com/thirdweb-dev/js/commit/1985de89aae5196233789221ad06f1b6554d27fc) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Expose inMemoryStorage for inAppWallet backend usage
- [#7034](https://github.com/thirdweb-dev/js/pull/7034) [`fc65350`](https://github.com/thirdweb-dev/js/commit/fc6535069beefa419f827c23673f386100a60fd9) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Preload wallet balances on pay embed
- [#7038](https://github.com/thirdweb-dev/js/pull/7038) [`da3fc99`](https://github.com/thirdweb-dev/js/commit/da3fc99c0bb71043b902867a5003bc3d34041269) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Default to in-memory storage when creating inapp wallets outside the browser
## 5.99.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "thirdweb",
"version": "5.99.0",
"version": "5.99.1",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
+1
View File
@@ -11,3 +11,4 @@ export {
type ResolveArweaveSchemeOptions,
} from "../utils/arweave.js";
export type { AsyncStorage } from "../utils/storage/AsyncStorage.js";
export { inMemoryStorage } from "../utils/storage/inMemoryStorage.js";
@@ -18,7 +18,7 @@ export type CanClaimResult = {
/**
* Check if a user can claim a drop.
*
* This method is only available on the `DropERC1155` contract.
* @param options - The options for the transaction.
* @returns Whether the user can claim the drop.
*
@@ -17,6 +17,7 @@ import {
export type GetActiveClaimConditionParams = GetActiveClaimConditionIdParams;
/**
* Retrieves the active claim condition.
* This method is only available on the `DropERC1155` contract.
* @param options - The transaction options.
* @returns A promise that resolves to the active claim condition.
* @throws An error if the claim condition is unsupported.
@@ -9,6 +9,7 @@ export type GetClaimConditionsParams = {
};
/**
* Retrieves all claim conditions.
* This method is only available on the `DropERC1155` contract.
* @param options - The transaction options.
* @returns A promise that resolves to all claim conditions.
* @throws An error if the claim conditions are unsupported by the contract.
@@ -18,6 +18,7 @@ export type ClaimToParams = {
/**
* Claim ERC1155 NFTs to a specified address
* This method is only available on the `DropERC1155` contract.
* @param options - The options for the transaction
* @extension ERC1155
* @example
@@ -23,6 +23,7 @@ export type ResetClaimEligibilityParams = GetClaimConditionsParams;
/**
* Reset the claim eligibility for all users.
* This method is only available on the `DropERC1155` contract.
* @param options
* @returns the prepared transaction
* @extension ERC1155
@@ -22,6 +22,7 @@ export type SetClaimConditionsParams = {
/**
* Set the claim conditions for a ERC1155 drop
* This method is only available on the `DropERC1155` contract.
* @param options
* @returns the prepared transaction
* @extension ERC1155
@@ -37,6 +37,7 @@ export type LazyMintParams = {
/**
* Lazily mints ERC1155 tokens.
* This method is only available on the `DropERC1155` contract.
* @param options - The options for the lazy minting process.
* @returns A promise that resolves to the prepared contract call.
* @extension ERC1155
@@ -13,6 +13,7 @@ export type MintAdditionalSupplyToParams = {
/**
* Mints a "supply" number of additional ERC1155 tokens to the specified "to" address.
* This method is only available on the `TokenERC1155` contract.
* @param options - The transaction options.
* @returns A promise that resolves to the transaction result.
* @extension ERC1155
@@ -16,6 +16,7 @@ export type MintAdditionalSupplyToBatchParams = WithOverrides<{
/**
* This extension batches multiple `mintAdditionalSupplyToBatch` extensions into one single multicall.
* This method is only available on the `TokenERC1155` contract.
* Keep in mind that there is a limit of how many NFTs you can mint per transaction.
* This limit varies depends on the network that you are transacting on.
*
@@ -19,6 +19,7 @@ export type MintToParams = WithOverrides<{
/**
* Mints a "supply" number of new ERC1155 tokens to the specified "to" address.
* This method is only available on the `TokenERC1155` contract.
* If the `nft` parameter is a string, it will be used as the token URI.
* If the `nft` parameter is a file, it will be uploaded to the storage server and the resulting URI will be used as the token URI.
* @param options - The transaction options.
@@ -40,6 +40,7 @@ export type MintToBatchParams = WithOverrides<{
/**
* This extension batches multiple `mintTo` extensions into one single multicall.
* This method is only available on the `TokenERC1155` contract.
* Keep in mind that there is a limit of how many NFTs you can mint per transaction.
* This limit varies depends on the network that you are transacting on.
*
@@ -19,6 +19,7 @@ import {
/**
* Mints a new ERC1155 token with the given minter signature
* This method is only available on the `TokenERC1155` contract.
* @param options - The transaction options.
* @example
* ```ts
@@ -20,6 +20,7 @@ export type UpdateTokenURIParams = {
* which means it uses `setTokenURI` under the hood.
* While the `setTokenURI` method only takes in a uri string, this extension takes in a user-friendly [`NFTInput`](https://portal.thirdweb.com/references/typescript/v5/NFTInput),
* upload that content to IPFS and pass the IPFS URI (of said `NFTInput`) to the underlying `setTokenURI` method.
* This method is only available on the `TokenERC1155` contract.
*
* This extension does not validate the NFTInput so make sure you are passing the proper content that you want to update.
*
@@ -17,7 +17,7 @@ export type CanClaimResult = {
/**
* Check if a user can claim a drop.
*
* This method is only available on the `DropERC20` contract.
* @param options - The options for the transaction.
* @returns Whether the user can claim the drop.
*
@@ -5,6 +5,7 @@ import * as MultiById from "../../__generated__/IDropERC20/read/getClaimConditio
/**
* Retrieves the active claim condition.
* This method is only available on the `DropERC20` contract.
* @param options - The transaction options.
* @returns A promise that resolves to the active claim condition.
* @throws An error if the claim condition is unsupported.
@@ -5,6 +5,7 @@ import * as MultiById from "../../__generated__/IDropERC20/read/getClaimConditio
/**
* Retrieves all claim conditions.
* This method is only available on the `DropERC20` contract.
* @param options - The transaction options.
* @returns A promise that resolves to all claim conditions.
* @throws An error if the claim conditions are unsupported by the contract.
@@ -18,6 +18,8 @@ export type ClaimToParams = {
/**
* Claim ERC20 NFTs to a specified address
* This method is only available on the `DropERC20` contract.
*
* @param options - The options for the transaction
* @extension ERC20
* @example
@@ -12,6 +12,7 @@ import {
/**
* Reset the claim eligibility for all users.
* This method is only available on the `DropERC20` contract.
* @param options
* @returns the prepared transaction
* @extension ERC20
@@ -21,6 +21,7 @@ export type SetClaimConditionsParams = {
/**
* Set the claim conditions for a ERC20 drop
* This method is only available on the `DropERC20` contract.
* @param options
* @returns the prepared transaction
* @extension ERC20
@@ -27,6 +27,7 @@ export type MintToParams = Prettify<
/**
* Mints a specified amount of tokens to a given address.
* This method is only available on the `TokenERC20` contract.
* @param options - The options for minting tokens.
* @returns A prepared transaction object.
* @extension ERC20
@@ -17,6 +17,7 @@ import {
/**
* Mints a new ERC20 token with the given minter signature
* This method is only available on the `TokenERC20` contract.
* @param options - The transaction options.
* @extension ERC20
* @example
@@ -18,6 +18,8 @@ export type CanClaimResult = {
/**
* Check if a user can claim a drop.
*
* This method is only available on the `DropERC721` contract.
*
* @param options - The options for the transaction.
* @returns Whether the user can claim the drop.
*
@@ -5,6 +5,8 @@ import * as ById from "../../__generated__/IDrop/read/getClaimConditionById.js";
/**
* Retrieves the active claim condition.
* This method is only available on the `DropERC721` contract.
*
* @param options - The transaction options.
* @returns A promise that resolves to the active claim condition.
* @throws An error if the claim condition is unsupported.
@@ -28,6 +30,8 @@ export async function getActiveClaimCondition(
/**
* Checks if the `getActiveClaimCondition` method is supported by the given contract.
* This method is only available on the `DropERC721` contract.
*
* @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors.
* @returns A boolean indicating if the `getActiveClaimCondition` method is supported.
* @extension ERC721
@@ -5,6 +5,9 @@ import * as MultiById from "../../__generated__/IDrop/read/getClaimConditionById
/**
* Retrieves all claim conditions.
*
* This method is only available on the `DropERC721` contract.
*
* @param options - The transaction options.
* @returns A promise that resolves to all claim conditions.
* @throws An error if the claim conditions are unsupported by the contract.
@@ -22,6 +22,7 @@ export type ClaimToParams = {
/**
* Claim ERC721 NFTs to a specified address
* This method is only available on the `DropERC721` contract.
* @param options - The options for the transaction
* @extension ERC721
* @example
@@ -22,6 +22,7 @@ export type ClaimToBatchParams = WithOverrides<{
* This extension batches multiple `claimTo` extensions into one single multicall.
* Keep in mind that there is a limit of how many NFTs you can claim per transaction.
* This limit varies depends on the network that you are transacting on.
* This method is only available on the `DropERC721` contract.
*
* You are recommended to experiment with the number to figure out the best number for your chain of choice.
* @extension ERC721
@@ -12,6 +12,7 @@ import {
/**
* Reset the claim eligibility for all users.
* This method is only available on the `DropERC721` contract.
* @param options
* @returns the prepared transaction
* @extension ERC721
@@ -21,6 +21,7 @@ export type SetClaimConditionsParams = {
/**
* Set the claim conditions for a ERC721 drop
* This method is only available on the `DropERC721` contract.
* @param options
* @returns the prepared transaction
* @extension ERC721
@@ -32,6 +32,7 @@ export type CreateDelayedRevealBatchParams = {
/**
* Creates a batch of encrypted NFTs that can be revealed at a later time.
* This method is only available on the `DropERC721` contract.
*
* @param options {CreateDelayedRevealBatchParams} - The delayed reveal options.
* @param options.placeholderMetadata {@link NFTInput} - The placeholder metadata for the batch.
@@ -14,7 +14,7 @@ export type RevealParams = {
/**
* Reveals a previously lazy minted batch of NFTs.
*
* This method is only available on the `DropERC721` contract.
* @param options {RevealParams} - The reveal parameters.
* @param options.batchId {number} - The ID of the batch to reveal. Get this by calling `getBatchesToReveal`. {@see getBatchesToReveal}
* @param options.password {string} - The password for the reveal, set when the batch was created. {@see createDelayedRevealBatch}
@@ -19,6 +19,8 @@ export type LazyMintParams = {
/**
* Lazily mints ERC721 tokens.
* This method is only available on the `DropERC721` contract.
*
* @param options - The options for the lazy minting process.
* @returns A promise that resolves to the prepared contract call.
* @extension ERC721
@@ -17,6 +17,8 @@ export type MintToParams = WithOverrides<{
/**
* Mints a new ERC721 token and assigns it to the specified address.
* This method is only available on the `TokenERC721` contract.
*
* If the `nft` parameter is a string, it will be used as the token URI.
* If the `nft` parameter is a file, it will be uploaded to the storage server and the resulting URI will be used as the token URI.
* @param options - The transaction options.
@@ -22,6 +22,8 @@ import {
/**
* Mints a new ERC721 token with the given minter signature
* This method is only available on the `TokenERC721` contract.
*
* @param options - The transaction options.
* @example
* ```ts
@@ -23,6 +23,7 @@ export type UpdateTokenURIParams = {
* upload that content to IPFS and pass the IPFS URI (of said `NFTInput`) to the underlying `setTokenURI` method.
*
* This extension does not validate the NFTInput so make sure you are passing the proper content that you want to update.
* This method is only available on the `TokenERC721` contract.
*
* @extension ERC721
* @returns the prepared transaction from `setTokenURI`
@@ -63,6 +63,7 @@ import { SwapFlow } from "./swap/SwapFlow.js";
import { SwapScreenContent } from "./swap/SwapScreenContent.js";
import { TokenSelectorScreen } from "./swap/TokenSelectorScreen.js";
import { TransferFlow } from "./swap/TransferFlow.js";
import { useWalletsAndBalances } from "./swap/fetchBalancesForWallet.js";
import {
type SupportedChainAndTokens,
useBuySupportedDestinations,
@@ -218,6 +219,15 @@ function BuyScreenContent(props: BuyScreenContentProps) {
);
}, [props.supportedTokens, supportedSourcesQuery.data, payOptions]);
// preload wallets and balances
useWalletsAndBalances({
client: props.client,
sourceSupportedTokens: sourceSupportedTokens || [],
toChain: toChain,
toToken: toToken,
mode: payOptions.mode,
});
const { fromChain, setFromChain, fromToken, setFromToken } =
useFromTokenSelectionStates({
payOptions,
@@ -4,7 +4,6 @@ import {
ChevronRightIcon,
Cross2Icon,
} from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { trackPayEvent } from "../../../../../../../analytics/track/pay.js";
import type { Chain } from "../../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
@@ -43,14 +42,9 @@ import { FiatValue } from "./FiatValue.js";
import { WalletRow } from "./WalletRow.js";
import {
type TokenBalance,
fetchBalancesForWallet,
useWalletsAndBalances,
} from "./fetchBalancesForWallet.js";
type WalletKey = {
id: WalletId;
address: string;
};
export function TokenSelectorScreen(props: {
client: ThirdwebClient;
sourceTokens: SupportedTokens | undefined;
@@ -71,46 +65,12 @@ export function TokenSelectorScreen(props: {
const chainInfo = useChainMetadata(props.toChain);
const theme = useCustomTheme();
const walletsAndBalances = useQuery({
queryKey: [
"wallets-and-balances",
props.sourceSupportedTokens,
props.toChain.id,
props.toToken,
props.tokenAmount,
props.mode,
activeAccount?.address,
connectedWallets.map((w) => w.getAccount()?.address),
],
enabled: !!props.sourceSupportedTokens && !!chainInfo.data,
queryFn: async () => {
const entries = await Promise.all(
connectedWallets.map(async (wallet) => {
const balances = await fetchBalancesForWallet({
wallet,
accountAddress: activeAccount?.address,
sourceSupportedTokens: props.sourceSupportedTokens || [],
toChain: props.toChain,
toToken: props.toToken,
tokenAmount: props.tokenAmount,
mode: props.mode,
client: props.client,
});
return [
{
id: wallet.id,
address: wallet.getAccount()?.address || "",
} as WalletKey,
balances,
] as const;
}),
);
const map = new Map<WalletKey, TokenBalance[]>();
for (const entry of entries) {
map.set(entry[0], entry[1]);
}
return map;
},
const walletsAndBalances = useWalletsAndBalances({
client: props.client,
sourceSupportedTokens: props.sourceSupportedTokens || [],
toChain: props.toChain,
toToken: props.toToken,
mode: props.mode,
});
if (
@@ -1,3 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import type { Chain } from "../../../../../../../chains/types.js";
import { getCachedChain } from "../../../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
@@ -9,7 +10,11 @@ import {
type GetWalletBalanceResult,
getWalletBalance,
} from "../../../../../../../wallets/utils/getWalletBalance.js";
import type { WalletId } from "../../../../../../../wallets/wallet-types.js";
import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js";
import { useChainMetadata } from "../../../../../../core/hooks/others/useChainQuery.js";
import { useActiveAccount } from "../../../../../../core/hooks/wallets/useActiveAccount.js";
import { useConnectedWallets } from "../../../../../../core/hooks/wallets/useConnectedWallets.js";
import type {
SupportedTokens,
TokenInfo,
@@ -32,7 +37,6 @@ type FetchBalancesParams = {
sourceSupportedTokens: SupportedTokens;
toChain: Chain;
toToken: ERC20OrNativeToken;
tokenAmount: string;
mode: PayUIOptions["mode"];
client: ThirdwebClient;
};
@@ -43,13 +47,70 @@ export type TokenBalance = {
token: TokenInfo;
};
export async function fetchBalancesForWallet({
type WalletKey = {
id: WalletId;
address: string;
};
export function useWalletsAndBalances(props: {
sourceSupportedTokens: SupportedTokens;
toChain: Chain;
toToken: ERC20OrNativeToken;
mode: PayUIOptions["mode"];
client: ThirdwebClient;
}) {
const activeAccount = useActiveAccount();
const connectedWallets = useConnectedWallets();
const chainInfo = useChainMetadata(props.toChain);
return useQuery({
queryKey: [
"wallets-and-balances",
props.sourceSupportedTokens,
props.toChain.id,
props.toToken,
props.mode,
activeAccount?.address,
connectedWallets.map((w) => w.getAccount()?.address),
],
enabled:
!!props.sourceSupportedTokens && !!chainInfo.data && !!activeAccount,
queryFn: async () => {
const entries = await Promise.all(
connectedWallets.map(async (wallet) => {
const balances = await fetchBalancesForWallet({
wallet,
accountAddress: activeAccount?.address,
sourceSupportedTokens: props.sourceSupportedTokens || [],
toChain: props.toChain,
toToken: props.toToken,
mode: props.mode,
client: props.client,
});
return [
{
id: wallet.id,
address: wallet.getAccount()?.address || "",
} as WalletKey,
balances,
] as const;
}),
);
const map = new Map<WalletKey, TokenBalance[]>();
for (const entry of entries) {
map.set(entry[0], entry[1]);
}
return map;
},
});
}
async function fetchBalancesForWallet({
wallet,
accountAddress,
sourceSupportedTokens,
toChain,
toToken,
tokenAmount,
mode,
client,
}: FetchBalancesParams): Promise<TokenBalance[]> {
@@ -133,7 +194,7 @@ export async function fetchBalancesForWallet({
b.chain.id === chainId &&
b.token.address.toLowerCase() === token.address.toLowerCase(),
);
if (!isNative && !isAlreadyFetched) {
if (isAlreadyFetched && !isNative) {
// ERC20 on insight-enabled chain already handled by insight call
continue;
}
@@ -148,11 +209,12 @@ export async function fetchBalancesForWallet({
});
const include =
token.address === destinationToken.address &&
token.address.toLowerCase() ===
destinationToken.address.toLowerCase() &&
chain.id === toChain.id
? mode === "fund_wallet" && account.address === accountAddress
? false
: Number(balance.displayValue) > Number(tokenAmount)
? !(
mode === "fund_wallet" && account.address === accountAddress
)
: balance.value > 0n;
if (include) {
@@ -0,0 +1,15 @@
import type { AsyncStorage } from "./AsyncStorage.js";
const store = new Map<string, string>();
export const inMemoryStorage: AsyncStorage = {
getItem: async (key: string) => {
return store.get(key) ?? null;
},
setItem: async (key: string, value: string) => {
store.set(key, value);
},
removeItem: async (key: string) => {
store.delete(key);
},
};
@@ -23,7 +23,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* const account = await wallet.connect({
* client,
* chain,
* strategy: "google",
* strategy: "google", // or "apple", "facebook","discord", "github", "twitch", "x", "telegram", "line", "coinbase", etc
* });
* ```
*
@@ -31,15 +31,21 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
*
* ### Enable smart accounts and sponsor gas for your users:
*
* With the `executionMode` option, you can enable smart accounts and sponsor gas for your users.
*
* **Using EIP-7702** (recommended):
*
* On chains with EIP-7702 enabled, you can upgrade the inapp wallet to a smart account, keeping the same address and performance as the regular EOA.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
* import { sepolia } from "thirdweb/chains";
*
* const wallet = inAppWallet({
* smartAccount: {
* chain: sepolia,
* executionMode: {
* mode: "EIP7702",
* sponsorGas: true,
* },
* },
* });
*
* // account will be a smart account with sponsored gas enabled
@@ -49,8 +55,28 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* });
* ```
*
* **Using EIP-4337**:
*
* On chains without EIP-7702 enabled, you can still use smart accounts using EIP-4337, this will return a different address (the smart contract address) than the regular EOA.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets/in-app";
*
* const wallet = inAppWallet({
* executionMode: {
* mode: "EIP4337",
* smartAccount: {
* chain: sepolia, // chain required for EIP-4337
* sponsorGas: true,
* }
* },
* });
* ```
*
* ### Login with email
*
* To login with email, you can use the `preAuthenticate` function to first send a verification code to the user's email, then login with the verification code.
*
* ```ts
* import { inAppWallet, preAuthenticate } from "thirdweb/wallets/in-app";
*
@@ -73,22 +99,10 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* });
* ```
*
* ### Login with SIWE
* ```ts
* import { inAppWallet, createWallet } from "thirdweb/wallets";
*
* const rabby = createWallet("io.rabby");
* const inAppWallet = inAppWallet();
*
* const account = await inAppWallet.connect({
* strategy: "wallet",
* chain: mainnet,
* wallet: rabby,
* client: MY_CLIENT
* });
* ```
*
* ### Login with phone number
*
* Similar to email, you can login with a phone number by first sending a verification code to the user's phone number, then login with the verification code.
*
* ```ts
* import { inAppWallet, preAuthenticate } from "thirdweb/wallets/in-app";
*
@@ -111,8 +125,28 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* });
* ```
*
* ### Login with another wallet (SIWE)
*
* You can also login to the in-app wallet with another existing wallet by signing a standard Sign in with Ethereum (SIWE) message.
*
* ```ts
* import { inAppWallet, createWallet } from "thirdweb/wallets";
*
* const rabby = createWallet("io.rabby");
* const inAppWallet = inAppWallet();
*
* const account = await inAppWallet.connect({
* strategy: "wallet",
* chain: mainnet,
* wallet: rabby,
* client: MY_CLIENT
* });
* ```
*
* ### Login with passkey
*
* You can also login with a passkey. This mode requires specifying whether it should create a new passkey, or sign in with an existing passkey. We recommend checking if the user has a passkey stored in their browser to automatically login with it.
*
* ```ts
* import { inAppWallet, hasStoredPasskey } from "thirdweb/wallets/in-app";
*
@@ -128,6 +162,11 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* ```
*
* ### Connect to a guest account
*
* You can also connect to a guest account, this will create a new account for the user instantly and store it in the browser's local storage.
*
* You can later "upgrade" this account by linking another auth method, like email or phone for example. This will preserve the account's address and history.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
*
@@ -141,6 +180,10 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
*
* ### Connect to a backend account
*
* For usage in backends, you can create wallets with the `backend` strategy and a stable walletSecret.
*
* Make sure to keep that walletSecret safe as it is the key to access that wallet, never expose it to the client.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
*
@@ -149,7 +192,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* const account = await wallet.connect({
* client,
* strategy: "backend",
* walletSecret: "...", // Provided by your app
* walletSecret: "...", // Your own secret, keep it safe
* });
* ```
*
@@ -185,16 +228,21 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
* });
* ```
*
* ### Specify a logo for your login page (Connect UI)
* ### Specify a logo, icon and name for your login page (Connect UI)
*
* You can specify a logo, icon and name for your login page to customize how in-app wallets are displayed in the Connect UI components (ConnectButton and ConnectEmbed).
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
* const wallet = inAppWallet({
* metadata: {
* image: {
* src: "https://example.com/logo.png",
* alt: "My logo",
* width: 100,
* height: 100,
* name: "My App",
* icon: "https://example.com/icon.png",
* image: {
* src: "https://example.com/logo.png",
* alt: "My logo",
* width: 100,
* height: 100,
* },
* },
* });
@@ -202,6 +250,8 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
*
* ### Hide the ability to export the private key within the Connect Modal UI
*
* By default, the Connect Modal will show a button to export the private key of the wallet. You can hide this button by setting the `hidePrivateKeyExport` option to `true`.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
* const wallet = inAppWallet({
@@ -224,7 +274,7 @@ import type { InAppWalletCreationOptions } from "../core/wallet/types.js";
*
* ### Override storage for the wallet state
*
* By default, wallet state is stored in the browser's local storage. You can override this behavior by providing a custom storage object, useful for server side integrations.
* By default, wallet state is stored in the browser's local storage if in the browser, or in-memory storage if not in the browser. You can override this behavior by providing a custom storage object, useful for server side and CLI integrations.
*
* ```ts
* import { inAppWallet } from "thirdweb/wallets";
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { TEST_CLIENT } from "~test/test-clients.js";
import { sepolia } from "../../../../chains/chain-definitions/sepolia.js";
import { parseEventLogs } from "../../../../event/actions/parse-logs.js";
import { userOperationEventEvent } from "../../../../extensions/erc4337/__generated__/IEntryPoint/events/UserOperationEvent.js";
import { executedEvent } from "../../../../extensions/erc7702/__generated__/MinimalAccount/events/Executed.js";
import { sendAndConfirmTransaction } from "../../../../transaction/actions/send-and-confirm-transaction.js";
import { prepareTransaction } from "../../../../transaction/prepare-transaction.js";
import { inAppWallet } from "../in-app.js";
describe("InAppWallet Integration Tests", () => {
it("should sign a message with backend strategy", async () => {
const wallet = inAppWallet();
const account = await wallet.connect({
client: TEST_CLIENT,
strategy: "backend",
walletSecret: "test-secret",
});
expect(account.address).toBeDefined();
const message = await account.signMessage({
message: "Hello, world!",
});
expect(message).toBeDefined();
});
it("should sign a message with guest strategy", async () => {
const wallet = inAppWallet();
const account = await wallet.connect({
client: TEST_CLIENT,
strategy: "guest",
});
expect(account.address).toBeDefined();
const message = await account.signMessage({
message: "Hello, world!",
});
expect(message).toBeDefined();
});
it("should sponsor gas for a 7702 smart account", async () => {
const chain = sepolia;
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true,
},
});
const account = await wallet.connect({
client: TEST_CLIENT,
strategy: "guest",
chain,
});
expect(account.address).toBeDefined();
const tx = await sendAndConfirmTransaction({
transaction: prepareTransaction({
chain,
client: TEST_CLIENT,
to: account.address,
value: 0n,
}),
account,
});
expect(tx.transactionHash).toBeDefined();
const logs = parseEventLogs({
logs: tx.logs,
events: [executedEvent()],
});
const executedLog = logs[0];
if (!executedLog) {
throw new Error("No executed log found");
}
expect(executedLog.args.to).toBe(account.address);
expect(executedLog.args.value).toBe(0n);
});
it("should sponsor gas for a 4337 smart account", async () => {
const chain = sepolia;
const wallet = inAppWallet({
executionMode: {
mode: "EIP4337",
smartAccount: {
chain,
sponsorGas: true,
},
},
});
const account = await wallet.connect({
client: TEST_CLIENT,
strategy: "guest",
chain,
});
expect(account.address).toBeDefined();
const tx = await sendAndConfirmTransaction({
transaction: prepareTransaction({
chain,
client: TEST_CLIENT,
to: account.address,
value: 0n,
}),
account,
});
expect(tx.transactionHash).toBeDefined();
const logs = parseEventLogs({
logs: tx.logs,
events: [userOperationEventEvent()],
});
const executedLog = logs[0];
if (!executedLog) {
throw new Error("No executed log found");
}
expect(executedLog.args.sender).toBe(account.address);
expect(executedLog.args.success).toBe(true);
});
});
@@ -1,5 +1,7 @@
import type { ThirdwebClient } from "../../../../client/client.js";
import { getThirdwebBaseUrl } from "../../../../utils/domains.js";
import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js";
import { inMemoryStorage } from "../../../../utils/storage/inMemoryStorage.js";
import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
import type { SocialAuthOption } from "../../../../wallets/types.js";
import type { Account } from "../../../interfaces/wallet.js";
@@ -86,7 +88,7 @@ export class InAppWebConnector implements InAppConnector {
this.ecosystem = ecosystem;
this.passkeyDomain = passkeyDomain;
this.storage = new ClientScopedStorage({
storage: storage ?? webLocalStorage,
storage: storage ?? getDefaultStorage(),
clientId: client.clientId,
ecosystem: ecosystem,
});
@@ -489,3 +491,11 @@ export class InAppWebConnector implements InAppConnector {
function assertUnreachable(x: never, message?: string): never {
throw new Error(message ?? `Invalid param: ${x}`);
}
function getDefaultStorage(): AsyncStorage {
if (typeof window !== "undefined" && window.localStorage) {
return webLocalStorage;
}
// default to in-memory storage if we're not in the browser
return inMemoryStorage;
}
+2
View File
@@ -1,5 +1,7 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.77
## 0.2.76
## 0.2.75

Some files were not shown because too many files have changed in this diff Show More