Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d0c2ac63a9 Version Packages (#7042)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-16 05:06:17 -07:00
Joaquim VergesandGitHub 170f14fb54 [Dashboard] Add pagination to server wallets page (#7070) 2025-05-16 05:04:26 -07:00
Joaquim VergesandGitHub da2a2a0e86 [SDK] Fix buyWithCrypto false not respected when returning from quote (#7069) 2025-05-16 04:57:48 -07:00
saminaandGitHub e62fd3fe40 [Docs] Engine Update (#7052)
Signed-off-by: samina <[email protected]>
2025-05-16 04:09:33 -07:00
Joaquim VergesandGitHub 99a7cce624 [Engine] Add all testnets to test transaction options (#7066) 2025-05-16 04:08:17 -07:00
Joaquim VergesandGitHub 61152dd098 [Engine] Add FAILED status to execution result and update error handling (#7065) 2025-05-16 03:34:27 -07:00
Maximilian HubertandGitHub 05519fcdde [DOCS] fix link in contributing.md (#7063)
Signed-off-by: Maximilian Hubert <[email protected]>
2025-05-16 00:11:57 -07:00
saminaandGitHub 7257747520 [Docs] Engine (#7057)
Signed-off-by: samina <[email protected]>
2025-05-16 00:10:35 -07:00
Prithvish BaidyaandGitHub e71e7d70a6 [Vault] default to engine-cloud vault proxy (#7058) 2025-05-15 16:44:03 -07:00
Joaquim VergesandGitHub 8d47864967 [SDK] Export prepareUserOp and add support for new wallets (#7061) 2025-05-15 16:43:11 -07:00
kumaryash90 4a8adc143f [Docs] Stylus dev workflow (#7060)
<!--

## 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 documentation and assets for integrating thirdweb tools into the Stylus contract development workflow, specifically for Rust. It includes setup instructions, publishing and deployment commands, and images to enhance the documentation.

### Detailed summary
- Added new sections in `page.mdx` for Arbitrum Stylus.
- Included prerequisites for Rust and Solidity installations.
- Provided setup commands for creating a Stylus project.
- Added commands for publishing and deploying Stylus contracts.
- Included images (`stylus-publish-page.png`, `stylus-deploy-page.png`) to illustrate the process.

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

<!-- end pr-codex -->
2025-05-15 21:30:32 +00:00
MananTank db538473cf [NEB-248] Show native token balances in assets, show popular tokens on top (#7056)
<!--

## 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 enhances the `AssetsSection` component by integrating wallet balance retrieval, modifying how assets are displayed, and implementing new logic for sorting tokens. It also introduces a new constant for native tokens and adjusts rendering based on whether an asset is native.

### Detailed summary
- Added `NATIVE_TOKEN_ADDRESS` and `getWalletBalance` import from `thirdweb`.
- Updated `key` for `AssetItem` to include `chain_id`.
- Introduced `isNativeToken` to conditionally render the `ChainIconClient`.
- Modified the `href` for `Link` based on whether the asset is a native token.
- Added a new set `tokensToShowOnTop` for sorting specific tokens.
- Implemented `nativeBalances` query to fetch wallet balances for various chains.
- Combined `nativeBalances` and `assetsQuery` data for rendering in `AssetsSectionUI`.

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

<!-- end pr-codex -->
2025-05-15 20:52:49 +00:00
Manan TankandGitHub 2057546934 Nebula: Recent Activity improvements in sidebar (#7055) 2025-05-15 13:52:15 -07:00
gregandGitHub ba52e68eab [Dashboard] Fix: Trend markers and UB volume type dropdown (#7059) 2025-05-15 12:05:40 -07:00
0eff64824b [Dashboard] Feature: Add Universal Bridge to analytics dashboard (#7051)
Co-authored-by: Claude <[email protected]>
2025-05-14 18:07:07 -07:00
saminacodes ae7407dca4 [Docs] engine and ub changes (#7049)
<!--

## 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 documentation and user guidance for the `thirdweb` platform, specifically around the Engine v3 features, onramp providers, and pricing details. It introduces new FAQs, clarifies migration steps, and adds visual content.

### Detailed summary
- Added new section for "Onramp Providers" in `sidebar.tsx`.
- Updated Engine FAQs in `page.mdx` with pricing and transaction details.
- Revised migration guide in `page.mdx` with clearer instructions and a callout for wallet migration.
- Enhanced `faqs/page.mdx` with additional questions about Universal Bridge and supported payment methods.
- Updated "Buy With Fiat" availability to 160+ countries.
- Included images for creating a vault and sending transactions in the `get-started/page.mdx`.
- Revised onramp providers section to clarify provider preferences and fees in `onramp-providers/page.mdx`.

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

<!-- end pr-codex -->
2025-05-14 22:53:11 +00:00
Joe-Thirdweb a443b309dc Adding NeoX custom chain info. (#7045)
Adding Custom chain info to

- NeoX Mainnet 47763
- NeoX Testnt 12227332

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and metadata for the `Neo X` blockchain, enhancing the dashboard with images and information about its features and offerings.

### Detailed summary
- Added imports for `NeoXBanner` and `NeoXCTA` images.
- Introduced metadata for `Neo X` (ID: 47763) including:
  - `headerImgUrl`
  - `about` description
  - `cta` object with `backgroundImageUrl`, `title`, `buttonLink`, and `buttonText`.
- Duplicated metadata for `Neo X Testnet` (ID: 12227332) with similar structure.

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

<!-- end pr-codex -->
2025-05-14 22:44:28 +00:00
Joaquim VergesGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
ae2ff743c0 [Engine] Update OpenAPI spec and configuration (#7050)
Signed-off-by: Joaquim Verges <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-05-14 15:43:30 -07:00
MananTank dfa8603593 [NEB-213] Nebula: Show wallet Assets and Activity in sidebar (#7046)
<!--

## 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 primarily focuses on enhancing the `AssetsSection` and `TransactionsSection` components by adding new features and improving the UI. It also modifies several existing components for better functionality and user experience.

### Detailed summary
- Changed `mergedSessions.push(session)` to `mergedSessions.unshift(session)` in `useSessionsWithLocalOverrides.ts`.
- Updated the width of the sidebar in `ChatPageLayout.tsx` from `280px` to `300px`.
- Added `customDetailsButton` prop to `NebulaConnectWallet` in `NebulaConnectButton.tsx`.
- Implemented conditional rendering for `customDetailsButton` in `NebulaConnectWallet`.
- Created storybook stories for `AssetsSection` and `TransactionsSection` with stubs for tokens and transactions.
- Enhanced `AssetsSection` and `TransactionsSection` components to handle loading states and display messages when there are no assets or transactions.
- Introduced `WalletDetails` component in `ChatSidebar` to manage assets and transactions tabs.
- Refactored `WalletSelector` to integrate `useActiveWallet` for better wallet management and user feedback.
- Improved button components with tooltips for better accessibility and UX.

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

<!-- end pr-codex -->
2025-05-14 21:47:42 +00:00
saminacodes 9f1ca54c26 Update page.mdx (#7048)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `preferredProvider` options in the `detailsModal` configuration for the `connectbutton` component, enhancing payment provider flexibility.

### Detailed summary
- Modified `preferredProvider` options in `detailsModal`:
  - Removed `"KADO"`
  - Added `"COINBASE"`

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

<!-- end pr-codex -->
2025-05-14 17:44:23 +00:00
MananTank 7df81b7323 [TOOL-4493] Dashboard: Add starter legacy discontinuation banner (#7047)
<!--

## 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 a `StarterLegacyDiscontinuedBanner` component to the `RootTeamLayout` in `layout.tsx`. It displays a notification for users on the "starter_legacy" billing plan, informing them of the discontinuation and providing a link to select a new plan.

### Detailed summary
- Added a conditional rendering for `StarterLegacyDiscontinuedBanner` based on the team's billing plan.
- Created the `StarterLegacyDiscontinuedBanner` component to inform users about the discontinuation of starter legacy plans.
- Included a button with a link to the billing settings for selecting a new plan, featuring an `ArrowRightIcon`.

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

<!-- end pr-codex -->
2025-05-14 16:25:35 +00:00
GiselleNessi ab74fcdaf8 add new apis tab to nav (#7044)
<!--

## 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 adds a new section to the `Header` component in the `Header.tsx` file, introducing `apisLinks` for API documentation links and integrating a `DropdownLinks` component for displaying these links.

### Detailed summary
- Added a new constant `apisLinks` containing links to API documentation.
- Introduced a `DropdownLinks` component to display `apisLinks` in the header.
- Adjusted padding in the header's layout from `xl:px-2` to `xl:px-1` for the new dropdown.

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

<!-- end pr-codex -->
2025-05-14 15:09:12 +00:00
Joaquim VergesandGitHub 1191d5dad9 [Engine] Add EOA read/create permissions for server wallets (#7043) 2025-05-14 22:32:35 +12:00
Joaquim VergesandGitHub 20b5ba943a [SDK] Propagate 401 errors when connecting in-app wallet (#7040) 2025-05-14 02:03:41 -07:00
186 changed files with 2508 additions and 661 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ We use [Turborepo](https://turbo.build/repo/docs) to manage the repository, and
We use [pnpm](https://pnpm.io) for package management across the repo. `pnpm` is similar to `npm` or `yarn` but with more efficient disk space usage.
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](./packages/thirdweb). You can still find the legacy packages at [/legacy_packages](./legacy_packages).**
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](../packages/thirdweb). You can still find the legacy packages at [/legacy_packages](../legacy_packages).**
This single package provides a performant & lightweight SDK to interact with any EVM chain across Node, React, and React Native. Learn more about how to use the thirdweb SDK in our [documentation](https://portal.thirdweb.com/typescript/v5).
+8 -10
View File
@@ -48,15 +48,15 @@ async function fetchAnalytics(
);
}
// client id DEBUG OVERRIDE
// ANALYTICS_SERVICE_URL.searchParams.delete("projectId");
// ANALYTICS_SERVICE_URL.searchParams.delete("teamId");
// ANALYTICS_SERVICE_URL.searchParams.append(
// analyticsServiceUrl.searchParams.delete("projectId");
// analyticsServiceUrl.searchParams.delete("teamId");
// analyticsServiceUrl.searchParams.append(
// "teamId",
// "team_clmb33q9w00gn1x0u2ri8z0k0",
// "team_cm0lde33r02344w129k5hm2xz",
// );
// ANALYTICS_SERVICE_URL.searchParams.append(
// analyticsServiceUrl.searchParams.append(
// "projectId",
// "prj_clyqwud5y00u1na7nzxnzlz7o",
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
// );
return fetch(analyticsServiceUrl, {
@@ -377,7 +377,7 @@ export async function getEcosystemWalletUsage(args: {
export async function getUniversalBridgeUsage(args: {
teamId: string;
projectId: string;
projectId?: string;
from?: Date;
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
@@ -395,11 +395,10 @@ export async function getUniversalBridgeUsage(args: {
console.error(
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
return null;
return [];
}
const json = await res.json();
return json.data as UniversalBridgeStats[];
}
@@ -430,6 +429,5 @@ export async function getUniversalBridgeWalletUsage(args: {
}
const json = await res.json();
return json.data as UniversalBridgeWalletStats[];
}
@@ -12,6 +12,7 @@ export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
isCurrency?: boolean;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
+5 -4
View File
@@ -39,9 +39,10 @@ export function getFiltersFromSearchParams(params: {
};
const defaultInterval =
differenceInCalendarDays(range.to, range.from) > 30
? "week"
: ("day" as const);
params.interval ??
(differenceInCalendarDays(range.to, range.from) > 30
? ("week" as const)
: ("day" as const));
return {
range,
@@ -50,6 +51,6 @@ export function getFiltersFromSearchParams(params: {
? ("day" as const)
: params.interval === "week"
? ("week" as const)
: defaultInterval,
: (defaultInterval as "day" | "week"),
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -41,6 +41,8 @@ import creatorCTA from "./temp-assets/creatorCTA.png";
import lumiaBanner from "./temp-assets/lumiaBanner.png";
import somniaBanner from "./temp-assets/somniaBanner.png";
import NeoXBanner from "./temp-assets/NeoXBanner.jpg";
import NeoXCTA from "./temp-assets/NeoXCTA.jpg";
import flowBanner from "./temp-assets/flowBanner.png";
import flowCTA from "./temp-assets/flowCTA.png";
import mantleBanner from "./temp-assets/mantle.png";
@@ -545,6 +547,30 @@ const chainMetaRecord = {
about:
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
},
//NEO X
47763: {
headerImgUrl: NeoXBanner.src,
about:
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
cta: {
backgroundImageUrl: NeoXCTA.src,
title: "Learn more about Neo X",
buttonLink: "https://x.neo.org/",
buttonText: "Learn More",
},
},
//NEO X Testnet
12227332: {
headerImgUrl: NeoXBanner.src,
about:
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
cta: {
backgroundImageUrl: NeoXCTA.src,
title: "Learn more about Neo X",
buttonLink: "https://x.neo.org/",
buttonText: "Learn More",
},
},
//0G-Newton-Testnet
16600: {
headerImgUrl: zeroGBanner.src,
@@ -1,6 +1,7 @@
import {
getClientTransactions,
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
getWalletUsers,
@@ -14,6 +15,7 @@ import { redirect } from "next/navigation";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import type {
InAppWalletStats,
UniversalBridgeStats,
WalletStats,
WalletUserStats,
} from "types/analytics";
@@ -24,7 +26,10 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
import { getTeamBySlug } from "@/api/team";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { Suspense } from "react";
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
@@ -100,6 +105,7 @@ async function OverviewPageContent(props: {
userOpUsage,
clientTransactionsTimeSeries,
clientTransactions,
universalBridgeUsage,
] = await Promise.all([
// Aggregated wallet connections
getWalletConnections({
@@ -148,6 +154,13 @@ async function OverviewPageContent(props: {
to: range.to,
period: "all",
}),
// Universal Bridge
getUniversalBridgeUsage({
teamId: teamId,
from: range.from,
to: range.to,
period: interval,
}),
]);
const isEmpty =
@@ -164,8 +177,9 @@ async function OverviewPageContent(props: {
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
<AppHighlightsCard
userStats={walletUserStatsTimeSeries}
volumeStats={universalBridgeUsage}
searchParams={searchParams}
/>
</div>
@@ -218,70 +232,96 @@ async function OverviewPageContent(props: {
);
}
type UserMetrics = {
totalUsers: number;
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
returningUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = UserMetrics & {
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
let cumulativeUsers = 0;
for (const stat of userStats) {
cumulativeUsers += stat.newUsers ?? 0;
const volume = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
date: stat.date,
activeUsers: stat.totalUsers ?? 0,
returningUsers: stat.returningUsers ?? 0,
newUsers: stat.newUsers ?? 0,
totalUsers: cumulativeUsers,
totalVolume: volume,
feesCollected: fees,
});
}
return metrics;
}
function UsersChartCard({
function AppHighlightsCard({
userStats,
volumeStats,
searchParams,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats);
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
returningUsers: {
label: "Returning Users",
color: "hsl(var(--chart-4))",
totalVolume: {
label: "Total Volume",
color: "hsl(var(--chart-2))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Payments"
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
link="https://portal.thirdweb.com/connect/pay/overview"
/>
),
},
feesCollected: {
label: "Fee Revenue",
color: "hsl(var(--chart-4))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Fees"
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/connect/pay/fees"}
/>
),
},
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
} as const;
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
title="App Highlights"
chartConfig={chartConfig}
activeChart={
(searchParams?.usersChart as keyof UserMetrics) ?? "activeUsers"
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
data={timeSeriesData}
aggregateFn={(_data, key) =>
// If there is only one data point, use that one, otherwise use the previous
timeSeriesData.filter((d) => (d[key] as number) > 0).length >= 2
? timeSeriesData[timeSeriesData.length - 2]?.[key]
: timeSeriesData[timeSeriesData.length - 1]?.[key]
timeSeriesData.reduce((acc, curr) => acc + curr[key], 0)
}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -291,7 +331,7 @@ function UsersChartCard({
1
: undefined
}
queryKey="usersChart"
queryKey="appHighlights"
existingQueryParams={searchParams}
/>
);
@@ -14,11 +14,11 @@ export default async function Page(props: {
team_slug: string;
project_slug: string;
}>;
searchParams: {
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
}>;
}) {
const params = await props.params;
const project = await getProject(params.team_slug, params.project_slug);
@@ -36,7 +36,7 @@ export default async function Page(props: {
});
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<ResponsiveSearchParamsProvider value={searchParams}>
<div>
<div className="mb-4 flex justify-start">
<PayAnalyticsFilter />
@@ -19,15 +19,10 @@ import { Loader2Icon, LockIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
arbitrumSepolia,
baseSepolia,
optimismSepolia,
sepolia,
} from "thirdweb/chains";
import * as z from "zod";
import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton";
import { useTrack } from "../../../../../../../../hooks/analytics/useTrack";
import { useAllChainsData } from "../../../../../../../../hooks/chains/allChains";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
import { deleteUserAccessToken, getUserAccessToken } from "./utils";
@@ -53,6 +48,7 @@ export function SendTestTransaction(props: {
const [hasSentTx, setHasSentTx] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const chainsQuery = useAllChainsData();
const userAccessToken =
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
@@ -227,32 +223,13 @@ export function SendTestTransaction(props: {
<p className="text-sm">Network</p>
<SingleNetworkSelector
className="bg-background"
chainIds={[
baseSepolia.id,
optimismSepolia.id,
arbitrumSepolia.id,
sepolia.id,
1301,
1946,
1993,
919,
6342,
10143,
44787,
80002,
80069,
98985,
128123,
167009,
168587773,
37714555429,
1952959480,
97,
296,
1740,
4202,
10200,
]}
chainIds={chainsQuery.allChains
.filter(
(chain) =>
chain.testnet === true &&
chain.stackType !== "zksync_stack",
)
.map((chain) => chain.chainId)}
client={thirdwebClient}
chainId={form.watch("chainId")}
onChange={(chainId) => {
@@ -32,6 +32,10 @@ type ExecutionResult4337Serialized =
| {
status: "QUEUED";
}
| {
status: "FAILED";
error: string;
}
| {
status: "SUBMITTED";
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
@@ -183,6 +183,52 @@ export async function createWalletAccessToken(props: {
},
],
},
{
type: "eoa:read",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:create",
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
],
metadata: {
projectId: props.project.id,
@@ -289,7 +335,6 @@ export async function createManagementAccessToken(props: {
});
if (res.success) {
const data = res.data;
// store the management access token in the project
await updateProjectClient(
{
projectId: props.project.id,
@@ -308,8 +353,9 @@ export async function createManagementAccessToken(props: {
],
},
);
return res;
}
return res;
throw new Error(`Failed to create management access token: ${res.error}`);
}
export function maskSecret(secret: string) {
@@ -8,12 +8,14 @@ import { ServerWalletsTable } from "./wallet-table/wallet-table";
export default async function TransactionsServerWalletsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const { team_slug, project_slug } = await props.params;
const { page } = await props.searchParams;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
@@ -30,6 +32,8 @@ export default async function TransactionsServerWalletsPage(props: {
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const pageSize = 10;
const currentPage = Number.parseInt(page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
@@ -37,10 +41,14 @@ export default async function TransactionsServerWalletsPage(props: {
auth: {
accessToken: managementAccessToken,
},
options: {},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [] }, error: null, success: true };
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
return (
<>
@@ -50,6 +58,9 @@ export default async function TransactionsServerWalletsPage(props: {
<div className="flex flex-col gap-8">
<ServerWalletsTable
wallets={eoas.data.items as Wallet[]}
totalRecords={eoas.data.totalRecords}
currentPage={currentPage}
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
project={project}
teamSlug={team_slug}
managementAccessToken={managementAccessToken ?? undefined}
@@ -19,12 +19,21 @@ import { useQuery } from "@tanstack/react-query";
import { formatDistanceToNowStrict } from "date-fns";
import { format } from "date-fns/format";
import { SendIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import { Button } from "../../../../../../../../../@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../../../../../../../../../@/components/ui/pagination";
import { useThirdwebClient } from "../../../../../../../../../@/constants/thirdweb.client";
import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter";
import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter";
@@ -36,11 +45,17 @@ export function ServerWalletsTableUI({
project,
teamSlug,
managementAccessToken,
totalRecords,
currentPage,
totalPages,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
}) {
const [showSigners, setShowSigners] = useState(false);
return (
@@ -121,6 +136,60 @@ export function ServerWalletsTableUI({
</TableBody>
</Table>
</TableContainer>
<div className="flex flex-col items-center p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} server wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
currentPage > 1 ? currentPage - 1 : 1
}`}
passHref
legacyBehavior
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
);
}
@@ -6,16 +6,25 @@ export function ServerWalletsTable({
wallets,
project,
teamSlug,
currentPage,
totalPages,
totalRecords,
managementAccessToken,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
}) {
return (
<ServerWalletsTableUI
wallets={wallets}
totalRecords={totalRecords}
currentPage={currentPage}
totalPages={totalPages}
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
@@ -36,12 +36,17 @@ export function TransactionDetailsUI({
transactionHash,
confirmedAt,
createdAt,
errorMessage,
executionParams,
executionResult,
} = transaction;
const status = executionResult?.status as keyof typeof statusDetails;
const errorMessage =
executionResult && "error" in executionResult
? executionResult.error
: executionResult && "revertData" in executionResult
? executionResult.revertData?.revertReason
: null;
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
@@ -9,6 +9,7 @@ import {
} from "components/analytics/date-range-selector";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
@@ -16,12 +17,16 @@ import type {
import {
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
getWalletUsers,
isProjectActive,
} from "@/api/analytics";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { RangeSelector } from "components/analytics/range-selector";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
@@ -116,6 +121,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<GenericLoadingPage />}>
<ProjectAnalytics
params={params}
project={project}
range={range}
interval={interval}
@@ -133,12 +139,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
}) {
const { project, range, interval, searchParams, client } = props;
const { project, params, range, interval, searchParams, client } = props;
// Fetch all analytics data in parallel
const [
@@ -147,6 +154,7 @@ async function ProjectAnalytics(props: {
inAppWalletUsage,
userOpUsageTimeSeries,
userOpUsage,
universalBridgeUsage,
] = await Promise.allSettled([
// Aggregated wallet connections
getWalletConnections({
@@ -187,22 +195,30 @@ async function ProjectAnalytics(props: {
to: range.to,
period: "all",
}),
// Universal Bridge
getUniversalBridgeUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: interval,
}),
]);
return (
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled" &&
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
<AppHighlightsCard
chartKey={
(searchParams.usersChart as
| "totalUsers"
| "activeUsers"
| "newUsers"
| "returningUsers") ?? "activeUsers"
(searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={params}
userStats={walletUserStatsTimeSeries.value}
volumeStats={universalBridgeUsage.value}
searchParams={searchParams}
/>
</div>
@@ -267,14 +283,14 @@ async function ProjectAnalytics(props: {
);
}
type UserMetrics = {
totalUsers: number;
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
returningUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = UserMetrics & {
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
@@ -283,52 +299,82 @@ type TimeSeriesMetrics = UserMetrics & {
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
let cumulativeUsers = 0;
for (const stat of userStats) {
cumulativeUsers += stat.newUsers ?? 0;
const volume = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
date: stat.date,
activeUsers: stat.totalUsers ?? 0,
returningUsers: stat.returningUsers ?? 0,
newUsers: stat.newUsers ?? 0,
totalUsers: cumulativeUsers,
totalVolume: volume,
feesCollected: fees,
});
}
return metrics;
}
function UsersChartCard({
function AppHighlightsCard({
chartKey,
userStats,
volumeStats,
params,
searchParams,
}: {
chartKey: keyof UserMetrics;
chartKey: keyof AggregatedMetrics;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
params: PageParams;
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats);
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
returningUsers: {
label: "Returning Users",
color: "hsl(var(--chart-4))",
totalVolume: {
label: "Total Volume",
color: "hsl(var(--chart-2))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Payments"
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
link="https://portal.thirdweb.com/connect/pay/overview"
/>
),
},
feesCollected: {
label: "Fee Revenue",
color: "hsl(var(--chart-4))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Fees"
description="Your app hasn't collected any fees yet."
link={`/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge/settings`}
/>
),
},
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
} as const;
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
title="App Highlights"
chartConfig={chartConfig}
activeChart={chartKey}
queryKey="usersChart"
queryKey="appHighlights"
data={timeSeriesData}
aggregateFn={(_data, key) =>
// If there is only one data point, use that one, otherwise use the previous
@@ -1,5 +1,8 @@
import { getTeamBySlug } from "@/api/team";
import { Button } from "@/components/ui/button";
import { PosthogIdentifierServer } from "components/wallets/PosthogIdentifierServer";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { EnsureValidConnectedWalletLoginServer } from "../../components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
@@ -28,6 +31,10 @@ export default async function RootTeamLayout(props: {
return (
<div className="flex min-h-dvh flex-col">
<div className="flex grow flex-col">
{team.billingPlan === "starter_legacy" && (
<StarterLegacyDiscontinuedBanner teamSlug={team_slug} />
)}
{team.billingStatus === "pastDue" && (
<PastDueBanner teamSlug={team_slug} />
)}
@@ -50,3 +57,31 @@ export default async function RootTeamLayout(props: {
</div>
);
}
function StarterLegacyDiscontinuedBanner(props: {
teamSlug: string;
}) {
return (
<div className="border-red-600 border-b bg-red-50 px-4 py-6 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100">
<div className="text-center">
<p>Starter legacy plans are being discontinued on May 31, 2025</p>
<p>
To prevent service interruptions and losing access to your current
features select a new plan
</p>
<Button
asChild
size="sm"
className="mt-3 gap-2 border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800"
>
<Link
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
>
Select a new plan
<ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</div>
);
}
@@ -41,7 +41,7 @@ export function BarChart({
}}
className="aspect-auto h-[250px] w-full pt-6"
>
{data.length === 0 ? (
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? (
<EmptyChartState> {emptyChartContent} </EmptyChartState>
) : (
<RechartsBarChart
@@ -87,7 +87,7 @@ export function BarChart({
});
}}
valueFormatter={(v: unknown) =>
isCurrency
isCurrency || chartConfig[activeKey]?.isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
@@ -5,7 +5,12 @@ import { BarChart } from "./BarChart";
import { Stat } from "./Stat";
type CombinedBarChartConfig<K extends string> = {
[key in K]: { label: string; color: string };
[key in K]: {
label: string;
color: string;
isCurrency?: boolean;
emptyContent?: React.ReactNode;
};
};
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
@@ -72,7 +77,7 @@ export function CombinedBarChartCard<
<Stat
label={chartConfig[key].label}
value={
isCurrency
isCurrency || chartConfig[key].isCurrency
? toUSD(aggregateFn(data, key) ?? 0)
: (aggregateFn(data, key) ?? 0)
}
@@ -95,6 +100,7 @@ export function CombinedBarChartCard<
chartConfig={chartConfig}
data={data}
activeKey={activeChart}
emptyChartContent={chartConfig[activeChart].emptyContent}
/>
</CardContent>
</Card>
@@ -6,25 +6,46 @@ import Link from "next/link";
export function EmptyStateCard({
metric,
link,
}: { metric: string; link?: string }) {
description,
}: { metric: string; link?: string; description?: string }) {
return (
<Card className="container h-[300px] p-2 md:h-[400px]">
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-md border text-center">
<div className="flex size-8 items-center justify-center rounded-md border bg-card text-muted-foreground">
<PlugIcon className="size-4" />
</div>
<div className="font-semibold text-lg">No data available</div>
<div className="text-muted-foreground text-sm">
Your app may not be configured to use {metric}.
</div>
{link && (
<Button asChild variant="primary" size="sm" className="mt-4">
<Link href={link} target="_blank" className="text-sm">
Configure {metric}
</Link>
</Button>
)}
<EmptyStateContent
metric={metric}
link={link}
description={description}
/>
</div>
</Card>
);
}
export function EmptyStateContent({
metric,
description,
link,
}: {
metric: string;
description?: string;
link?: string;
}) {
return (
<div className="flex w-full flex-col items-center justify-center gap-2 text-foreground">
<div className="flex size-8 items-center justify-center rounded-md border bg-card">
<PlugIcon className="size-4" />
</div>
<div className="font-semibold text-lg">No data available</div>
<div className="text-muted-foreground text-sm">
{description ?? `Your app may not be configured to use ${metric}.`}
</div>
{link && (
<Button asChild variant="primary" size="sm" className="mt-4">
<Link href={link} target="_blank" className="text-sm">
Configure {metric}
</Link>
</Button>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookThirdwebClient } from "../../../../../stories/utils";
import { type AssetBalance, AssetsSectionUI } from "./AssetsSection";
const meta = {
title: "Nebula/AssetsSection",
component: AssetsSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
} satisfies Meta<typeof AssetsSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const tokensStub: AssetBalance[] = [
{
chain_id: 8453,
token_address: "0xe8e55a847bb446d967ef92f4580162fb8f2d3f38",
name: "Broge",
symbol: "BROGE",
decimals: 18,
balance: "10000000000000000000000",
},
{
chain_id: 8453,
token_address: "0x8d2757ea27aabf172da4cca4e5474c76016e3dc5",
name: "clBTC",
symbol: "clBTC",
decimals: 18,
balance: "2",
},
{
chain_id: 8453,
token_address: "0xb56d0839998fd79efcd15c27cf966250aa58d6d3",
name: "BASED USA",
symbol: "USA",
decimals: 18,
balance: "1000000000000000000",
},
{
chain_id: 8453,
token_address: "0x600c9b69a65fb6d2551623a53ddef17b050233cd",
name: "BearPaw",
symbol: "PAW",
decimals: 18,
balance: "48888800000000000000",
},
{
chain_id: 8453,
token_address: "0x4c96a67b0577358894407af7bc3158fc1dffbeb5",
name: "Degen Point Of View",
symbol: "POV",
decimals: 18,
balance: "69000000000000000000",
},
{
chain_id: 8453,
token_address: "0x4200000000000000000000000000000000000006",
name: "Wrapped Ether",
symbol: "WETH",
decimals: 18,
balance: "6237535850425",
},
];
export const MultipleAssets: Story = {
args: {
data: tokensStub,
isPending: false,
client: storybookThirdwebClient,
},
};
export const SingleAsset: Story = {
args: {
data: tokensStub.slice(0, 1),
isPending: false,
client: storybookThirdwebClient,
},
};
export const EmptyAssets: Story = {
args: {
data: [],
isPending: false,
client: storybookThirdwebClient,
},
};
export const Loading: Story = {
args: {
data: [],
isPending: true,
client: storybookThirdwebClient,
},
};
@@ -0,0 +1,257 @@
import { Skeleton } from "@/components/ui/skeleton";
import { isProd } from "@/constants/env-utils";
import { useQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import Link from "next/link";
import {
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
defineChain,
getAddress,
toTokens,
} from "thirdweb";
import {
Blobbie,
TokenIcon,
TokenProvider,
useActiveAccount,
useActiveWalletChain,
} from "thirdweb/react";
import { getWalletBalance } from "thirdweb/wallets";
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
export type AssetBalance = {
chain_id: number;
token_address: string;
balance: string;
name: string;
symbol: string;
decimals: number;
};
export function AssetsSectionUI(props: {
data: AssetBalance[];
isPending: boolean;
client: ThirdwebClient;
}) {
if (props.data.length === 0 && !props.isPending) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
<div className="rounded-full border p-1">
<XIcon className="size-4" />
</div>
<div className="text-muted-foreground text-sm">No Assets </div>
</div>
);
}
return (
<div className="flex flex-col gap-1">
{!props.isPending &&
props.data.map((asset) => (
<AssetItem
key={`${asset.chain_id}-${asset.token_address}`}
asset={asset}
client={props.client}
/>
))}
{props.isPending &&
new Array(10).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<SkeletonAssetItem key={index} />
))}
</div>
);
}
function SkeletonAssetItem() {
return (
<div className="flex h-[48px] items-center gap-2 px-2 py-1">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-32 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</div>
);
}
function AssetItem(props: {
asset: AssetBalance;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(props.asset.chain_id);
const isNativeToken = props.asset.token_address === NATIVE_TOKEN_ADDRESS;
return (
<TokenProvider
address={props.asset.token_address}
client={props.client}
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.asset.chain_id)}
>
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
<div className="relative">
<TokenIcon
className="size-8 rounded-full border"
loadingComponent={
<Blobbie
address={props.asset.token_address}
className="size-8 rounded-full"
/>
}
fallbackComponent={
<Blobbie
address={props.asset.token_address}
className="size-8 rounded-full"
/>
}
/>
{!isNativeToken && (
<div className="-right-0.5 -bottom-0.5 absolute rounded-full border bg-background p-0.5">
<ChainIconClient
client={props.client}
className="size-3.5"
src={chainMeta?.icon?.url || ""}
/>
</div>
)}
</div>
<div className="flex min-w-0 flex-col text-sm">
<Link
href={
isNativeToken
? `https://thirdweb.com/${props.asset.chain_id}`
: `https://thirdweb.com/${props.asset.chain_id}/${props.asset.token_address}`
}
target="_blank"
className="truncate font-medium before:absolute before:inset-0"
>
{props.asset.name}
</Link>
<p className="truncate text-muted-foreground text-sm">
{`${toTokens(BigInt(props.asset.balance), props.asset.decimals)} ${props.asset.symbol}`}
</p>
</div>
</div>
</TokenProvider>
);
}
export function AssetsSection(props: {
client: ThirdwebClient;
}) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const assetsQuery = useQuery({
queryKey: ["v1/tokens/erc20", account?.address, activeChain?.id],
queryFn: async () => {
if (!account || !activeChain) {
return [];
}
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`,
);
url.searchParams.set("limit", "50");
url.searchParams.set("metadata", "true");
url.searchParams.set("include_spam", "false");
url.searchParams.set("clientId", nebulaAppThirdwebClient.clientId);
for (const chain of chains) {
url.searchParams.append("chain", chain.toString());
}
const response = await fetch(url.toString());
const json = (await response.json()) as {
data: AssetBalance[];
};
const tokensToShowOnTop = new Set(
[
// base
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // usdc,
"0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", // wbtc
"0x4200000000000000000000000000000000000006", // wrapped eth
// ethereum
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // usdc
"0xdac17f958d2ee523a2206206994597c13d831ec7", // usdt
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52", // bnb
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // weth
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", // wbtc
// optimism
"0x4200000000000000000000000000000000000042", // op token
"0xdc6ff44d5d932cbd77b52e5612ba0529dc6226f1", // world coin
"0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", // usdt
"0x0b2c639c533813f4aa9d7837caf62653d097ff85", // usdc
"0x4200000000000000000000000000000000000006", // wrapped eth
// polygon
"0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // weth
"0xc2132d05d31c914a87c6611c10748aeb04b58e8f", // usdt
"0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3", // bnb
"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // usdc
"0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // usdc.e
].map((x) => getAddress(x)),
);
return json.data.sort((a, b) => {
if (tokensToShowOnTop.has(getAddress(a.token_address))) {
return -1;
}
if (tokensToShowOnTop.has(getAddress(b.token_address))) {
return 1;
}
return 0;
});
},
enabled: !!account && !!activeChain,
});
const nativeBalances = useQuery({
queryKey: ["getWalletBalance", account?.address, activeChain?.id],
queryFn: async () => {
if (!account || !activeChain) {
return [];
}
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
const result = await Promise.allSettled(
chains.map((chain) =>
getWalletBalance({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(chain),
client: props.client,
address: account.address,
}),
),
);
return result
.filter((r) => r.status === "fulfilled")
.map((r) => ({
chain_id: r.value.chainId,
token_address: r.value.tokenAddress,
balance: r.value.value.toString(),
name: r.value.name,
symbol: r.value.symbol,
decimals: r.value.decimals,
}))
.filter((x) => x.balance !== "0") as AssetBalance[];
},
});
const isPending = assetsQuery.isPending || nativeBalances.isPending;
const data = [...(nativeBalances.data ?? []), ...(assetsQuery.data ?? [])];
return (
<AssetsSectionUI data={data} isPending={isPending} client={props.client} />
);
}
@@ -36,8 +36,10 @@ import {
AccountBlobbie,
AccountName,
AccountProvider,
WalletIcon,
WalletName,
WalletProvider,
useActiveWallet,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import type { Wallet } from "thirdweb/wallets";
@@ -451,8 +453,18 @@ function WalletSelector(props: {
selectedAddress: string | undefined;
}) {
const [open, setOpen] = useState(false);
const activeWallet = useActiveWallet();
if (!props.selectedAddress) {
const accountBlobbie = <AccountBlobbie className="size-3 rounded-full" />;
const accountAvatarFallback = (
<WalletIcon
className="size-3 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
if (!props.selectedAddress || !activeWallet) {
return null;
}
@@ -471,34 +483,32 @@ function WalletSelector(props: {
aria-expanded={open}
className="flex h-auto items-center gap-1 rounded-full px-2 py-1.5 text-xs"
>
<AccountProvider
address={props.selectedAddress}
client={props.client}
>
<AccountAvatar
className="size-4 rounded-full"
loadingComponent={
<Skeleton className="size-3 rounded-full border" />
}
fallbackComponent={
<AccountBlobbie className="size-3 rounded-full" />
}
/>
<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>
<WalletProvider id={activeWallet.id}>
<AccountProvider
address={props.selectedAddress}
client={props.client}
>
<AccountAvatar
className="size-3 rounded-full"
loadingComponent={accountAvatarFallback}
fallbackComponent={accountAvatarFallback}
/>
<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>
</WalletProvider>
</Button>
</PopoverTrigger>
@@ -513,81 +523,107 @@ function WalletSelector(props: {
</div>
<div className="[&>*:not(:last-child)]:border-b">
{sortedWallets.map((wallet) => (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
key={wallet.address}
className={cn(
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
props.selectedAddress === wallet.address && "bg-accent/50",
)}
onClick={() => {
setOpen(false);
props.onClick(wallet);
}}
>
<div className="flex items-center gap-2">
<AccountProvider address={wallet.address} client={props.client}>
<WalletProvider id={wallet.walletId}>
<div className="flex items-center gap-2">
<AccountAvatar
className="size-8 rounded-full"
loadingComponent={
<Skeleton className="size-8 rounded-full border" />
}
fallbackComponent={
<AccountBlobbie className="size-8 rounded-full" />
}
/>
{sortedWallets.map((wallet) => {
const accountBlobbie = (
<AccountBlobbie className="size-8 rounded-full" />
);
const accountAvatarFallback = (
<WalletIcon
className="size-8 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
<div>
<div className="flex items-center gap-1">
<AccountName
className="text-sm"
loadingComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
fallbackComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
return (
<div
role="button"
tabIndex={0}
key={wallet.address}
className={cn(
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
props.selectedAddress === wallet.address && "bg-accent/50",
)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setOpen(false);
props.onClick(wallet);
}
}}
onClick={() => {
setOpen(false);
props.onClick(wallet);
}}
>
<div className="flex items-center gap-2">
<WalletProvider id={wallet.walletId}>
<AccountProvider
address={wallet.address}
client={props.client}
>
<WalletProvider id={wallet.walletId}>
<div className="flex items-center gap-2.5">
<AccountAvatar
className="size-8 rounded-full"
loadingComponent={accountAvatarFallback}
fallbackComponent={accountAvatarFallback}
/>
<CopyButton address={wallet.address} />
<div className="flex flex-col">
<div className="flex items-center gap-1">
<AccountName
className="text-sm"
loadingComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
fallbackComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
/>
{wallet.walletId === "smart" && (
<Badge variant="outline" className="bg-card px-2">
Gasless
</Badge>
)}
</div>
<CopyButton address={wallet.address} />
<div className="text-muted-foreground text-xs">
{wallet.walletId === "smart" ? (
"Smart Account"
) : (
<WalletName
fallbackComponent={<span> Your Account </span>}
loadingComponent={
<Skeleton className="h-3.5 w-40" />
}
/>
)}
{wallet.walletId === "smart" && (
<Badge
variant="outline"
className="bg-card px-2"
>
Gasless
</Badge>
)}
</div>
<div className="text-muted-foreground text-sm">
{wallet.walletId === "smart" ? (
"Smart Account"
) : (
<WalletName
fallbackComponent={
<span> Your Account </span>
}
loadingComponent={
<Skeleton className="h-3.5 w-40" />
}
/>
)}
</div>
</div>
</div>
</div>
</div>
</WalletProvider>
</AccountProvider>
</WalletProvider>
</AccountProvider>
</div>
</div>
{props.selectedAddress === wallet.address && (
<CheckIcon className="size-4 text-foreground" />
)}
</div>
))}
{props.selectedAddress === wallet.address && (
<CheckIcon className="size-4 text-foreground" />
)}
</div>
);
})}
</div>
</PopoverContent>
</Popover>
@@ -17,7 +17,7 @@ export function ChatPageLayout(props: {
props.className,
)}
>
<aside className="hidden w-[280px] shrink-0 border-border border-r bg-card lg:block">
<aside className="hidden w-[300px] shrink-0 border-border border-r bg-card lg:block">
<ChatSidebar
sessions={props.sessions}
authToken={props.authToken}
@@ -1,10 +1,15 @@
"use client";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import {
ChevronDownIcon,
ChevronRightIcon,
FileCode2Icon,
LogOutIcon,
@@ -14,15 +19,27 @@ import {
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import {
AccountAvatar,
AccountBlobbie,
AccountProvider,
useActiveWallet,
} from "thirdweb/react";
import { WalletIcon, WalletName, WalletProvider } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { useIsClientMounted } from "../../../../components/ClientOnly/ClientOnly";
import { doNebulaLogout } from "../../login/auth-actions";
import type { TruncatedSessionInfo } from "../api/types";
import { useNewChatPageLink } from "../hooks/useNewChatPageLink";
import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides";
import { NebulaIcon } from "../icons/NebulaIcon";
import { nebulaAppThirdwebClient } from "../utils/nebulaThirdwebClient";
import { AssetsSection } from "./AssetsSection/AssetsSection";
import { ChatSidebarLink } from "./ChatSidebarLink";
import { NebulaConnectWallet } from "./NebulaConnectButton";
import { TransactionsSection } from "./TransactionsSection/TransactionsSection";
export function ChatSidebar(props: {
sessions: TruncatedSessionInfo[];
@@ -97,7 +114,11 @@ export function ChatSidebar(props: {
</div>
)}
<div className="mt-auto space-y-1 border-y border-dashed px-2 py-4">
<div className="mt-auto">
<WalletDetails />
</div>
<div className="flex items-center justify-center gap-2 border-t px-4 py-3">
<SidebarIconLink
href="https://portal.thirdweb.com/nebula"
icon={FileCode2Icon}
@@ -120,10 +141,122 @@ export function ChatSidebar(props: {
label="Log Out"
/>
</div>
</div>
);
}
<div className="[&>*]:!w-full p-4">
<NebulaConnectWallet detailsButtonClassName="!bg-background hover:!border-active-border" />
function WalletDetails() {
const [tab, setTab] = useState<"assets" | "transactions">("assets");
const [isExpanded, setIsExpanded] = useState(true);
return (
<DynamicHeight transition="height 220ms ease">
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
<CustomConnectButton />
<Button
variant="ghost"
size="sm"
className={cn(
"h-auto w-auto p-1.5 transition-transform duration-300",
isExpanded ? "rotate-180" : "",
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon className="size-4" />
</Button>
</div>
{isExpanded && (
<div className="border-t border-dashed">
<TabButtons
tabContainerClassName="px-2 pt-2"
tabClassName="!text-sm py-1.5"
tabs={[
{
name: "Assets",
onClick: () => setTab("assets"),
isActive: tab === "assets",
},
{
name: "Recent Activity",
onClick: () => setTab("transactions"),
isActive: tab === "transactions",
},
]}
/>
{isExpanded && (
<div className="h-[230px] overflow-y-auto py-4 pr-1 pl-2">
{tab === "assets" && (
<AssetsSection client={nebulaAppThirdwebClient} />
)}
{tab === "transactions" && (
<TransactionsSection client={nebulaAppThirdwebClient} />
)}
</div>
)}
</div>
)}
</DynamicHeight>
);
}
function CustomConnectButton() {
const activeWallet = useActiveWallet();
const accountBlobbie = <AccountBlobbie className="size-8 rounded-full" />;
const accountAvatarFallback = (
<WalletIcon
className="size-8 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
return (
<div className="[&>*]:!w-full grow">
<NebulaConnectWallet
detailsButtonClassName="!bg-background hover:!border-active-border"
customDetailsButton={
activeWallet
? (address) => {
return (
<WalletProvider id={activeWallet.id}>
<AccountProvider
address={address}
client={nebulaAppThirdwebClient}
>
<Button
variant="ghost"
className="flex h-auto w-full items-center justify-start gap-2.5 rounded-lg px-2 hover:bg-accent"
>
<AccountAvatar
className="size-8 rounded-full"
fallbackComponent={accountAvatarFallback}
loadingComponent={accountAvatarFallback}
/>
<div className="flex flex-col items-start justify-start">
<div className="text-sm">
{shortenAddress(address)}
</div>
<div className="text-muted-foreground text-sm">
{activeWallet.id === "smart" ? (
<span> Smart Wallet </span>
) : (
<WalletName
fallbackComponent={<span>Wallet</span>}
loadingComponent={<span>Wallet</span>}
/>
)}
</div>
</div>
</Button>
</AccountProvider>
</WalletProvider>
);
}
: undefined
}
/>
</div>
);
}
@@ -140,7 +273,7 @@ function ToggleThemeButton() {
icon={
isClientMounted ? (theme === "light" ? SunIcon : MoonIcon) : Spinner
}
label="Theme"
label="Toggle Theme"
/>
);
}
@@ -152,16 +285,22 @@ function SidebarIconLink(props: {
href: string;
}) {
return (
<Button
asChild
variant="ghost"
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
>
<Link href={props.href} target={props.target} prefetch={false}>
<props.icon className="size-4" />
{props.label}
</Link>
</Button>
<ToolTipLabel label={props.label}>
<Button
asChild
variant="ghost"
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
<Link
href={props.href}
target={props.target}
prefetch={false}
aria-label={props.label}
>
<props.icon className="size-5" />
</Link>
</Button>
</ToolTipLabel>
);
}
@@ -171,13 +310,15 @@ function SidebarIconButton(props: {
onClick: () => void;
}) {
return (
<Button
variant="ghost"
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
onClick={props.onClick}
>
<props.icon className="size-4" />
{props.label}
</Button>
<ToolTipLabel label={props.label}>
<Button
variant="ghost"
onClick={props.onClick}
aria-label={props.label}
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
<props.icon className="size-5" />
</Button>
</ToolTipLabel>
);
}
@@ -21,6 +21,7 @@ export const NebulaConnectWallet = (props: {
connectButtonClassName?: string;
signInLinkButtonClassName?: string;
detailsButtonClassName?: string;
customDetailsButton?: (address: string) => React.ReactElement;
}) => {
const router = useDashboardRouter();
const { theme } = useTheme();
@@ -63,6 +64,7 @@ export const NebulaConnectWallet = (props: {
);
}
const { customDetailsButton } = props;
return (
<ConnectButton
theme={getSDKTheme(t)}
@@ -90,6 +92,9 @@ export const NebulaConnectWallet = (props: {
}}
detailsButton={{
className: props.detailsButtonClassName,
render: customDetailsButton
? () => customDetailsButton(account.address)
: undefined,
}}
chains={allChainsV5}
// we have an AutoConnect already added in root layout with AA configuration
@@ -0,0 +1,153 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookThirdwebClient } from "../../../../../stories/utils";
import {
TransactionSectionUI,
type WalletTransaction,
} from "./TransactionsSection";
const meta = {
title: "Nebula/TransactionsSection",
component: TransactionSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
} satisfies Meta<typeof TransactionSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const transactionsStub: WalletTransaction[] = [
{
chain_id: "8453",
hash: "0x2a098695dcfa32a67ec115af7c8da1ef6f443ea72baf7e49525204dd521a985e",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "transfer",
signature: "transfer(address,uint256)",
inputs: {
to: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "10000",
},
},
},
{
chain_id: "8453",
hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1",
value: "1000000000000000",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
decoded: {
name: "",
signature: "",
inputs: null,
},
},
{
chain_id: "8453",
hash: "0xf2d92059c9ea425ccf7568bfe2589b3c7e45b108b5af658ec79c2f2d3723e410",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "1000000000000000",
decoded: {
name: "",
signature: "",
inputs: null,
},
},
{
chain_id: "8453",
hash: "0xea3da430876c09acfa665450a91edb99fe8dc018864c5dfa3ac53bf265ce8d66",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "transfer",
signature: "transfer(address,uint256)",
inputs: {
to: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
value: "100000",
},
},
},
{
chain_id: "8453",
hash: "0xad03e5b350645f2e4cdd066c30b2f6b708aa34bd4d1c5ca20fd81ecfe1656164",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "approve",
signature: "approve(address,uint256)",
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "10000000",
},
},
},
{
chain_id: "8453",
hash: "0xd3106aaa4b7e530ac7530c8ea4984eec52670aabaf5969ae6bc8d8246e74c3c0",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "0",
decoded: {
name: "initiateTransaction",
signature:
"initiateTransaction((bytes32,address,uint256,address,address,uint256,address,uint256,bytes,bytes),bytes)",
inputs: {},
},
},
{
chain_id: "8453",
hash: "0xd284b6e0dd938b4610ff1877c1d4692a8d10d83ec6be24789dc87e1ef4aa4756",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "approve",
signature: "approve(address,uint256)",
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "1000000",
},
},
},
];
export const MultipleAssets: Story = {
args: {
data: transactionsStub,
isPending: false,
client: storybookThirdwebClient,
},
};
export const SingleAsset: Story = {
args: {
data: transactionsStub.slice(0, 1),
isPending: false,
client: storybookThirdwebClient,
},
};
export const EmptyAssets: Story = {
args: {
data: [],
isPending: false,
client: storybookThirdwebClient,
},
};
export const Loading: Story = {
args: {
data: [],
isPending: true,
client: storybookThirdwebClient,
},
};
@@ -0,0 +1,205 @@
import { Skeleton } from "@/components/ui/skeleton";
import { isProd } from "@/constants/env-utils";
import { useQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
// Note: this is not the full object type returned from insight API, it only includes fields we care about
export type WalletTransaction = {
chain_id: string;
value: string;
hash: string;
from_address: string;
to_address: string;
decoded: {
name: string;
signature: string;
inputs: null | object;
};
};
export function TransactionSectionUI(props: {
data: WalletTransaction[];
isPending: boolean;
client: ThirdwebClient;
}) {
if (props.data.length === 0 && !props.isPending) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
<div className="rounded-full border p-1">
<XIcon className="size-4" />
</div>
<div className="text-muted-foreground text-sm">No Recent Activity</div>
</div>
);
}
return (
<div className="flex flex-col gap-1">
{!props.isPending &&
props.data.map((asset) => (
<TransactionInfo
key={asset.hash}
transaction={asset}
client={props.client}
/>
))}
{props.isPending &&
new Array(10).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<SkeletonAssetItem key={index} />
))}
</div>
);
}
function SkeletonAssetItem() {
return (
<div className="flex h-[48px] items-center gap-2 px-2 py-1">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-32 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</div>
);
}
function TransactionInfo(props: {
transaction: WalletTransaction;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.transaction.chain_id));
const title = getTransactionTitle(props.transaction);
const description = getTransactionDescription(props.transaction);
const explorer =
chainMeta?.explorers?.[0]?.url ||
`https://thirdweb.com/${props.transaction.chain_id}`;
return (
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
<ChainIconClient
client={props.client}
className="size-8 rounded-full border"
src={chainMeta?.icon?.url || ""}
/>
<div className="flex min-w-0 flex-col text-sm">
<Link
href={`${explorer}/tx/${props.transaction.hash}`}
target="_blank"
className="truncate font-medium capitalize before:absolute before:inset-0"
>
{title}
</Link>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</div>
);
}
function getTransactionTitle(transaction: WalletTransaction) {
if (transaction.decoded.name) {
return transaction.decoded.name;
}
if (transaction.decoded.signature) {
const nameFromSignature = transaction.decoded.signature.split("(")[0];
if (nameFromSignature) {
return nameFromSignature;
}
}
if (transaction.value !== "0") {
return "Transfer";
}
return "Transaction Sent";
}
function getTransactionDescription(transaction: WalletTransaction) {
if (
typeof transaction.decoded.inputs === "object" &&
transaction.decoded.inputs !== null
) {
if (
"to" in transaction.decoded.inputs &&
typeof transaction.decoded.inputs.to === "string"
) {
return `To: ${shortenAddress(transaction.decoded.inputs.to)}`;
}
if (
"spender" in transaction.decoded.inputs &&
typeof transaction.decoded.inputs.spender === "string"
) {
return `Spender: ${shortenAddress(transaction.decoded.inputs.spender)}`;
}
}
return `To: ${shortenAddress(transaction.to_address)}`;
}
export function TransactionsSection(props: {
client: ThirdwebClient;
}) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const txQuery = useQuery({
queryKey: ["v1/wallets/transactions", account?.address, activeChain?.id],
queryFn: async () => {
if (!account || !activeChain) {
return [];
}
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/wallets/${account.address}/transactions`,
);
url.searchParams.set("limit", "20");
url.searchParams.set("decode", "true");
url.searchParams.set("clientId", nebulaAppThirdwebClient.clientId);
const threeMonthsAgoUnixTime = Math.floor(
(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000,
);
url.searchParams.set(
"filter_block_timestamp_gte",
`${threeMonthsAgoUnixTime}`,
);
for (const chain of chains) {
url.searchParams.append("chain", chain.toString());
}
const response = await fetch(url.toString());
const json = (await response.json()) as {
data?: WalletTransaction[];
};
return json.data ?? [];
},
retry: false,
enabled: !!account && !!activeChain,
});
return (
<TransactionSectionUI
data={txQuery.data ?? []}
isPending={txQuery.isPending}
client={props.client}
/>
);
}
@@ -15,7 +15,7 @@ export function useSessionsWithLocalOverrides(
if (index !== -1) {
mergedSessions[index] = session;
} else {
mergedSessions.push(session);
mergedSessions.unshift(session);
}
}
@@ -16,7 +16,7 @@ import {
const STORAGE_KEY = "thirdweb:ub-analytics-range";
type SavedRange = {
rangeType: string;
rangeType: "custom" | DurationId | undefined;
interval: "day" | "week";
};
@@ -47,7 +47,10 @@ export function PayAnalyticsFilter() {
from: undefined,
to: undefined,
interval: savedRange.interval,
defaultRange: (savedRange.rangeType || "last-30") as DurationId,
defaultRange:
savedRange.rangeType === "custom"
? "last-30"
: savedRange.rangeType || "last-30",
});
setResponsiveSearchParams((v) => ({
@@ -57,7 +60,6 @@ export function PayAnalyticsFilter() {
interval: savedRange.interval,
}));
} catch (e) {
localStorage.removeItem(STORAGE_KEY);
console.error("Failed to parse saved range:", e);
}
}
@@ -3,7 +3,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
import { useId, useMemo } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { UniversalBridgeWalletStats } from "types/analytics";
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
type GraphDataItem = {
date: string;
@@ -20,7 +20,7 @@ export function PayNewCustomers(props: {
/**
* For each date, compute the total number of wallets that have never existed before in the time series
*/
const { graphData, trend } = useMemo(() => {
const { graphData } = useMemo(() => {
const dates = new Set<string>();
for (const item of props.data) {
if (!dates.has(item.date)) {
@@ -47,16 +47,7 @@ export function PayNewCustomers(props: {
value: newUsers,
});
}
const lastPeriod = newUsersData[newUsersData.length - 2];
const currentPeriod = newUsersData[newUsersData.length - 1];
// Calculate the percent change from last period to current period
const trend =
lastPeriod && currentPeriod && lastPeriod.value > 0
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
: lastPeriod?.value === 0
? 100
: 0;
return { graphData: newUsersData, trend };
return { graphData: newUsersData };
}, [props.data, props.dateFormat]);
const isEmpty = useMemo(
() => graphData.length === 0 || graphData.every((x) => x.value === 0),
@@ -86,17 +77,6 @@ export function PayNewCustomers(props: {
);
}}
/>
{!isEmpty && (
<SkeletonContainer
loadedData={trend}
className="rounded-2xl"
skeletonData={1}
render={(v) => {
return <ChangeBadge percent={v} />;
}}
/>
)}
</div>
</div>
</div>
@@ -122,7 +102,7 @@ export function PayNewCustomers(props: {
{payload?.date}
</p>
<p className="text-base text-medium">
Customers: {payload?.value}
New Customers: {payload?.value}
</p>
</div>
);
@@ -43,6 +43,7 @@ export function PaymentHistory(props: {
});
return res;
},
refetchInterval: 10_000,
});
const isEmpty = useMemo(
() => !payPurchaseData?.data.length,
@@ -68,8 +69,8 @@ export function PaymentHistory(props: {
<table className="w-full selection:bg-inverted selection:text-inverted-foreground ">
<thead>
<TableHeadingRow>
<TableHeading> Paid </TableHeading>
<TableHeading> Bought </TableHeading>
<TableHeading> Sent </TableHeading>
<TableHeading> Received </TableHeading>
<TableHeading>Type</TableHeading>
<TableHeading>Status</TableHeading>
<TableHeading>Recipient</TableHeading>
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { UniversalBridgeStats } from "types/analytics";
import { toUSD } from "../../../../utils/number";
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
type GraphData = {
date: string;
@@ -24,7 +24,7 @@ export function Payouts(props: {
props.data.every((x) => x.developerFeeUsdCents === 0);
const barColor = isEmpty ? "hsl(var(--accent))" : "hsl(var(--chart-1))";
const { graphData, totalPayoutsUSD, trend } = useMemo(() => {
const { graphData, totalPayoutsUSD } = useMemo(() => {
const dates = new Set<string>();
for (const item of props.data) {
if (!dates.has(item.date)) {
@@ -49,18 +49,9 @@ export function Payouts(props: {
value: total / 100,
});
}
const lastPeriod = cleanedData[cleanedData.length - 2];
const currentPeriod = cleanedData[cleanedData.length - 1];
const trend =
lastPeriod && currentPeriod && lastPeriod.value > 0
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
: lastPeriod?.value === 0
? 100
: 0;
return {
graphData: cleanedData,
totalPayoutsUSD: totalPayouts / 100,
trend,
};
}, [props.data, props.dateFormat]);
@@ -90,17 +81,6 @@ export function Payouts(props: {
);
}}
/>
{!isEmpty && (
<SkeletonContainer
className="rounded-2xl"
loadedData={trend}
skeletonData={1}
render={(percent) => {
return <ChangeBadge percent={percent} />;
}}
/>
)}
</div>
<div className="relative flex w-full justify-center">
@@ -24,11 +24,10 @@ export function TotalPayVolume(props: {
};
}) {
const uniqueId = useId();
const [successType, setSuccessType] = useState<"success" | "fail">("success");
const [type, setType] = useState<"all" | "crypto" | "fiat">("all");
const graphData: GraphData[] | undefined = useMemo(() => {
let data = (() => {
const data = (() => {
switch (type) {
case "crypto": {
return props.data?.filter((x) => x.type === "onchain");
@@ -45,13 +44,6 @@ export function TotalPayVolume(props: {
}
})();
data = (() => {
if (successType === "fail") {
return data.filter((x) => x.status === "failed");
}
return data.filter((x) => x.status === "completed");
})();
const dates = new Set<string>();
for (const item of data) {
if (!dates.has(item.date)) {
@@ -72,15 +64,13 @@ export function TotalPayVolume(props: {
});
}
return cleanedData;
}, [props.data, type, successType, props.dateFormat]);
}, [props.data, type, props.dateFormat]);
const isEmpty =
graphData.length === 0 || graphData.every((x) => x.value === 0);
const chartColor = isEmpty
? "hsl(var(--muted-foreground))"
: successType === "success"
? "hsl(var(--chart-1))"
: "hsl(var(--chart-3))";
: "hsl(var(--chart-1))";
return (
<div className="flex flex-1 flex-col">
@@ -104,21 +94,6 @@ export function TotalPayVolume(props: {
<SelectItem value="fiat">Fiat</SelectItem>
</SelectContent>
</Select>
<Select
value={successType}
onValueChange={(value: "success" | "fail") => {
setSuccessType(value);
}}
>
<SelectTrigger className="bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="success">Successful</SelectItem>
<SelectItem value="fail">Failed</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
@@ -1,7 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
export function NoDataOverlay() {
return (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-card/50 text-muted-foreground text-sm backdrop-blur-sm">
@@ -14,39 +10,6 @@ export function CardHeading(props: { children: React.ReactNode }) {
return <h3 className="font-medium text-base">{props.children}</h3>;
}
export function ChangeBadge(props: { percent: number }) {
const percentValue = `${props.percent.toFixed(0)}%`;
let label = "No change compared to prior range";
if (props.percent !== 0) {
label = `
${props.percent >= 0 ? "Increase" : "Decrease"} of ${percentValue} compared to prior range
`;
}
return (
<ToolTipLabel label={label}>
<div>
<Badge
variant={props.percent >= 0 ? "success" : "destructive"}
className="gap-1 px-2 py-1.5 text-sm"
>
{props.percent >= 0 ? (
<ArrowUpIcon className="size-4 " />
) : (
<ArrowDownIcon className="size-4" />
)}
{new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
signDisplay: "never",
}).format(props.percent)}
</Badge>
</div>
</ToolTipLabel>
);
}
export function TableData({ children }: { children: React.ReactNode }) {
return <td className="px-3 py-2 text-sm">{children}</td>;
}
-1
View File
@@ -28,7 +28,6 @@
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "1.2.3",
"@tanstack/react-query": "5.74.4",
"@thirdweb-dev/engine": "0.0.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
@@ -9,16 +9,64 @@ export function useEngineTxStatus(queueId: string | undefined) {
if (!queueId) throw new Error("No queue ID provided");
const res = await get_engine_tx_status(queueId);
const txStatus: EngineTxStatus = {
queueId: queueId,
status: res.result.status,
chainId: res.result.chainId,
transactionHash: res.result.transactionHash,
queuedAt: res.result.queuedAt,
sentAt: res.result.sentAt,
minedAt: res.result.minedAt,
cancelledAt: res.result.cancelledAt,
};
let txStatus: EngineTxStatus;
switch (res.status) {
case "QUEUED": {
txStatus = {
queueId: queueId,
status: "queued",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: null,
minedAt: null,
cancelledAt: null,
};
break;
}
case "SUBMITTED": {
txStatus = {
queueId: queueId,
status: "sent",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: res.createdAt,
minedAt: null,
cancelledAt: null,
};
break;
}
case "CONFIRMED": {
txStatus = {
queueId: queueId,
status: "mined",
chainId: res.chain.id.toString(),
transactionHash: res.transactionHash,
queuedAt: res.createdAt,
sentAt: res.confirmedAt,
minedAt: res.confirmedAt,
cancelledAt: null,
};
break;
}
case "FAILED": {
txStatus = {
queueId: queueId,
status: "errored",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: null,
minedAt: null,
cancelledAt: res.cancelledAt,
};
break;
}
default: {
throw new Error(`Unknown engine tx status: ${res}`);
}
}
return txStatus;
},
+61 -35
View File
@@ -1,12 +1,18 @@
"use server";
import { Engine } from "@thirdweb-dev/engine";
import { Engine, defineChain, encode, getContract } from "thirdweb";
import { multicall } from "thirdweb/extensions/common";
import * as ERC20 from "thirdweb/extensions/erc20";
import * as ERC1155 from "thirdweb/extensions/erc1155";
import { THIRDWEB_CLIENT } from "../../lib/client";
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
const ENGINE_VAULT_ACCESS_TOKEN = process.env
.ENGINE_VAULT_ACCESS_TOKEN as string;
const engine = new Engine({
url: process.env.ENGINE_URL as string,
accessToken: process.env.ENGINE_ACCESS_TOKEN as string,
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
export async function airdrop_tokens_with_engine(params: {
@@ -17,20 +23,37 @@ export async function airdrop_tokens_with_engine(params: {
amount: string;
}[];
}) {
const res = await engine.erc20.mintBatchTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
data: params.receivers,
},
const contract = getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
});
const data = await Promise.all(
params.receivers.map((receiver) =>
encode(
ERC20.mintTo({
contract,
to: receiver.toAddress,
amount: receiver.amount,
}),
),
),
);
const tx = multicall({
contract,
data,
});
return res.result;
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.transactionId;
}
export async function get_engine_tx_status(queueId: string) {
const status = await engine.transaction.status(queueId);
const status = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: queueId,
});
return status;
}
@@ -49,17 +72,19 @@ type MintNFTParams = {
};
export async function mint_erc1155_nft_with_engine(params: MintNFTParams) {
const res = await engine.erc1155.mintTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
receiver: params.toAddress,
metadataWithSupply: params.metadataWithSupply,
},
);
const tx = ERC1155.mintTo({
contract: getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
}),
nft: params.metadataWithSupply.metadata,
to: params.toAddress,
supply: BigInt(params.metadataWithSupply.supply),
});
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.result;
return res.transactionId;
}
type ClaimNFTParams = {
@@ -71,16 +96,17 @@ type ClaimNFTParams = {
};
export async function claim_erc1155_nft_with_engine(params: ClaimNFTParams) {
const res = await engine.erc1155.claimTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
receiver: params.receiverAddress,
quantity: params.quantity.toString(),
tokenId: params.tokenId,
},
);
const tx = ERC1155.claimTo({
contract: getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
}),
to: params.receiverAddress,
tokenId: BigInt(params.tokenId),
quantity: BigInt(params.quantity),
});
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.result;
return res.transactionId;
}
@@ -34,49 +34,49 @@ export function AirdropCode() {
}
const engineAirdropSendCode = `\
const chainId = ${airdropExample.chainId};
const contractAddress = "${airdropExample.contractAddress}";
const addresses = ${JSON.stringify(
airdropExample.receivers.map((x) => ({
address: x.toAddress,
quantity: x.amount,
recipient: x.toAddress,
amount: x.amount,
})),
null,
2,
)};
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/airdrop\`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
"Content-Type": "application/json",
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
},
body: JSON.stringify({ addresses }),
const contract = getContract({
address: ${airdropExample.contractAddress},
chain: defineChain(${airdropExample.chainId}),
client: THIRDWEB_CLIENT,
});
const data = await response.json();
const queueId = data.queueId;
const transaction = airdropERC20({
contract,
tokenAddress: ${airdropExample.contractAddress},
contents: addresses,
});
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
`;
const engineAirdropGetStatus = `\
function getEngineTxStatus(queueId: string) {
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
},
});
const data = await response.json();
return data.result;
}
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
const result = await getEngineTxStatus(queueId);
const result = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.status);
// or wait for the transaction to be mined (polls status until it's mined)
const result = await Engine.waitForTransactionHash({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.transactionHash);
`;
@@ -32,10 +32,10 @@ export function EngineAirdropPreview() {
const res = await airdropMutation.mutateAsync();
updateEngineTxStatus({
chainId: airdropExample.chainId,
queueId: res.queueId,
queueId: res,
});
setQueueId(res.queueId);
setQueueId(res);
};
return (
@@ -1,5 +1,5 @@
export const airdropExample = {
contractAddress: "0xcB30dB8FB977e8b27ae34c86aF16C4F5E428c0bE",
contractAddress: "0x6E238275023A2575136CF60f655B6B2C0C58b4ac",
chainId: 84532,
chainName: "Base Sepolia",
chainExplorer: "https://base-sepolia.blockscout.com",
@@ -13,7 +13,7 @@ export function MintCode() {
<div className="h-4" />
<h3 className="mb-2 font-semibold text-xl tracking-tight">
Send Transaction Request to Mint NFTs
Send Transaction Request to Mint Dynamic NFTs
</h3>
<Code code={engineMintCode} lang="typescript" />
@@ -36,48 +36,45 @@ export function MintCode() {
const engineMintCode = `\
const chainId = ${mintExample.chainId};
const contractAddress = "${mintExample.contractAddress}";
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/mint-to\`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
"Content-Type": "application/json",
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
},
body: JSON.stringify({
receiver: "0x....",
metadataWithSupply: {
metadata: {
name: "...",
description: "...",
image: "...", // ipfs or https link to your asset
},
supply: "1",
},
})
const contract = getContract({
address: ${mintExample.contractAddress},
chain: defineChain(${mintExample.chainId}),
client: THIRDWEB_CLIENT,
});
const data = await response.json();
console.log(data.queueId);
const transaction = mintTo({
contract,
to: "0x....",
nft: {
name: "...",
description: "...",
image: "...", // ipfs or https link to your asset
},
supply: "1",
});
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
`;
const getEngineStatusCode = `\
function getEngineTxStatus(queueId: string) {
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
},
});
const data = await response.json();
return data.result;
}
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
const result = await getEngineTxStatus(queueId);
const result = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.status);
// or wait for the transaction to be mined (polls status until it's mined)
const result = await Engine.waitForTransactionHash({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.transactionHash);
`;
@@ -231,10 +231,10 @@ export function EngineMintPreview() {
});
// optimistic update
queryClient.setQueryData(["engineTxStatus", result.queueId], {
queryClient.setQueryData(["engineTxStatus", result], {
status: "queued",
chainId: mintExample.chainId.toString(),
queueId: result.queueId,
queueId: result,
transactionHash: null,
queuedAt: new Date().toISOString(),
sentAt: null,
@@ -242,7 +242,7 @@ export function EngineMintPreview() {
cancelledAt: null,
} satisfies EngineTxStatus);
setQueueId(result.queueId);
setQueueId(result);
};
return (
@@ -1,5 +1,5 @@
export const mintExample = {
contractAddress: "0x8CD193648f5D4E8CD9fD0f8d3865052790A680f6",
contractAddress: "0x8a4E14591088bBce4a4Dcfa677C1af78d336d6FB",
chainId: 84532,
chainName: "Base Sepolia",
chainExplorer: "https://base-sepolia.blockscout.com",
@@ -7,7 +7,7 @@ export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
title="Mint NFTs"
title="Mint Dynamic NFTs"
description={
<>
Allow your users to mint new tokens into any given contract. You
@@ -71,7 +71,7 @@ export function EngineWebhooksPreview() {
async function onSubmit(values: z.infer<typeof formSchema>) {
const res = await claimMutation.mutateAsync(values.receiverAddress);
setQueueId(res.queueId);
setQueueId(res);
}
return (
+24 -1
View File
@@ -117,6 +117,21 @@ export const connectLinks: Array<{
},
] as const;
const apisLinks = [
{
name: "Insight",
href: "https://insight-api.thirdweb.com/reference",
},
{
name: "Engine Cloud",
href: "https://engine.thirdweb.com/reference#tag/write",
},
{
name: "Universal Bridge",
href: "https://bridge.thirdweb.com/reference",
},
];
const supportLinks = [
{
name: "Get thirdweb support",
@@ -221,7 +236,15 @@ export function Header() {
<DocSearch variant="search" />
</div>
<div className="xl:px-2">
<div className="xl:px-1">
<DropdownLinks
links={apisLinks}
onLinkClick={() => setShowBurgerMenu(false)}
category="APIs"
/>
</div>
<div className="xl:px-1">
<DropdownLinks
links={supportLinks}
onLinkClick={() => setShowBurgerMenu(false)}
@@ -95,7 +95,7 @@ You can specify which onramp provider to present to your users. By default, we c
client={client}
detailsModal={{
payOptions: {
preferredProvider: "STRIPE" | "KADO" | "TRANSAK",
preferredProvider: "STRIPE" | "TRANSAK" | "COINBASE",
},
}}
/>
+10 -1
View File
@@ -29,6 +29,15 @@ This can include intents, native bridges, third party bridges, cross-chain messa
health, price, and speed to guarantee the best path every time.
</Details>
<Details summary="Am I able to use Universal Bridge with other wallet infrastructure providers?">
Yes, Universal Bridge is designed to be agnostic to wallet infrastructure. You can use it with any wallet service.
</Details>
<Details summary="Does Universal Bridge support Apple Pay or Google Pay?">
Apple Pay and Google Pay are supported through the onramp providers depending on region. Coinbase, Stripe, and Transak currently support Apple Pay. Transak supports Google Pay. [Learn more about the supported onramp providers.](/connect/pay/onramp-providers).
</Details>
<Details summary="What are the KYC requirements?">
Onramp options all require minimal KYC. This is done to comply with local regulations and to ensure the safety of our users.
Please note: onramping with Coinbase does not require KYC for a purchase below $500 with debit card.
@@ -41,7 +50,7 @@ Our transaction maximum starts from $1500 per week for new users and can increas
</Details>
<Details summary="What countries does Buy With Fiat support?">
**Buy With Fiat** is available 130+ countries. The following countries are _UNSUPPORTED_:
**Buy With Fiat** is available 160+ countries. The following countries are _UNSUPPORTED_:
- Afghanistan
- Africa (All Countries)
@@ -12,14 +12,30 @@ export const metadata = createMetadata({
# Onramp Providers
thirdweb Universal Bridge integrates with Stripe, Kado and Transak to power our onramp. By default, we choose a recommended provider based on the location of the user, KYC status, and currency.
**Onramp Providers** are services that allow users to buy crypto with fiat. You can set preferred providers for your application by using the [`preferredProvider`](/connect/pay/customization/payembed#preferred-provider) parameter in the `PayEmbed` component.
Universal Bridge supports the following onramp providers:
| Provider | Supported Countries |
| -------- | ------------------------------------------------------------------------------------------------------- |
| Transak | https://transak.notion.site/On-Ramp-Payment-Methods-Fees-Other-Details-b0761634feed4b338a69f4f186d906a5 |
| Kado | https://www.kado.money/supported-countries |
| Stripe | https://docs.stripe.com/crypto/onramp |
- **Coinbase**
- **Transak**
- **Stripe**
## Choosing a Preferred Provider
## Provider Fees
It is possible to specify a preferred onramp provider by setting the `preferredProvider` parameter in the ConnectButton, PayEmbed, or Headless Integration. Go to [customizations](../pay/customization/connectbutton#preferredProvider) for more detail, or checkout the [API Reference for preferredProvider](https://portal.thirdweb.com/references/typescript/v5/GetBuyWithFiatQuoteParams).
Each provider has different fees associated with their services which often depend on various factors. Onramp provider fees are set by the provider and are not controlled by thirdweb.
| Provider | Fees |
| -------- | ---- |
| Coinbase | [Learn more about Coinbase Fees](https://help.coinbase.com/en/coinbase/trading-and-funding/pricing-and-fees/fees) |
| Transak | [Learn more about Transak Fees](https://support.transak.com/en/articles/7845942-how-does-transak-calculate-prices-and-fees)|
| Stripe | [Learn more about Stripe Fees](https://stripe.com/legal/crypto-onramp). |
## Provider Regions
Each provider has different regions they support. You can learn more about the supported regions for each provider in the links below.
| Provider | Regions |
| -------- | ------- |
| Coinbase | [View Regions](https://docs.cdp.coinbase.com/onramp/docs/api-configurations) |
| Transak | [View Regions](https://transak.com/global-coverage) |
| Stripe | [View Regions](https://support.stripe.com/questions/crypto-supportability-and-availability-by-region) |
+4
View File
@@ -241,6 +241,10 @@ export const sidebar: SideBar = {
name: "Supported Routes",
href: "https://thirdweb.com/routes",
},
{
name: "Onramp Providers",
href: `${paySlug}/onramp-providers`,
},
{
name: "Service Fees",
href: `${paySlug}/fees`,
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,66 @@
import { DocImage } from "@doc";
import { Tabs, TabsList, TabsContent } from "@/components/ui/tabs";
import stylusPublishPage from "./assets/stylus-publish-page.png";
import stylusDeployPage from "./assets/stylus-deploy-page.png";
# Arbitrum Stylus
Integrate thirdweb tools in Stylus contract (Rust) development workflow.
Use thirdweb CLI to create, publish, and deploy contracts written with Stylus. The deployed contracts can be used via thirdweb dashboard or SDK.
## Prerequisites
- Rust toolchain should be installed (https://www.rust-lang.org/tools/install)
- Solidity (solc) should be installed (https://docs.soliditylang.org/en/latest/installing-solidity.html)
You can also use the Stylus quickstart guide for reference: https://docs.arbitrum.io/stylus/quickstart
## Getting started
To get started, setup a starter repo (can be skipped if you already have a Stylus project):
<Tabs defaultValue="forge">
<TabsContent value="forge">
```bash
npx thirdweb create-stylus
```
</TabsContent>
</Tabs>
This will setup a project containing a template contract.
## Publishing and Deploying
Use one of these commands to publish or deploy your Stylus contract.
<Tabs defaultValue="forge">
<TabsContent value="forge">
```bash
npx thirdweb publish-stylus -k <YOUR SECRET KEY>
```
</TabsContent>
</Tabs>
OR
<Tabs defaultValue="forge">
<TabsContent value="forge">
```bash
npx thirdweb deploy-stylus -k <YOUR SECRET KEY>
```
</TabsContent>
</Tabs>
Publishing a contract saves the contract metadata to an onchain registry, and creates a contract page from where you can deploy a specific version of this contract multiple times.
This is what you should see:
<DocImage src={stylusPublishPage} />
After deployment, the contract will be available on thirdweb dashboard. You can interact with it via dashboard or integrate it in your app using the code snippets as shown below:
<DocImage src={stylusDeployPage} />
More templates for Stylus Rust contracts and use-case specific installable modules are coming soon.
@@ -799,6 +799,11 @@ export const sidebar: SideBar = {
},
],
},
// stylus
{
name: "Arbitrum Stylus",
href: `${buildSlug}/stylus`,
},
],
},
{ separator: true },
Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

+9 -1
View File
@@ -2,4 +2,12 @@ import { Callout, Details } from "@doc";
# Engine FAQs
More information coming soon.
<Details summary="How is pricing calculated for Engine Cloud?">
Pricing is calculated through the number of write requests (ex: /v1/write/contract) made through the Engine API. Requests cost $1 per 1,000 requests. Read requests made through Engine API are free, within the RPC limits/plan.
For transactions through server wallets paid through the user's thirdweb account, users will pay a 5% premium on the gas fee of each transaction completed on any mainnet as part of account abstraction fees. There are no gas charges for testnets.
**Please note: Legacy pricing plans may be subject to the 10% fee instead of 5%. Please update your pricing plan to avoid being subject to the 10% fee.**
The breakdown for usage and transaction fees can be found in your usage dashboard under the team overview.
</Details>
@@ -1,4 +1,6 @@
import { Callout, Step, Steps, Tabs, TabsContent, TabsTrigger, TabsList } from "@doc";
import { Callout, Step, Steps, Tabs, TabsContent, TabsTrigger, TabsList, DocImage } from "@doc";
import CreateVault from '../assets/create-vault.png';
import SendTransaction from '../assets/send-transaction.png';
# Get started
@@ -19,7 +21,8 @@ Learn how to get started with thirdweb Engine. This guide will walk you through
Create a vault to manage your Engine's server wallets. After setup, you can manage your Vault to create additional access tokens or rotate admin keys.
<Callout variant="info" title="Vault">
Vault is thirdweb's key management service designed to store smart server wallets non-custodially. Learn more about [Vault].
Vault is thirdweb's key management service designed to store smart server wallets non-custodially. [Learn more about Vault.](/vault)
<DocImage src={CreateVault} alt="Create Vault"/>
</Callout>
</Step>
@@ -35,6 +38,7 @@ Learn how to get started with thirdweb Engine. This guide will walk you through
To verify your server wallet setup and see how transactions work, you can send a test transaction in the next step. This sends a no-op transaction—a transaction with zero value—to your own wallet.
You can send additional test transactions or proceed with the full setup whenever you're ready.
<DocImage src={SendTransaction} alt="Send Test Transaction"/>
</Step>
<Step title="Integrate with your app">
@@ -1,6 +1,12 @@
import { Callout } from "@doc";
# 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.
Learn how to migrate your applications from Engine v2 to Engine v3. We'll cover common operations and highlight the differences in API requests and authentication.
<Callout variant="info" title="Migrating Wallets">
If you'd like to migrate your wallets from v2 to v3 to also be secured by [Vault](/vault) or thirdweb's key management service, please [contact us for assistance.](https://thirdweb.com/support)
</Callout>
## Writing to a contract
+1 -1
View File
@@ -84,7 +84,7 @@ Looking for the previous version? [Head back to the Engine V2 documentation.](/e
| Wallets | Server Wallets | Server Wallets, AWS, GCP, Circle |
| Database Backups | ❌ | 30 Day Backups |
| Infrastructure | Shared | Isolated |
| Rate Limits | [Limited by RPC Plan](https://thirdweb.com/pricing) | Unlimited |
| Rate Limits | Unlimited | Unlimited |
| Pricing | [See pricing page](https://thirdweb.com/pricing) | [See pricing page](https://thirdweb.com/pricing) |
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/insight
## 3.0.1
### Patch Changes
- [#7050](https://github.com/thirdweb-dev/js/pull/7050) [`ae2ff74`](https://github.com/thirdweb-dev/js/commit/ae2ff743c05be7267e904ece9098601794b10dd9) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update openAPI spec
## 1.0.0
### Major Changes
+22 -7
View File
@@ -5,23 +5,38 @@ This package is a thin openAPI wrapper for insight, our in-house indexer.
## Configuration
```ts
import { configure } from "@thirdweb-dev/insight";
import { configure } from "@thirdweb-dev/engine";
// call this once at the startup of your application
configure({
clientId: "<YOUR_CLIENT_ID>",
secretKey: "<PROJECT_SECRET_KEY>",
});
```
## Example Usage
```ts
import { getV1Events } from "@thirdweb-dev/insight";
import { writeContract } from "@thirdweb-dev/engine";
const events = await getV1Events({
query: {
chain: [1, 137],
filter_address: "0x1234567890123456789012345678901234567890",
const result = await writeContract({
headers: {
"x-vault-access-token": "<VAULT_ACCESS_TOKEN>",
},
body: {
executionOptions: {
from: "0x1234567891234567891234567891234567891234",
chainId: "1",
},
params: [
{
contractAddress: "0x1234567890123456789012345678901234567890",
method: "function transfer(address to, uint256 amount)",
params: [
"0x1234567890123456789012345678901234567890",
"1000000000000000000",
],
},
],
},
});
```
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "http://localhost:3009/openapi", // TODO: update to prod
input: "https://engine.thirdweb.com/openapi",
output: { path: "src/client", lint: "biome", format: "biome" },
plugins: ["@hey-api/client-fetch"],
});
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/engine",
"version": "3.0.0",
"version": "3.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
@@ -23,7 +23,10 @@
},
"./package.json": "./package.json"
},
"files": ["dist/*", "src/*"],
"files": [
"dist/*",
"src/*"
],
"dependencies": {
"@hey-api/client-fetch": "0.10.0"
},
+1 -1
View File
@@ -23,6 +23,6 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(
createConfig<ClientOptions>({
baseUrl: "http://localhost:3009",
baseUrl: "https://engine.thirdweb.com",
}),
);
+6 -1
View File
@@ -144,6 +144,7 @@ export type WriteContractResponses = {
transactions: Array<{
id: string;
batchIndex: number;
clientId: string;
chainId: string;
from: string | null;
transactionParams:
@@ -188,6 +189,7 @@ export type WriteContractResponses = {
transactions: Array<{
id: string;
batchIndex: number;
clientId: string;
chainId: string;
from: string | null;
transactionParams:
@@ -273,6 +275,7 @@ export type SendTransactionResponses = {
transactions: Array<{
id: string;
batchIndex: number;
clientId: string;
chainId: string;
from: string | null;
transactionParams:
@@ -317,6 +320,7 @@ export type SendTransactionResponses = {
transactions: Array<{
id: string;
batchIndex: number;
clientId: string;
chainId: string;
from: string | null;
transactionParams:
@@ -876,6 +880,7 @@ export type SearchTransactionsResponses = {
transactions: Array<{
id: string;
batchIndex: number;
clientId: string;
chainId: string;
from: string | null;
transactionParams:
@@ -1013,5 +1018,5 @@ export type GetTransactionAnalyticsSummaryResponse =
GetTransactionAnalyticsSummaryResponses[keyof GetTransactionAnalyticsSummaryResponses];
export type ClientOptions = {
baseUrl: "http://localhost:3009" | (string & {});
baseUrl: "https://engine.thirdweb.com" | (string & {});
};
+15
View File
@@ -1,5 +1,20 @@
# thirdweb
## 5.99.2
### Patch Changes
- [#7065](https://github.com/thirdweb-dev/js/pull/7065) [`61152dd`](https://github.com/thirdweb-dev/js/commit/61152dd0984adc36fdcd722e1382c716c6bf2368) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Return timestamps in Engine.getTransactionStatus()
- [#7069](https://github.com/thirdweb-dev/js/pull/7069) [`da2a2a0`](https://github.com/thirdweb-dev/js/commit/da2a2a0e86f6ab5b919667964e001f16e20326ad) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix buyWithCrypto false not respected when going back from quote
- [#7040](https://github.com/thirdweb-dev/js/pull/7040) [`20b5ba9`](https://github.com/thirdweb-dev/js/commit/20b5ba943a3c22633c7c49ba82104a6057be10b0) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Propagate 401 errors when connecting in-app wallet
- [#7061](https://github.com/thirdweb-dev/js/pull/7061) [`8d47864`](https://github.com/thirdweb-dev/js/commit/8d478649678fa2016bd6a6b72873dd1aaa2f5e05) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Expose prepareUserOp utility function
- Updated dependencies [[`ae2ff74`](https://github.com/thirdweb-dev/js/commit/ae2ff743c05be7267e904ece9098601794b10dd9)]:
- @thirdweb-dev/engine@3.0.1
## 5.99.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "thirdweb",
"version": "5.99.1",
"version": "5.99.2",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
@@ -49,6 +49,9 @@ export type ExecutionResult = Prettify<
chain: Chain;
from: string | undefined;
id: string;
createdAt: string;
confirmedAt: string | null;
cancelledAt: string | null;
}
>;
@@ -105,6 +108,9 @@ export async function getTransactionStatus(args: {
const executionResult = data.executionResult as ExecutionResult4337Serialized;
return {
...executionResult,
createdAt: data.createdAt,
confirmedAt: data.confirmedAt,
cancelledAt: data.cancelledAt,
chain: getCachedChain(Number(data.chainId)),
from: data.from ?? undefined,
id: data.id,
@@ -5,6 +5,7 @@ export {
createUnsignedUserOp,
signUserOp,
createAndSignUserOp,
prepareUserOp,
getUserOpHash,
} from "../../wallets/smart/lib/userop.js";
@@ -510,8 +510,9 @@ function BuyScreenContent(props: BuyScreenContentProps) {
client={client}
onBack={() => {
if (
screen.id === "buy-with-crypto" ||
screen.id === "buy-with-fiat"
(screen.id === "buy-with-crypto" ||
screen.id === "buy-with-fiat") &&
enabledPaymentMethods.buyWithCryptoEnabled
) {
setScreen({
id: "select-from-token",
@@ -3137,6 +3137,79 @@ export async function getWalletInfo<TImage extends boolean>(
: import("./wallet/app.imem/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "com.socios.app": {
return (
image
? import("./wallet/com.socios.app/image.js").then(
(img) => img.default,
)
: import("./wallet/com.socios.app/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "io.bimwallet": {
return (
image
? import("./wallet/io.bimwallet/image.js").then((img) => img.default)
: import("./wallet/io.bimwallet/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "com.adftechnology": {
return (
image
? import("./wallet/com.adftechnology/image.js").then(
(img) => img.default,
)
: import("./wallet/com.adftechnology/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "com.reown.appkit-lab": {
return (
image
? import("./wallet/com.reown.appkit-lab/image.js").then(
(img) => img.default,
)
: import("./wallet/com.reown.appkit-lab/index.js").then(
(w) => w.wallet,
)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "com.reown": {
return (
image
? import("./wallet/com.reown/image.js").then((img) => img.default)
: import("./wallet/com.reown/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "com.reown.docs": {
return (
image
? import("./wallet/com.reown.docs/image.js").then(
(img) => img.default,
)
: import("./wallet/com.reown.docs/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "io.yowallet": {
return (
image
? import("./wallet/io.yowallet/image.js").then((img) => img.default)
: import("./wallet/io.yowallet/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "org.hot-labs": {
return (
image
? import("./wallet/org.hot-labs/image.js").then((img) => img.default)
: import("./wallet/org.hot-labs/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "ai.purewallet": {
return (
image
? import("./wallet/ai.purewallet/image.js").then((img) => img.default)
: import("./wallet/ai.purewallet/index.js").then((w) => w.wallet)
) as Promise<[TImage] extends [true] ? string : any>;
}
case "io.walletverse": {
return (
image
+11 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by the `scripts/wallets/generate.ts` script.
// Do not modify this file manually.
// 403 wallets
// 413 wallets
export type WCSupportedWalletIds =
| "io.metamask"
| "com.trustwallet.app"
@@ -404,6 +404,16 @@ export type WCSupportedWalletIds =
| "network.trustkeys"
| "finance.voltage"
| "app.imem"
| "com.socios.app"
| "io.bimwallet"
| "com.adftechnology"
| "com.reown.appkit-lab"
| "com.reown"
| "com.reown.docs"
| "com.reown"
| "io.yowallet"
| "org.hot-labs"
| "ai.purewallet"
| "io.walletverse"
| "com.berasig";
+46 -1
View File
@@ -201,7 +201,7 @@ const ALL_MINIMAL_WALLET_INFOS = (<const>[
},
{
id: "com.fastex.wallet",
name: "Fastex Wallet",
name: "Yo Wallet",
hasMobileSupport: true,
},
{
@@ -1974,6 +1974,51 @@ const ALL_MINIMAL_WALLET_INFOS = (<const>[
name: "iMe",
hasMobileSupport: true,
},
{
id: "com.socios.app",
name: "Socios.com - Wallet & Tokens",
hasMobileSupport: true,
},
{
id: "io.bimwallet",
name: "BIM Wallet",
hasMobileSupport: true,
},
{
id: "com.adftechnology",
name: "ADF Wallet",
hasMobileSupport: true,
},
{
id: "com.reown.appkit-lab",
name: "Flutter Sample Wallet",
hasMobileSupport: true,
},
{
id: "com.reown",
name: "React Native Sample Wallet",
hasMobileSupport: true,
},
{
id: "com.reown.docs",
name: "Kotlin Sample Internal Wallet",
hasMobileSupport: true,
},
{
id: "io.yowallet",
name: "YoWallet",
hasMobileSupport: true,
},
{
id: "org.hot-labs",
name: "HOT Wallet",
hasMobileSupport: true,
},
{
id: "ai.purewallet",
name: "PureWallet app",
hasMobileSupport: true,
},
{
id: "io.walletverse",
name: "Walletverse",
@@ -0,0 +1,7 @@
// This file is auto-generated by the `scripts/wallets/generate.ts` script.
// Do not modify this file manually.
const image =
"data:image/webp;base64,UklGRgQFAABXRUJQVlA4IPgEAAAwIACdASqAAIAAPm0ylEakIyIhrhJJGIANiWIAz9RWEa/HcsjzP4bxBaed5gHOo8xXm3/2P1Rfrt+M3yAfsB1i/oFftN1tH7l/srYd69Tf/mL/QMZfNJ80EKEGulnbAUcBnTCirNK/1Ubo5TRGY/cp41APZRCzbWT2WvcyhYb6chB3YziLFGYRI+xb+iHU0fshtIBMoseO5v//Q8+W7fcLDKmjr7QxAZ1pq2faN//9XO80B0xVraVlJk52BeRI3ka/DHx65tWO4OzJU3yHjPYT6qR6MX41Gq0QyrvEU0s5IlQP5qweywIJAADiwLwtPy85pYcE1qRNTnBAikdJPCe4Qr/16x/3/PELgAD+/KgPJb+mk+tPXLY04UQnLyKAaX9mzAZworFvQfVX7/5Opy/7rv8vTN71BG77JKCYGBk6XWok8T9RVfgAbbRY8479+6Sl1Ij1VrzFpD+u/qZkx2EkIpH74jkeOnl9BUvcYui0HQHATy3PN20vP0QqkekGeNJ7jHPD98Q3Qen6sZeOE8NhdCwWmmsAs6ZRlQxJzwzWf76IsYo8GpasuDYRco6eUgZeFDuPpmcu+Yy8pI57y0InWYv05Jw4P7VMc/+CLZKJh3OJblL2vIP8RqH4a/ivVS/0vzeN+nE6XNYRcHpTIF2H0hQLafGmcQzLBwbS7jXVrT8ksE23UMP75O92c/o1WEE+ada6R6PgoWPtnzH/zqMbgKTb1nS1K1YYS/OBVSkg1bp4LsKKR13SLxjMHYZ7CeMi7OuztBhmqHVYm0NQPp/RXIxwkYYb2gij3fZCPYk7txETutPjXyhq3JpvscHCmrVaSOvOSFEF6hpYHXlJtDe1V6gsIxVdYobhUflfP+naF+J08LdEypc5l6layhVLUtFqtNM65hkLQpZbl6FWJf7PWf6P2FFXklOquhAf9QjNddtpXUPoGWWEYcbGBlzr98vjnKfbD4QtMG0loff1cfog7ce9SYKlLjfJiNJT4HKe/ZQqeMQ5VaJoQPGhrM3p87fq3BrcEukDX4V2fb7PM8zVDKuC+2vnT8U3CmgX0+XEZoto/SKF16O9wYUcN16VKN4gfXcZ4fKflWkSye3jN/yZWKYwPAON5vP1DfMEq4wBhkN1Hwq1VgzVQeBCVwFqcGNacc/ipVr44aA0M7n2/HQo1LKvrEg2ceWFByhlGYd7TwCWsPrl3Jnqd4t1+/ihc+vltUR8D42H+FO/K8/q56EscLiqN+BJffyQQPhvVtxT1E8UmJIO3BPbYwlYfnpV/6BYuJkZSr/jz7bKJZuAOMwSu2cPp3U14Ko46VVy6oAnhpRIMI2ERdey0+Q+5jETJawhy6VnYycH1W5a5GFuvS62aJFxxgRvhT/C0I+b1gpz1fZoPz8QgqK5JZxwK97fbTF7XoAAkxOHiUHcjluKSEyIIuGyStPKtFk7JKx783eP+UHNXot4w61O1HguL5vBgQ3ehKd9Eg5+yfdWIHs25GnXwfYDuhsXKbv1JigmxT48QzPfkR5AAAJoXi9wmOxQI8dug241k/XRlUaJ3tEUg31G856xaXEo4kloiLq/v+vSrBHNyrXX31umKq8UgMnzVQOJY0Uri8fznGX0X2MmMr4IkBh1risIAUfTE/SIIwgnWkuEjUGcucXpVSRbQh7I7QHs1Y0WH5Qk3eFp596qVZju2SGV9IAAAAA=";
export default image;
@@ -0,0 +1,32 @@
// This file is auto-generated by the `scripts/wallets/generate.ts` script.
// Do not modify this file manually.
export const wallet = {
id: "ai.purewallet",
name: "PureWallet app",
homepage: "https://purewallet.ai",
image_id: "4e97b9d6-60eb-42cd-6256-5cd7205e3f00",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/purewallet/id6738949168?platform=iphone",
android:
"https://play.google.com/store/apps/details?id=com.nslab.purewalletp&pli=1",
mac: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "purewalletp://",
universal: null,
},
desktop: {
native: null,
universal: null,
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://wallet.catecoin.club/",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://clot.app",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://everspace.app/",
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: "gamic://",
universal: null,
universal: "https://gamic.app/",
},
} as const;
@@ -27,6 +27,7 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal:
"https://chrome.google.com/webstore/detail/keeper-wallet/lpilbniiabackdjcionkobglmddfbcjo",
},
} as const;
@@ -28,6 +28,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://wallet.keplr.app",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://keyring.app/",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://defi.krystal.app/",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://m1nty.app",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://wallet.qubic.app",
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://app.sinum.io",
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://docs.localtrade.cc/products/defi-wallet-mvp-for-ios",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://3swallet.com/",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://abra.com",
},
} as const;
@@ -0,0 +1,7 @@
// This file is auto-generated by the `scripts/wallets/generate.ts` script.
// Do not modify this file manually.
const image =
"data:image/webp;base64,UklGRjoEAABXRUJQVlA4IC4EAABQGACdASqAAIAAPm02l0ikIyIhJJVY4IANiWMKFrc0fYPMdrD9w3TEqfYhkG9Rnic9JPzD/9T0h/9B6n/QA/ZL1bP7N7BP7AewB5bXshf32aSfi+F+s3wn7T29QwH0qE1DyyHEDgVF/jLsP/AqI0mIQZ1yxtgJ75iNJnb6GBKAbP2gXIolxJarFiDNkKhqUPbISLwx/nh7kz0UY8bdBOzrzeVWldx5pNcxiwuru/hXwKrUUrncjixCU6Dd1/lmfM5baYHYLFvQS2aSYAD+/5WUYIl0GoWT7gAgOrOfUt6dED+qbPjSyJGiygVlGYWdl3DW2Ozn3jU0YcwCXWXvAxFEhC0Gx+Y/vPjzU4DptHhjTQx/UgRuNlrn785CyaD9WsPVCYY24FU6DReEUGbTE89WWwvDQ/65hd4rDuLHDYCoveJ1Otwv2GkTFSMZp8lWCX70S5LSZk5TYRE35W7PxUJ6808xCnllC+WP+QAEdiWC5c/e0McrsB1gDGbvvg+edL+YelfR5q5pfb5UclqZWVjnqh3ZYDuCnwaNyJONDdovv+jH4ujfxugRnuQ3YT6TwH6SZ+vv9LWmqZRv0NrPsQIh/Gx6NP4o70O0JBa8O/FvAPUTcmuOAuv50Y0G+UHwvrVXZiJy4dP5qhpejqXpULybjMC31nkl3p/yCb2P1UCuvmnnAWaetHmluA7/He948JlnT/NDsgl2dibN/YY50xmp4v0axOB9DnzBr6vbIEZfxlG4zWX/gKtm06NVWw+CAMvCc/aAW7Bvg6hL78Se6p7blXBTKFQuDnn5XHO67ypYz5sgjbh9JjVV23jkN8FH5bV5UqulJOwNjMUlvlRd/S9+g4/6rnEgn6rz6Yitb/kQ+BUfga7i3EgztNuil5WE+FpMOWj0pUWxlT3aRihD+LrZPdhBMoIqgwf39clbBWGP86Lu/ZSXQszPy2Acln7OSReL6q8Sa4vh8U/4kSEc2k7H2XLpWeF8eMRaqjmiBkFzO35iNfFtq3fh6lloOz68D10s8aJ8LEm0r3sKWyiI12omYjvMCQ/d3eb34XwYSfHKNHIyJCay4yJvp+QmbYDw09QLI9EOQg6up53BtftO2Xwuz9vUzpYXo81z5v+Mo63RUltrakimoCdd/Kbib6oXgenrafY9G3cCMCKP0ES0wQfC/9m5ygTimTCtf+bhn/FHZI7nd2XH/H+kkyo4p74d5hUo2iyRgt8TNxwPdana162o+GGJUKsIZ+w5+kjrkharJT90wO+ttCEsfR5pp1PxrBV056so9RwsHtjvxWKDCPartUHuEJLh1lT/IN61Ov+MKitBq0BTd9K4uvsTw6hggsNhaJs6O/Tq/Cb/+RUdu7tfYpsSabweMcyE0wldxrsUB2+cWEXH/swGYXutO/IiuFUy8I4N8mjD98AAAAAAAA==";
export default image;
@@ -0,0 +1,31 @@
// This file is auto-generated by the `scripts/wallets/generate.ts` script.
// Do not modify this file manually.
export const wallet = {
id: "com.adftechnology",
name: "ADF Wallet",
homepage: "https://www.adftechnology.com/",
image_id: "28acc8ed-a0e7-4004-1968-c54869fa2100",
app: {
browser: null,
ios: null,
android: "https://play.google.com/store/apps/details?id=com.adf.wallet",
mac: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "adf://",
universal: null,
},
desktop: {
native: null,
universal: null,
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://apps.apple.com/us/app/bettatrade/id6474153866",
},
} as const;
@@ -13,7 +13,7 @@ export const wallet = {
mac: null,
windows: null,
linux: null,
chrome: null,
chrome: "https://www.binance.com/en/download",
firefox: null,
safari: null,
edge: null,
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://www.binance.com/en/web3wallet",
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://wallet.bitcoin.com/",
},
} as const;
@@ -26,6 +26,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://bitso.com/web3-wallet",
},
} as const;
@@ -27,6 +27,6 @@ export const wallet = {
},
desktop: {
native: null,
universal: null,
universal: "https://www.broearn.com",
},
} as const;
@@ -2,6 +2,6 @@
// Do not modify this file manually.
const image =
"data:image/webp;base64,UklGRrIHAABXRUJQVlA4IKYHAACwJQCdASqAAIAAPm0wlUYkIyIhKxdLmIANiWwA0MouX75h/dfyP9oGsf1T8Of1Tnip68tbx39C/5n3je+71C/nf/e+4B+ov7DdYnzAftL+0/vYejf++f4/2Hf7J/rusz9FPy1P24+HT9p/209nS8b/GXPr2Syu4e7JZWKuHfh5+An9R4qPPNowqmleJDBvAHYmuGhkt+Kob17LzVz5lNH1BIbFuVOVGofXXE5rvrWDwiCqUU7sUiovoLMjMT4otjkYmo33SSfAYoiofY/d/dRHaMtaWYNofT7FSfpmXkWrKfcfdwdAiXkVRuvVVHd8XLs/0W1NKPD2vQiRNPCfnPWL9aYbPTkWyALa5kQp6vPeE0mLPHq1vy4WktOyqABHUfQGLvpEhiLdmbRJ0RR3C6fnE5f8xm6AAP7+VdBNDnj/vKppmyPzYp6Cafn0x6b7Eb1JBnPH0Dg3aprWMgUoRG6COVNywDZ9NZd+i1AaYfrR3h6oYoPtiLTWJKDj9kbUFt27ji+ppIlFxnlxf5T7yLrmJABeauNx6rBu01sYXrXq7nJtJk6ST1+6NbpHnl0ewElGFATD6Ks44FEhOGF8GBa74QWH5mYjv5G7FNA/jszv6BEPPsRe4fTzbypdv6eYRzMSWeJW7J36fNwTKA47R4BeZ3VOKzqHqqhr384gAXX+UlmweGXvFbKl3lyfkkyHNiPwyYlyeCMM6ITydtXMBDcdySlhGdV3oHN3Y27F11CQXFwBPJgCb/gAMkreB1r0AG0x3lwIspnBqKyAgPApll2fkxE/+fp347V+fVG0rmUymy248B2+oAAnsPgbAOmaLSIora0nkKUj9horOv1VsnyAs/PDI+7Z6pIIObECJERPYaVAajDZlBbCWtCh3ruKCDq0M/Y1H7VjCd+2kQxY5HookGjtSiQwAAuEOp2GEFwXwyAW8hyywT8yHBLITMhbKes86TplAZ/N0t4eTpt2pwXMEQNs2kAQ4lf8XjewZkqzqwEpqZIHtaIxg0mud2Hys8UGmhY+5oGVphx6+/U1y1vECRgjbAB3uwZtd5Q80/DCZ/2IsdvwsBXwn9OJEoRApzUGAp7jHNw+WSo1zG6mvgDx6lvuT8zbOTxrq0Jd+PmnIyn29NsXILVlpnde9uhCB9IbEXJSZPjViM1okL8jEO4SegaewCtOJcUiS0sVGqq2P1cjadOXJEBDWhrdHFORe6uuOA8qyjzOFM7HX7gLXM/o4LRindoKrRom3Wsk/YYul/b9cR+J47wPo8wiRthXh2uHwQ/+emCIgvBy7XiEoeVdzX3+zwIYlR6SjPCpr7sdkd+lINy8Ua5tp9gRNDToz9a4RJ/jeCe6D/FZijI/R9v2wiqOL/g7XhGSe2uqKE+cqcyOnrCdtLTeFRNXeF4x4oUl86PfwWlL4RMxdiCGmhZlut921H9oxJXNtnILmqqW/Ip06bmkMZB+gOAE8uBhC0GfikaE6cqBzpLIhadhpB50ql7pYaJix5xo6sGvfvE98cKhVBO3ehv57FCcAw1eulKk/b82xEjKl8PcO4wAewwWJhtQxyiZQ9ZlrnrFSgoFptdkVQFZD4VBqcRAFhS0O8LEKihEK2UWgKpJdtTwk6VKGvVVkIv30xJMS9v371X0tfhkdx8XKARND2k/7iXRjRo2Y+DNTFIS/XhaxRE5+X7GweWxvzcLbIipXHbSKcF2mvNKk7LvyvCbc0ka0vRspMNXrc7u69Q3BNSgSOwya/SwIUNz3yC0GD9xWJY0Xzb6Y1wndMQeEr+bi3ZHuuZ0QKmFLvDi/Ua3e5PKjZSPxBo4W/z2jVvgz8CL/nA06GRvyk1DnjSMyPhRxWmmtEqOqto1/t8X3laM+IR+S2vmGUu0hXjWhnXdBIj8igNO+pssXcqcGqG/kbevkXj5CtRwGbhv5wLNndGTbuvdRxvLzg6H3GU2jKRYdnYdfJC5euS8s1R0pE7NcOGZabHtYkfSdUXRFnRjpfcwlYRGFToMYSBKR43FkFIaR4lXX9pEW+NivKqosv/G07CEJK6YAhGVY2iAthWtgZnJBwc/TG7AbU9CbXbdciiLoqEZRUUAzUhGezqMxKlCVi9UzEh15uJCoAK8gSOs1GNlFURmvOK9CnxC3M6lftTXR1+H4ZutECldRGyhPQLfcrH7Dn7h0Whj6alGMtwJRnC6gxJuT4IhUEbhMK6FEKiYBfR0aFcgFtMLysx7A2WEnBITbqoOArdp0KcSyFrbQiLuRX0pQr7jvgI+qSDIZnCMSUlndVfwnb+iESnh5kxqoM2CipjnKBw3KOqQ2QhBtSqeUgTQhWjAJ5l8Q78n4ZIycUwP3rX7UK+//t7Tf20Znn+plDSDWX7IDs4K5x3HRsJoBvWcFVwPvXnBYBvhsh2+53aeLwOdcm7kZP/LlwZghrXSoDJeaOIs13H10trMrw0caxhC+9IRzgKIPTIM1v3mcsZ3N6KhiPzQotczw743tnhMNX7/XkAW4pAxzUbWADSZatfPb7D65ti6FiTs/S+SC+TSacK30tbiSZTn6siw4kzukIQJ8+t9rbvOuSte6uvr+bFfV3h2Zi8G4pMIkuxJ7aDNlaYc9LbGToLUIAAAAA==";
"data:image/webp;base64,UklGRpYEAABXRUJQVlA4IIoEAAAwGgCdASqAAIAAPm00lUgkIqIhJJcamIANiUAa6gxaIObPyz766oDzNfBfzD/O/zDdAP0d/yf6q9hTzAeAB7gPQA/ln+l6xj0AP2A9Mv2LP7v/qv169kC5q5EXslpS7KdqL/Cb0AR5kBKve8ZZLwKezayJxwY0I5an7xvHGw/247hMtmC5OIHyrL1y9vHXzveltLH25GF5CRwR4OiFRxs8aibfH6aDJ1HuFCZT26iRhuPMbSkN0BvOvVqvAnSO0DewpND2MbTvMa9prt++gQbhWaAWbyfezXRVgAD+/KgAALi8oshSjnWh7CViWT2tNMBnV0uw+unZuSvnYSD5UUlJcPJyugPbpF34x+uYSOQyY3RggtLovpT4r6LoW7tf/wOaOY0f4qS1xqFuko2FKY/pq48LpLoYspBQknF+CdlZbygeKy6cFA9SbZHazOpIBdMov88HN7gnTpN9bJOyCf73f1YmaMYzt/lWifxOyKpGVquqnqgb84adSXymhLp1Tdpm9SvQeNWEMk/gnxrkdfZrbn6IgCpLVN2deMQ/8NUgLVDBy604j7637RpfcoHnYXHyvyt5X0Vu3j1CVB5C8tWujefwNmooOqeigxeST8ngG8gBismcfqaoxRPKrjFFvpULxCvml0yz+khU1+uWbB7vu7fvuCn6VasOP7++koNJNPGI1UTyRBHY5dIvAoPAUOAmrOsjaKmDLXYJbPdJuUv5jmmHi0EQ1BAetoo4vz5SjXpswF5wS1gJ5Y0WQyNKevFpyXnky3KUo2w1qZ4LvqpQygy3+70P3XQQrtheXrhJ09NazZT1ZUMVK0T1yeMT7AEDR2IT1UYWpVwZHzk2HqxkoPXxCZVRK/5DZbCSza6dGgQhrVcR8tgcsujsE3yXsW8Ikca4d13uLR5Wfh00i2PI1Dpr8TSytQxr731rMIF2hrQ2hMUiT2OMyTJsNgyXoLPqIVjhSz15C+SSbLwxOgdhSnPZ9WkGrx6gb0wR3zqAq96frXGr8HBI5UMfQEFS5fBbhF0rG9VjpLI62X6m8R8j03bEA4pAFOG8HWju2W8FjN1ijkCLMlyiUpNU7EEyKPmTxlcK2mJpOIdhcMyOf5cfA41obQmURPGLIE9RVy7UkLuc86tTndeKDpPK2d31NJtSzLpviX5A9LOeDX+x/PA6xzckcejJqt0SxnBwsbGyF9RaRNJjvHHCKrSJMYXEBP1mW7vUZV1F3aGtKwGtcnml39Bw42e/h8e0tUWnA+uEAgk7awr1Xb/CVVbVGujWsIqTUMLOE8k92Nm+fQF9lR/3mpWETUZV3h6QNOSWnghIzK6tnTDwT1OuXHcZB/EGtouXNO+CXU5PEnzf9wgZNl/oZ9YnBIaPa1aQ64+QXMUCdib9oGLKhsA+X1C5a8ROWTwGxWUg7EFbjhGCAHhuGKG5FwkPS874fM9geCYInQChd+lJx5WAsiTAew9AW3Us7ZZn4C1i89wglJXP2I/FD77flEyiMM6bkveoRK5UVs7StpoM0pNkoKJxPzLRDc+2UA9KnLG1awOgAAAA";
export default image;

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