Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
77063e5c85 Version Packages (#7588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 15:51:49 -07:00
Joaquim VergesandGitHub 585eac2a6f [Dashboard] Replace vault access token with project secret key for authentication (#7570) 2025-07-10 15:32:36 -07:00
MananTank c54db522b3 Dashboard: Fix webhooks posthog feature flag conditional rendering (#7585)
<!--

## 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 centralized webhooks feature, enhancing the sidebar layout and analytics for webhooks. It adds loading states, integrates feature flags, and modifies the handling of webhooks in various components.

### Detailed summary
- Added `Loading` components using `GenericLoadingPage` in multiple webhook pages.
- Updated `ProjectSidebarLayout` to include `isCentralizedWebhooksFeatureFlagEnabled`.
- Enhanced `ProjectLayout` to fetch and utilize the centralized webhooks feature flag.
- Modified `WebhooksAnalyticsPage` to check for the centralized webhooks feature flag before redirecting.
- Adjusted `WebhooksLayout` to conditionally render links based on the centralized webhooks feature flag.
- Refactored `isFeatureFlagEnabled` function for improved feature flag handling.
- Updated `WebhooksPage` to include centralized webhooks feature flag checks and redirection logic.
- Cleaned up imports and ensured consistent use of `params` across various components.

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

<!-- end pr-codex -->

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

* **New Features**
  * Added multiple loading indicators across webhooks analytics, contracts, universal bridge, and sidebar routes for smoother user experience.
  * Sidebar and tab navigation dynamically update based on the centralized webhooks feature flag.

* **Bug Fixes**
  * Enhanced error handling with 404 responses for missing accounts, projects, or authentication on webhooks pages.
  * Redirects implemented when the centralized webhooks feature flag is disabled.

* **Refactor**
  * Updated feature flag checks to a new cached API accepting structured parameters.
  * Streamlined parameter handling and navigation logic for webhooks pages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 20:15:27 +00:00
Joaquim VergesandGitHub d62e9971ee [SDK] Fix inapp wallets showing up in all wallets list (#7586) 2025-07-10 13:14:57 -07:00
MananTank 7d796f5aa5 Dashboard: Fix theme for BuyWidget in chain page (#7583)
<!--

## 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 `BuyFundsSection` component by integrating theme support for the `BuyWidget` using the `useTheme` hook from `next-themes`. It adjusts the widget's theme based on the current application theme.

### Detailed summary
- Added `useTheme` hook to access the current theme.
- Imported `getSDKTheme` utility for theme handling.
- Passed the correct theme to the `BuyWidget` based on whether the theme is "dark" or "light".

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * The Buy Funds section now adapts its appearance based on your selected theme (light or dark), providing a more consistent and personalized user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 19:57:29 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a5bb36141d Version Packages (#7584)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 12:50:16 -07:00
Prithvish BaidyaandGitHub f3234c3c41 [thirdweb/server-wallet] fix address used for simulation for ERC4337 (#7581) 2025-07-10 11:55:04 -07:00
91e3781e83 [Dashboard] Improve user filtering functionality (#7572)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-10 11:53:41 -07:00
arcoraven 86e3aa8c04 feat: webhooks list in dashboard (#7505)
# [Dashboard] Feature: Add Webhook Management UI

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/38dbe90b-2bd9-47b6-b190-3c257e99d2fe.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/d0a40295-c1c7-45c5-9b23-224a2a631b83.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/42d5337d-fcfe-4ef7-a349-1f596013e003.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/b7b89fe6-a5f7-4961-b927-bfc754c6665f.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/88916c15-a501-4ceb-9267-a23290f683b9.png)

## Notes for the reviewer
This PR adds a new webhook management UI to the dashboard, allowing users to create, edit, delete, and monitor webhooks. The implementation includes:

- A new overview page for webhooks with a table to display all configured webhooks
- Create/edit webhook modals with form validation
- Topic selector for webhook event subscriptions
- Delete confirmation with activity warning
- Webhook metrics display showing request counts and error rates
- API integration for webhook configuration management

## How to test
Navigate to the webhooks section in a project to view the new UI. Create a new webhook, edit its configuration, and test the delete functionality. Verify that metrics are displayed correctly for existing webhooks.

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

* **New Features**
  * Introduced a comprehensive webhooks management interface, including creation, editing, deletion, and topic selection with optional JSON filters.
  * Added analytics dashboards for webhooks, featuring charts for request status codes and latency percentiles, with filtering by webhook and date range.
  * Implemented feature flag-based tab navigation for "Overview" and "Analytics" in the webhooks section.
  * Provided detailed webhook metrics, including recent activity and error rates.

* **Bug Fixes**
  * Improved error handling and user feedback for webhook configuration and analytics operations.

* **Chores**
  * Added PostHog server-side analytics integration.
  * Updated environment variable naming for analytics configuration.
  * Added new dependency for analytics support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces webhook analytics features and enhances the webhook management system, including new types for webhook statistics, UI components for creating and editing webhooks, and improvements in data fetching and handling.

### Detailed summary
- Added `posthog-node` dependency for server-side analytics.
- Updated environment variable from `NEXT_PUBLIC_POSTHOG_API_KEY` to `NEXT_PUBLIC_POSTHOG_KEY`.
- Introduced interfaces for `WebhookRequestStats`, `WebhookLatencyStats`, and `WebhookSummaryStats`.
- Created `CreateWebhookConfigModal` and `EditWebhookConfigModal` components for managing webhooks.
- Implemented `WebhooksAnalytics` and `WebhookAnalyticsServer` components for displaying analytics data.
- Enhanced `WebhookMetrics` to show metrics and error rates.
- Added `isFeatureFlagEnabled` function for feature flag checks.
- Implemented data fetching functions for webhook metrics in `analytics.ts`.
- Updated the webhook configuration management with new API endpoints and improved error handling.
- Created `TopicSelectorModal` for selecting topics with optional filters.
- Improved UI components for better user experience in managing webhooks.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-07-10 17:21:02 +00:00
MananTank aa1035d16c [TOOL-4988] Dashboard, SDK: Add gas sponsorship option for asset creation for in-app wallets (#7566)
<!--

## 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 handling of gasless transactions across various components in the `thirdweb` dashboard, specifically for NFT and token creation processes. It introduces new parameters and logic to support gas sponsorship features.

### Detailed summary
- Added `useAuthToken` option in `bundler.ts` fetch requests.
- Enhanced `fetch.ts` to include authentication headers for bundler requests.
- Updated `CreateTokenFunctions` and `CreateNFTCollectionFunctions` to accept `gasless` parameter.
- Modified multiple functions in the NFT and token creation pages to support gasless transactions.
- Implemented UI elements for enabling gas sponsorship in the `LaunchNFT` and `LaunchTokenStatus` components.
- Adjusted error handling for unsupported gas sponsorship on certain chains.

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

<!-- end pr-codex -->

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

Here are the updated release notes based on the provided summary:

* **New Features**
  * Added support for gasless transactions when launching NFT collections and tokens, available for eligible team plans and in-app wallets.
  * Introduced a toggle in the launch UI to enable or disable gasless mode for NFT and token creation.
  * Updated UI labels and dialogs for launching NFT collections.

* **Refactor**
  * Standardized function signatures for NFT and token creation to accept a unified parameter object, including a new gasless flag.
  * Centralized account and contract retrieval logic for improved maintainability and consistency.
  * Improved error logging in the token creation workflow.

* **Bug Fixes**
  * Enhanced error handling during token launch to detect unsupported gas sponsorship and provide user-friendly retry options.

* **Chores**
  * Updated request headers logic to properly handle authorization tokens and additional headers for bundler URLs.
  * Enabled authentication token usage in bundler request configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 15:18:10 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bd395a3cff Version Packages (#7579)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 00:51:14 -07:00
Joaquim VergesandGitHub 446a69b1d1 [SDK] update wallet definitions (#7580) 2025-07-09 21:51:41 -07:00
Manan TankandGitHub 826d9c80a8 Dashboard: Remove OP superchain banner, CTAs, credits (#7569) 2025-07-09 20:10:50 -07:00
Joaquim VergesandGitHub 772eb0cc53 [SDK] Handle different fiat currencies in payment widgets (#7578) 2025-07-09 19:59:35 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
5c6dd874e0 Version Packages (#7577)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 16:55:48 -07:00
a9e47e4759 Replace PayEmbed with BuyWidget code (#7574)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:47:29 -07:00
bfd728a9d1 Add branding hide prop to widgets (#7575)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:46:29 -07:00
saminacodes 381a2823d7 [Docs] Remove Nebula from portal navigation (#7576)
```
<!--

## title your PR with this format: "[Portal] Feature: Remove Nebula from portal navigation"

## Notes for the reviewer

This PR removes all user-facing references to Nebula from the portal navigation and homepage, as requested.

-   **Key point:** All Nebula pages and underlying content remain untouched; only navigation links and homepage sections were removed.
-   Removed Nebula from the main header navigation and the APIs dropdown.
-   Removed Nebula sections from the homepage (API Reference and Learning Resources).
-   Updated the Vault section icon on the homepage from `NebulaIcon` (which was removed) to `ConnectIcon`.

## How to test

1.  Run the portal locally.
2.  Verify "Nebula" is no longer present in the main navigation bar or the "APIs" dropdown.
3.  On the homepage (`/`), confirm that the "Nebula" cards are removed from the "API Reference" and "Learning Resources" sections.
4.  Confirm that the "Vault" card in the "Learning Resources" section now uses the `ConnectIcon`.
5.  Run `npm run portal:build` to ensure the project builds successfully.

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C04DYC3G5NZ/p1752094214240499?thread_ts=1752094214.240499&cid=C04DYC3G5NZ)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on removing references to `Nebula` from the application, including links and UI components, while retaining and emphasizing `Insight` and `Vault`.

### Detailed summary
- Removed `Nebula` link from `connectLinks` in `Header.tsx`.
- Deleted `Nebula` API reference card from `ReferenceSection` in `page.tsx`.
- Eliminated `Nebula` card from `LearningResourcesSection` in `page.tsx`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * None.

* **Bug Fixes**
  * None.

* **Refactor**
  * Removed all references to the "Nebula" feature from navigation menus and resource sections.
  * Updated the icon for the "Vault" article card.

* **Chores**
  * Cleaned up unused imports related to "Nebula".

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 21:53:40 +00:00
saminacodes 2111b8b12c Rename Universal Bridge to Payments (#7571)
```
<!--

## title your PR with this format: "[Dashboard] Feature: Rename Universal Bridge to Payments"

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

## Notes for the reviewer

This PR renames all instances of "Universal Bridge" to "Payments" across the projects dashboard. This is a branding update and does not change any underlying functionality or URLs.

Changes include:
- Sidebar navigation label
- Page titles and descriptions
- Callouts and sub-text (e.g., analytics, FTUX)
- Webhooks section (tab name and heading)

## How to test

1. Navigate to any project dashboard.
2. Verify the sidebar now shows "Payments" instead of "Universal Bridge".
3. Click on "Payments" in the sidebar and confirm the page title and description are updated.
4. Check the "Analytics" page to ensure "Universal Bridge" references are updated to "Payments".
5. Go to "Webhooks" and then the "Payments" tab to confirm the heading and tab name are correct.
6. Verify the FTUX section also reflects "Payments".

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C08GEMP858V/p1752037029611589?thread_ts=1752037029.611589&cid=C08GEMP858V)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming references from "Universal Bridge" to "Payments" throughout the codebase, reflecting a change in terminology for a feature that facilitates payments.

### Detailed summary
- Updated `name` in `layout.tsx` from "Universal Bridge" to "Payments".
- Changed `label` in `ProjectSidebarLayout.tsx` from "Universal Bridge" to "Payments".
- Modified `title` in `ProjectFTUX.tsx` from "Universal Bridge" to "Payments".
- Changed heading in `page.tsx` from "Universal Bridge Webhooks" to "Payments Webhooks".
- Updated description in `analytics/page.tsx` from "Universal Bridge" to "Payments".
- Changed description in `sidebar/page.tsx` from "Universal Bridge" to "Payments".
- Updated heading in `universal-bridge/page.tsx` from "Get Started with Universal Bridge" to "Get Started with Payments".
- Changed heading in `universal-bridge/layout.tsx` from "Universal Bridge" to "Payments".
- Updated description in `universal-bridge/layout.tsx` to reflect "Payments" instead of "Universal Bridge".

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

<!-- end pr-codex -->
2025-07-09 05:13:22 +00:00
567 changed files with 12184 additions and 8747 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
# posthog API key
# - not required for prod/staging
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
NEXT_PUBLIC_POSTHOG_KEY=""
# Stripe Customer portal
NEXT_PUBLIC_STRIPE_KEY=
+1
View File
@@ -55,6 +55,7 @@
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"posthog-node": "^5.4.0",
"prettier": "3.6.2",
"qrcode": "^1.5.3",
"react": "19.1.0",
@@ -0,0 +1,57 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { PostHog } from "posthog-node";
let _posthogClient: PostHog | null = null;
function getPostHogServer(): PostHog | null {
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
host: "https://us.i.posthog.com",
});
}
return _posthogClient;
}
/**
* Check if a feature flag is enabled for a specific user
* @param flagKey - The feature flag key
* @param userEmail - The user's email address for filtering
*/
export const isFeatureFlagEnabled = unstable_cache(
async (params: {
flagKey: string;
accountId: string;
email: string | undefined;
}): Promise<boolean> => {
const posthogClient = getPostHogServer();
if (!posthogClient) {
console.warn("Posthog client not set");
return true;
}
const { flagKey, accountId, email } = params;
try {
if (posthogClient && accountId) {
const isEnabled = await posthogClient.isFeatureEnabled(
flagKey,
accountId,
{
personProperties: email ? { email } : undefined,
},
);
if (isEnabled !== undefined) {
return isEnabled;
}
}
} catch (error) {
console.error(`Error checking feature flag ${flagKey}:`, error);
}
return false;
},
["is-feature-flag-enabled"],
{
revalidate: 3600, // 1 hour
},
);
+57
View File
@@ -12,6 +12,9 @@ import type {
UserOpStats,
WalletStats,
WalletUserStats,
WebhookLatencyStats,
WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
import { getAuthToken } from "./auth-token";
import { getChains } from "./chain";
@@ -482,6 +485,60 @@ export async function getEngineCloudMethodUsage(
return json.data as EngineCloudStats[];
}
export async function getWebhookSummary(
params: AnalyticsQueryParams & { webhookId: string },
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
searchParams.append("webhookId", params.webhookId);
const res = await fetchAnalytics(
`v2/webhook/summary?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookSummaryStats[] };
}
export async function getWebhookRequests(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/requests?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookRequestStats[] };
}
export async function getWebhookLatency(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/latency?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookLatencyStats[] };
}
export async function getInsightChainUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
-36
View File
@@ -1,44 +1,8 @@
import "server-only";
import type { ChainMetadata } from "thirdweb/chains";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import type { ChainService } from "@/types/chain";
export async function getGasSponsoredChains() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch gas sponsored chains",
res.status,
res.statusText,
);
res.body?.cancel();
return [];
}
try {
return (await res.json()).data as number[];
} catch (e) {
console.error("Failed to parse gas sponsored chains", e);
return [];
}
}
export function getChains() {
return fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
+312
View File
@@ -0,0 +1,312 @@
"use server";
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
export interface WebhookConfig {
id: string;
teamId: string;
projectId: string;
destinationUrl: string;
description: string;
pausedAt: string | null;
webhookSecret: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
topics: {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
filters: object | null;
}[];
}
type WebhookConfigsResponse =
| {
data: WebhookConfig[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface CreateWebhookConfigRequest {
topics: { id: string; filters: object | null }[];
destinationUrl: string;
description: string;
isPaused?: boolean;
}
type CreateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export interface Topic {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type TopicsResponse =
| {
data: Topic[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
topics?: { id: string; filters: object | null }[];
description?: string;
isPaused?: boolean;
}
type UpdateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
type DeleteWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export async function getWebhookConfigs(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
}): Promise<WebhookConfigsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function createWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
config: CreateWebhookConfigRequest;
}): Promise<CreateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function getAvailableTopics(): Promise<TopicsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function updateWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
config: UpdateWebhookConfigRequest;
}): Promise<UpdateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function deleteWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
}): Promise<DeleteWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "DELETE",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
@@ -1,45 +1,18 @@
import { formatDistance } from "date-fns";
import { CircleAlertIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { Account, BillingCredit } from "@/hooks/useApi";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ChainIconClient } from "@/icons/ChainIcon";
import type { BillingCredit } from "@/hooks/useApi";
import { formatToDollars } from "./formatToDollars";
interface CreditsItemProps {
credit?: BillingCredit;
isOpCreditDefault?: boolean;
onClickApply?: () => void;
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}
export const CreditsItem: React.FC<CreditsItemProps> = ({
credit,
isOpCreditDefault,
onClickApply,
twAccount,
client,
teamSlug,
}) => {
const [hasAppliedForOpGrant] = useLocalStorage(
`appliedForOpGrant-${twAccount.id}`,
false,
);
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
const isTwCredit = credit?.name.startsWith("TW -");
const isStartupCredit = credit?.name.startsWith("SU -");
let creditTitle = credit?.name ?? "thirdweb credits";
if (isOpCredit) {
creditTitle = "Optimism sponsorship credits";
} else if (isTwCredit) {
if (isTwCredit) {
creditTitle = "thirdweb credits";
} else if (isStartupCredit) {
creditTitle = "Startup grant credits";
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
<div className="flex flex-col gap-3 p-4 lg:p-6">
<div className="relative">
<div className="absolute top-0 right-0">
{isOpCredit ? (
<ChainIconClient
className="size-6"
client={client}
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
/>
) : isTwCredit ? (
{isTwCredit ? (
<Image
alt="tw-credit"
className="size-6"
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
</div>
)}
</div>
{hasAppliedForOpGrant && !credit && isOpCredit && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>Grant application pending approval</AlertTitle>
<AlertDescription>
You will receive an email once your application&apos;s status
changes.
</AlertDescription>
</Alert>
)}
</div>
{!hasAppliedForOpGrant && isOpCredit && (
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
<Button asChild size="sm" variant="outline">
<Link
href={`/team/${teamSlug}/~/settings/credits`}
onClick={() => {
if (onClickApply) {
onClickApply();
}
}}
>
Apply Now
</Link>
</Button>
</div>
)}
</div>
);
};
@@ -2,41 +2,26 @@
// TODO - convert to RSC
import type { ThirdwebClient } from "thirdweb";
import { type Account, useAccountCredits } from "@/hooks/useApi";
import { useAccountCredits } from "@/hooks/useApi";
import { CreditsItem } from "./CreditsItem";
export const CreditsInfoCard = (props: {
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}) => {
export const CreditsInfoCard = () => {
const { data: credits } = useAccountCredits();
if (!credits) {
return null;
}
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
if (restCredits.length === 0) {
return null;
}
return (
<section className="flex flex-col gap-4">
<CreditsItem
client={props.client}
credit={opCredit}
isOpCreditDefault={true}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits?.map((credit) => (
<CreditsItem
client={props.client}
credit={credit}
key={credit.couponId}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits.map((credit) => (
<CreditsItem credit={credit} key={credit.couponId} />
))}
</section>
);
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
@@ -63,6 +64,10 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
});
return {
project: res.project,
secret: res.secret,
+15 -5
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useActiveAccount } from "thirdweb/react";
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { createVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
// FIXME: We keep repeating types, API server should provide them
@@ -311,26 +312,35 @@ export type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
};
export async function rotateSecretKeyClient(params: {
teamId: string;
projectId: string;
}) {
export async function rotateSecretKeyClient(params: { project: Project }) {
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
});
if (!res.ok) {
throw new Error(res.error);
}
try {
// if the project has a vault admin key, rotate it as well
await createVaultAccountAndAccessToken({
project: params.project,
projectSecretKey: res.data.data.secret,
projectSecretHash: res.data.data.secretHash,
});
} catch (error) {
console.error("Failed to rotate vault admin key", error);
}
return res.data;
}
@@ -7,22 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
const fetchAccountList = ({
jwt,
clientId,
ecosystemSlug,
teamId,
pageNumber,
}: {
jwt: string;
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
pageNumber: number;
}) => {
return async () => {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
url.searchParams.append("clientId", clientId);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
url.searchParams.append("page", pageNumber.toString());
const res = await fetch(url.href, {
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
@@ -38,23 +50,27 @@ const fetchAccountList = ({
};
export function useEmbeddedWallets(params: {
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
page: number;
authToken: string;
}) {
const { clientId, page, authToken } = params;
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
const address = useActiveAccount()?.address;
return useQuery({
enabled: !!address && !!clientId,
enabled: !!address && !!(clientId || ecosystemSlug),
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
@@ -67,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
const address = useActiveAccount()?.address;
return useMutation({
mutationFn: async ({ clientId }: { clientId: string }) => {
mutationFn: async ({
clientId,
ecosystemSlug,
teamId,
}: {
clientId?: string;
ecosystemSlug?: string;
teamId: string;
}) => {
const responses: WalletUser[] = [];
let page = 1;
@@ -77,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
}>({
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
+25
View File
@@ -66,6 +66,31 @@ export interface UniversalBridgeWalletStats {
developerFeeUsdCents: number;
}
export interface WebhookRequestStats {
date: string;
webhookId: string;
httpStatusCode: number;
totalRequests: number;
}
export interface WebhookLatencyStats {
date: string;
webhookId: string;
p50LatencyMs: number;
p90LatencyMs: number;
p99LatencyMs: number;
}
export interface WebhookSummaryStats {
webhookId: string;
totalRequests: number;
successRequests: number;
errorRequests: number;
successRate: number;
avgLatencyMs: number;
errorBreakdown: Record<string, number>;
}
export interface AnalyticsQueryParams {
teamId: string;
projectId?: string;
@@ -1,12 +1,15 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
</section>
);
@@ -2,11 +2,7 @@ import "server-only";
import { notFound } from "next/navigation";
import type { ChainMetadata } from "thirdweb/chains";
import {
getChainServices,
getChains,
getGasSponsoredChains,
} from "@/api/chain";
import { getChainServices, getChains } from "@/api/chain";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ChainMetadataWithServices, ChainServices } from "@/types/chain";
import type { ChainCTAProps } from "./[chain_id]/(chainPage)/components/server/cta-card";
@@ -25,7 +21,6 @@ import cotiBanner from "./temp-assets/COTI_Banner.jpg";
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
import creatorBanner from "./temp-assets/creatorBanner.png";
import creatorCTA from "./temp-assets/creatorCTA.png";
import superchainCTABG from "./temp-assets/cta-bg-superchain.png";
import xaiCTABg from "./temp-assets/cta-bg-xai-connect.png";
import thirdwebCTA from "./temp-assets/cta-thirdweb.png";
import cyberChainBanner from "./temp-assets/cyberChainBanner.png";
@@ -135,17 +130,6 @@ type ExtraChainMetadata = Partial<{
cta: ChainCTAProps;
}>;
// TEMPORARY
const OP_CTA = {
backgroundImageUrl: superchainCTABG.src,
buttonLink: "/team/~/~/settings/credits",
buttonText: "Apply now",
description:
"Successful applicants receive gas grants for use across all supported Optimism Superchain networks. These grants can sponsor gas fees for any onchain activity using our Account Abstraction tools.",
title: "Optimism Superchain App Accelerator",
} satisfies ChainCTAProps;
const chainMetaRecord = {
// Flare
14: {
@@ -933,22 +917,12 @@ const chainMetaRecord = {
export async function getChainMetadata(
chainId: number,
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
const gasSponsoredChains = await getGasSponsoredChains();
const isGasSponsored = gasSponsoredChains.includes(chainId);
// TODO: fetch this from the API
if (chainId in chainMetaRecord) {
return {
...(isGasSponsored ? { cta: OP_CTA, gasSponsored: true } : {}),
// this will OVERRIDE the op CTA if there is a custom one configured
...chainMetaRecord[chainId as keyof typeof chainMetaRecord],
};
} else if (isGasSponsored) {
return {
cta: OP_CTA,
gasSponsored: true,
};
}
return null;
}
@@ -367,7 +367,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -1,12 +1,10 @@
import { redirect } from "next/navigation";
import { getStripeBalance } from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { CreditsInfoCard } from "@/components/billing/PlanCard";
import { Coupons } from "@/components/billing/SubscriptionCoupons/Coupons";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { CreditBalanceSection } from "./components/credit-balance-section.client";
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
@@ -28,9 +26,8 @@ export default async function Page(props: {
const account = await getValidAccount(pagePath);
const [team, authToken, teamMember] = await Promise.all([
const [team, teamMember] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
getMemberByAccountId(params.team_slug, account.id),
]);
@@ -50,11 +47,6 @@ export default async function Page(props: {
);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const highlightPlan =
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
@@ -84,11 +76,7 @@ export default async function Page(props: {
/>
)}
<CreditsInfoCard
client={client}
teamSlug={team.slug}
twAccount={account}
/>
<CreditsInfoCard />
<Coupons isPaymentSetup={validPayment} teamId={team.id} />
</div>
);
@@ -62,6 +62,10 @@ export async function EcosystemLayoutSlug({
name: "Analytics",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
},
{
name: "Users",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/users`,
},
{
name: "Design (coming soon)",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/#`,
@@ -0,0 +1,40 @@
import { loginRedirect } from "@app/login/loginRedirect";
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default async function EcosystemUsersPage(props: {
params: Promise<{ team_slug: string; slug: string }>;
}) {
const params = await props.params;
const [authToken, ecosystem, team] = await Promise.all([
getAuthToken(),
fetchEcosystem(params.slug, params.team_slug),
getTeamBySlug(params.team_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/ecosystem/${params.slug}/users`);
}
if (!ecosystem || !team) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
ecosystemSlug={ecosystem.slug}
teamId={team.id}
/>
);
}
@@ -1,10 +1,6 @@
"use client";
import { SmartWalletsBillingAlert } from "@app/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/Alerts";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { AAFooter } from "./AAFooterSection";
@@ -44,10 +40,6 @@ export function AccountAbstractionLayout(props: {
{props.hasSmartWalletsWithoutBilling && (
<SmartWalletsBillingAlert teamSlug={props.teamSlug} />
)}
<GasCreditAlert
projectId={props.projectId}
teamSlug={props.teamSlug}
/>
</div>
<div className="h-4" />
@@ -89,40 +81,3 @@ export function AccountAbstractionLayout(props: {
</div>
);
}
function GasCreditAlert(props: { teamSlug: string; projectId: string }) {
return (
<DismissibleAlert
description={
<>
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<UnderlineLink
href="https://thirdweb.com/superchain"
rel="noopener noreferrer"
target="_blank"
>
Learn More
</UnderlineLink>
<div className="mt-4 flex items-center gap-4">
<Button
asChild
className="bg-background"
size="sm"
variant="outline"
>
<Link
className="gap-2"
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
>
Claim your credits <ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</>
}
localStorageId={`${props.projectId}-gas-credit-alert`}
title="OP Gas Credit Program"
/>
);
}
@@ -62,9 +62,8 @@ function IntegrateAPIKeySection({
<ClientIDSection clientId={clientId} />
{secretKeyMasked && (
<SecretKeySection
projectId={project.id}
project={project}
secretKeyMasked={secretKeyMasked}
teamId={project.teamId}
/>
)}
</div>
@@ -257,7 +256,7 @@ function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
"Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing",
href: `/team/${props.teamSlug}/${props.projectSlug}/universal-bridge`,
icon: PayIcon,
title: "Universal Bridge",
title: "Payments",
},
];
@@ -1,13 +1,13 @@
"use client";
import { useState } from "react";
import type { Project } from "@/api/projects";
import { rotateSecretKeyClient } from "@/hooks/useApi";
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
export function SecretKeySection(props: {
secretKeyMasked: string;
teamId: string;
projectId: string;
project: Project;
}) {
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
@@ -31,8 +31,7 @@ export function SecretKeySection(props: {
}}
rotateSecretKey={async () => {
return rotateSecretKeyClient({
projectId: props.projectId,
teamId: props.teamId,
project: props.project,
});
}}
/>
@@ -22,8 +22,14 @@ export function ProjectSidebarLayout(props: {
layoutPath: string;
engineLinkType: "cloud" | "dedicated";
children: React.ReactNode;
isCentralizedWebhooksFeatureFlagEnabled: boolean;
}) {
const { layoutPath, engineLinkType, children } = props;
const {
layoutPath,
engineLinkType,
children,
isCentralizedWebhooksFeatureFlagEnabled,
} = props;
return (
<FullWidthSidebarLayout
@@ -75,7 +81,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${layoutPath}/universal-bridge`,
icon: PayIcon,
label: "Universal Bridge",
label: "Payments",
},
{
href: `${layoutPath}/tokens`,
@@ -119,8 +125,13 @@ export function ProjectSidebarLayout(props: {
]}
footerSidebarLinks={[
{
href: `${layoutPath}/webhooks`,
href: isCentralizedWebhooksFeatureFlagEnabled
? `${layoutPath}/webhooks`
: `${layoutPath}/webhooks/contracts`,
icon: BellIcon,
isActive: (pathname) => {
return pathname.startsWith(`${layoutPath}/webhooks`);
},
label: (
<span className="flex items-center gap-2">
Webhooks <Badge>New</Badge>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProject, getProjects, type Project } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
@@ -56,10 +57,18 @@ export default async function ProjectLayout(props: {
teamId: team.id,
});
const engineLinkType = await getEngineLinkType({
authToken,
project,
});
const [engineLinkType, isCentralizedWebhooksFeatureFlagEnabled] =
await Promise.all([
getEngineLinkType({
authToken,
project,
}),
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
]);
const isStaffMode = !teams.some((t) => t.slug === team.slug);
@@ -81,6 +90,9 @@ export default async function ProjectLayout(props: {
<ProjectSidebarLayout
engineLinkType={engineLinkType}
layoutPath={layoutPath}
isCentralizedWebhooksFeatureFlagEnabled={
isCentralizedWebhooksFeatureFlagEnabled
}
>
{props.children}
</ProjectSidebarLayout>
@@ -435,7 +435,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -47,6 +47,7 @@ function Story(props: { isOwnerAccount: boolean }) {
data: {
secret: `sk_${new Array(86).fill("x").join("")}`,
secretMasked: "sk_123...4567",
secretHash: "sk_123...4567",
},
};
}}
@@ -135,8 +135,7 @@ export function ProjectGeneralSettingsPage(props: {
project={props.project}
rotateSecretKey={async () => {
return rotateSecretKeyClient({
projectId: props.project.id,
teamId: props.project.teamId,
project: props.project,
});
}}
showNebulaSettings={props.showNebulaSettings}
@@ -42,16 +42,29 @@ export type CreateNFTCollectionFunctions = {
address: string;
}[];
chain: string;
gasless: boolean;
}) => Promise<void>;
erc721: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
setClaimConditions: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
};
erc1155: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: {
@@ -60,8 +73,12 @@ export type CreateNFTCollectionFunctions = {
startIndex: number;
count: number;
};
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
};
};
@@ -21,6 +21,7 @@ import {
import { grantRole } from "thirdweb/extensions/permissions";
import { useActiveAccount } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -42,15 +43,26 @@ export function CreateNFTPage(props: {
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
function getContractAndAccount(params: { chain: string }) {
function getAccount(params: { gasless: boolean }) {
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
if (params.gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
@@ -60,30 +72,26 @@ export function CreateNFTPage(props: {
throw new Error("Contract not found");
}
const contract = getContract({
return getContract({
address: contractAddress,
chain,
client: props.client,
});
return {
activeAccount,
contract,
};
}
async function handleContractDeploy(params: {
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { values: formValues, ercType } = params;
const { collectionInfo, sales } = formValues;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
const account = getAccount({
gasless: params.gasless,
});
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(collectionInfo.chain));
@@ -93,7 +101,7 @@ export function CreateNFTPage(props: {
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -110,7 +118,7 @@ export function CreateNFTPage(props: {
});
} else {
contractAddress = await deployERC1155Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -166,27 +174,32 @@ export function CreateNFTPage(props: {
}
async function handleLazyMintNFTs(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { formValues, ercType } = params;
const { values, ercType } = params;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155;
const transaction = lazyMintFn({
contract,
nfts: formValues.nfts,
nfts: values.nfts,
});
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -205,14 +218,19 @@ export function CreateNFTPage(props: {
}
async function handleSetClaimConditionsERC721(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) {
const { formValues } = params;
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const { values } = params;
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const firstNFT = formValues.nfts[0];
const account = getAccount({
gasless: params.gasless,
});
const firstNFT = values.nfts[0];
if (!firstNFT) {
throw new Error("No NFTs found");
}
@@ -235,7 +253,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -259,12 +277,17 @@ export function CreateNFTPage(props: {
startIndex: number;
count: number;
};
gasless: boolean;
}) {
const { values, batch } = params;
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const endIndexExclusive = batch.startIndex + batch.count;
const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive);
@@ -315,7 +338,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (error) {
@@ -339,15 +362,20 @@ export function CreateNFTPage(props: {
admins: {
address: string;
}[];
gasless: boolean;
chain: string;
}) {
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: params.chain,
});
const account = getAccount({
gasless: params.gasless,
});
// remove the current account from the list - its already an admin, don't have to add it again
const adminsToAdd = params.admins.filter(
(admin) => admin.address !== activeAccount.address,
(admin) => admin.address !== account.address,
);
const encodedTxs = await Promise.all(
@@ -369,7 +397,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (e) {
@@ -392,35 +420,35 @@ export function CreateNFTPage(props: {
{...props}
createNFTFunctions={{
erc721: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc721",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc721",
formValues,
...params,
});
},
setClaimConditions: async (formValues) => {
setClaimConditions: async (params) => {
return handleSetClaimConditionsERC721({
formValues,
...params,
});
},
},
erc1155: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc1155",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc1155",
formValues,
...params,
});
},
setClaimConditions: async (params) => {
@@ -19,6 +19,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -154,18 +155,28 @@ export function LaunchNFT(props: {
return shouldDeployERC721 ? "erc721" : "erc1155";
}, [formValues.nfts]);
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const contractLink = contractAddressRef.current
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
: null;
async function executeStep(steps: MultiStepState<StepId>[], stepId: StepId) {
if (stepId === "deploy-contract") {
const result =
await props.createNFTFunctions[ercType].deployContract(formValues);
const result = await props.createNFTFunctions[ercType].deployContract({
gasless: isGasless,
values: formValues,
});
contractAddressRef.current = result.contractAddress;
} else if (stepId === "set-claim-conditions") {
if (ercType === "erc721") {
await props.createNFTFunctions.erc721.setClaimConditions(formValues);
await props.createNFTFunctions.erc721.setClaimConditions({
gasless: isGasless,
values: formValues,
});
} else {
if (batchCount > 1) {
const batchStartIndex = batchesProcessedRef.current;
@@ -190,6 +201,7 @@ export function LaunchNFT(props: {
count: batchSize,
startIndex: batchIndex * batchSize,
},
gasless: isGasless,
values: formValues,
});
@@ -201,12 +213,16 @@ export function LaunchNFT(props: {
count: formValues.nfts.length,
startIndex: 0,
},
gasless: isGasless,
values: formValues,
});
}
}
} else if (stepId === "mint-nfts") {
await props.createNFTFunctions[ercType].lazyMintNFTs(formValues);
await props.createNFTFunctions[ercType].lazyMintNFTs({
gasless: isGasless,
values: formValues,
});
} else if (stepId === "set-admins") {
// this is type guard, this can never happen
if (!contractAddressRef.current) {
@@ -218,6 +234,7 @@ export function LaunchNFT(props: {
chain: formValues.collectionInfo.chain,
contractAddress: contractAddressRef.current,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
gasless: isGasless,
});
}
}
@@ -311,6 +328,7 @@ export function LaunchNFT(props: {
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -319,7 +337,7 @@ export function LaunchNFT(props: {
variant="default"
>
<ArrowUpFromLineIcon className="size-4" />
Launch NFT
Launch NFT Collection
</TransactionButton>
),
type: "custom",
@@ -327,7 +345,7 @@ export function LaunchNFT(props: {
prevButton={{
onClick: props.onPrevious,
}}
title="Launch NFT"
title="Launch NFT Collection"
>
<Dialog
open={isModalOpen}
@@ -468,6 +486,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* NFTs */}
<div className="border-b border-dashed px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">NFTs</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -509,6 +528,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* sales and fees */}
<div className="px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">Sales and Fees</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -537,6 +557,31 @@ export function LaunchNFT(props: {
</OverviewField>
</div>
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your NFT collection. <br /> This
allows you to launch the NFT collection without requiring any
balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
</StepCard>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useRef } from "react";
import { toast } from "sonner";
import {
defineChain,
getAddress,
getContract,
NATIVE_TOKEN_ADDRESS,
@@ -18,6 +18,7 @@ import {
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -45,18 +46,52 @@ export function CreateTokenAssetPage(props: {
projectSlug: string;
teamPlan: Team["billingPlan"];
}) {
const account = useActiveAccount();
const activeAccount = useActiveAccount();
const { idToChain } = useAllChainsData();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
async function deployContract(formValues: CreateAssetFormValues) {
if (!account) {
toast.error("No Connected Wallet");
function getAccount(gasless: boolean) {
if (!activeAccount) {
throw new Error("No Connected Wallet");
}
const socialUrls = formValues.socialUrls.reduce(
if (gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("Contract address not set");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
return getContract({
address: contractAddress,
chain,
client: props.client,
});
}
async function deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const socialUrls = values.socialUrls.reduce(
(acc, url) => {
if (url.url && url.platform) {
acc[url.platform] = url.url;
@@ -71,29 +106,31 @@ export function CreateTokenAssetPage(props: {
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
Number(values.chain),
idToChain.get(Number(values.chain)),
),
client: props.client,
params: {
description: formValues.description,
image: formValues.image,
description: values.description,
image: values.image,
// metadata
name: formValues.name,
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: formValues.symbol,
symbol: values.symbol,
},
type: "DropERC20",
});
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: formValues.chain,
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
@@ -103,18 +140,17 @@ export function CreateTokenAssetPage(props: {
reportContractDeployed({
address: contractAddress,
chainId: Number(formValues.chain),
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
contractAddressRef.current = contractAddress;
return {
contractAddress: contractAddress,
};
} catch (e) {
console.error(e);
const parsedError = parseError(e);
const errorMessage =
typeof parsedError === "string" ? parsedError : "Unknown error";
@@ -131,32 +167,25 @@ export function CreateTokenAssetPage(props: {
}
}
async function airdropTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
async function airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!contractAddress) {
throw new Error("No contract address");
}
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
});
try {
const airdropTx = transferBatch({
batch: formValues.airdropAddresses.map((recipient) => ({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
@@ -181,28 +210,18 @@ export function CreateTokenAssetPage(props: {
}
}
async function mintTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
async function mintTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
// poll until claim conditions are set before moving on to minting
await pollWithTimeout({
shouldStop: async () => {
@@ -214,9 +233,9 @@ export function CreateTokenAssetPage(props: {
timeoutMs: 30000,
});
const totalSupply = Number(formValues.supply);
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const totalSupply = Number(values.supply);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const ownerAndAirdropPercent = 100 - salePercent;
@@ -248,48 +267,36 @@ export function CreateTokenAssetPage(props: {
}
}
async function setClaimConditions(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
async function setClaimConditions(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const contract = getDeployedContract({
chain: values.chain,
});
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const account = getAccount(gasless);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const totalSupply = Number(formValues.supply);
const totalSupply = Number(values.supply);
const totalSupplyWei = toUnits(totalSupply.toString(), 18);
const phases: ClaimConditionsInput[] = [
{
currencyAddress:
getAddress(formValues.saleTokenAddress) ===
getAddress(values.saleTokenAddress) ===
getAddress(NATIVE_TOKEN_ADDRESS)
? undefined
: formValues.saleTokenAddress,
maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n,
: values.saleTokenAddress,
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
maxClaimableSupply: totalSupplyWei,
metadata: {
name:
formValues.saleEnabled && salePercent > 0
values.saleEnabled && salePercent > 0
? "Coin Sale phase"
: "Only Owner phase",
},
@@ -300,10 +307,7 @@ export function CreateTokenAssetPage(props: {
price: "0",
},
],
price:
formValues.saleEnabled && salePercent > 0
? formValues.salePrice
: "0",
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
startTime: new Date(),
},
];
@@ -22,13 +22,18 @@ import { TokenDistributionFieldset } from "./distribution/token-distribution";
import { LaunchTokenStatus } from "./launch/launch-token";
import { TokenInfoFieldset } from "./token-info/token-info-fieldset";
type CreateTokenFunctionsParams = {
values: CreateAssetFormValues;
gasless: boolean;
};
export type CreateTokenFunctions = {
deployContract: (values: CreateAssetFormValues) => Promise<{
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateAssetFormValues) => Promise<void>;
mintTokens: (values: CreateAssetFormValues) => Promise<void>;
airdropTokens: (values: CreateAssetFormValues) => Promise<void>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
};
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
@@ -13,6 +13,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import {
type MultiStepState,
MultiStepStatus,
@@ -26,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { parseError } from "@/utils/errorParser";
import { ChainOverview } from "../../_common/chain-overview";
import { FilePreview } from "../../_common/file-preview";
@@ -62,6 +64,13 @@ export function LaunchTokenStatus(props: {
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const { idToChain } = useAllChainsData();
const chainMetadata = idToChain.get(Number(formValues.chain));
function updateStatus(
index: number,
newStatus: MultiStepState<StepId>["status"],
@@ -104,30 +113,36 @@ export function LaunchTokenStatus(props: {
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0);
executeSteps(initialSteps, 0, isGasless);
}
const isComplete = steps.every((step) => step.status.type === "completed");
const isPending = steps.some((step) => step.status.type === "pending");
async function executeStep(stepId: StepId) {
async function executeStep(stepId: StepId, gasless: boolean) {
const params = {
gasless,
values: formValues,
};
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(formValues);
const result = await createTokenFunctions.deployContract(params);
setContractLink(
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
);
} else if (stepId === "set-claim-conditions") {
await createTokenFunctions.setClaimConditions(formValues);
await createTokenFunctions.setClaimConditions(params);
} else if (stepId === "mint-tokens") {
await createTokenFunctions.mintTokens(formValues);
await createTokenFunctions.mintTokens(params);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(formValues);
await createTokenFunctions.airdropTokens(params);
}
}
async function executeSteps(
steps: MultiStepState<StepId>[],
startIndex: number,
gasless: boolean,
) {
for (let i = startIndex; i < steps.length; i++) {
const currentStep = steps[i];
@@ -140,7 +155,7 @@ export function LaunchTokenStatus(props: {
type: "pending",
});
await executeStep(currentStep.id);
await executeStep(currentStep.id, gasless);
updateStatus(i, {
type: "completed",
@@ -172,20 +187,22 @@ export function LaunchTokenStatus(props: {
props.onLaunchSuccess();
}
async function handleRetry(step: MultiStepState<StepId>) {
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
const startIndex = steps.findIndex((s) => s.id === step.id);
if (startIndex === -1) {
return;
}
await executeSteps(steps, startIndex);
await executeSteps(steps, startIndex, gasless);
}
return (
<StepCard
nextButton={{
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -253,6 +270,30 @@ export function LaunchTokenStatus(props: {
<TokenDistributionBarChart distributionFormValues={formValues} />
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your coin. <br /> This allows you
to launch the coin without requiring any balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
<Dialog
open={isModalOpen}
// do not set onOpenChange
@@ -274,7 +315,7 @@ export function LaunchTokenStatus(props: {
</DialogHeader>
<MultiStepStatus
onRetry={handleRetry}
onRetry={(step) => handleRetry(step, isGasless)}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
@@ -282,13 +323,40 @@ export function LaunchTokenStatus(props: {
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
onRetry={() => handleRetry(step, isGasless)}
teamSlug={props.teamSlug}
trackingCampaign="create-coin"
/>
);
}
if (
errorMessage
.toLowerCase()
.includes("does not support eip-7702")
) {
return (
<div>
<p className="text-red-500 text-sm mb-2">
Gas Sponsorship is not supported on{" "}
{chainMetadata?.name || "selected chain"}
</p>
<Button
size="sm"
className="gap-2"
onClick={() => {
setIsGasless(false);
handleRetry(step, false);
}}
>
Continue without gas sponsorship
<ArrowRightIcon className="size-4" />
</Button>
</div>
);
}
return null;
}}
steps={steps}
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/projects";
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
@@ -9,7 +9,6 @@ import { CreateVaultAccountButton } from "../../vault/components/create-vault-ac
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SendTestTransaction } from "./send-test-tx.client";
import { deleteUserAccessToken } from "./utils";
interface Props {
managementAccessToken: string | undefined;
@@ -19,27 +18,12 @@ interface Props {
teamSlug: string;
testTxWithWallet?: string | undefined;
client: ThirdwebClient;
isManagedVault: boolean;
}
export const EngineChecklist: React.FC<Props> = (props) => {
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
const finalSteps = useMemo(() => {
const steps: Step[] = [];
steps.push({
children: (
<CreateVaultAccountStep
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
project={props.project}
teamSlug={props.teamSlug}
/>
),
completed: !!props.managementAccessToken,
description:
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
showCompletedChildren: false,
title: "Create a Vault Admin Account",
});
steps.push({
children: (
<CreateServerWalletStep
@@ -48,7 +32,7 @@ export const EngineChecklist: React.FC<Props> = (props) => {
teamSlug={props.teamSlug}
/>
),
completed: props.wallets.length > 0,
completed: props.wallets.length > 0 || props.hasTransactions,
description:
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
showCompletedChildren: false,
@@ -58,10 +42,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
steps.push({
children: (
<SendTestTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
userAccessToken={userAccessToken}
wallets={props.wallets}
/>
),
@@ -78,9 +62,9 @@ export const EngineChecklist: React.FC<Props> = (props) => {
props.project,
props.wallets,
props.hasTransactions,
userAccessToken,
props.teamSlug,
props.client,
props.isManagedVault,
]);
const isComplete = useMemo(
@@ -91,10 +75,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
if (props.testTxWithWallet) {
return (
<SendTestTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
userAccessToken={userAccessToken}
walletId={props.testTxWithWallet}
wallets={props.wallets}
/>
@@ -102,8 +86,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
}
if (finalSteps.length === 0 || isComplete) {
// clear token from local storage after FTUX is complete
deleteUserAccessToken(props.project.id);
return null;
}
return (
@@ -122,10 +104,7 @@ function CreateVaultAccountStep(props: {
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateVaultAccountButton
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
project={props.project}
/>
<CreateVaultAccountButton project={props.project} />
<Button asChild variant="outline">
<Link
href="https://portal.thirdweb.com/vault"
@@ -11,7 +11,6 @@ import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -24,10 +23,9 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
import { useDashboardRouter } from "@/lib/DashboardRouter";
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";
const formSchema = z.object({
accessToken: z.string().min(1, "Access token is required"),
secretKey: z.string().min(1, "Secret key is required"),
chainId: z.number(),
walletIndex: z.string(),
});
@@ -38,9 +36,9 @@ export function SendTestTransaction(props: {
wallets?: Wallet[];
project: Project;
teamSlug: string;
userAccessToken?: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
}) {
const queryClient = useQueryClient();
@@ -49,12 +47,9 @@ export function SendTestTransaction(props: {
const chainsQuery = useAllChainsData();
const userAccessToken =
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
const form = useForm<FormValues>({
defaultValues: {
accessToken: userAccessToken,
secretKey: "",
chainId: 84532,
walletIndex:
props.wallets && props.walletId
@@ -73,7 +68,7 @@ export function SendTestTransaction(props: {
const sendDummyTxMutation = useMutation({
mutationFn: async (args: {
walletAddress: string;
accessToken: string;
secretKey: string;
chainId: number;
}) => {
const response = await engineCloudProxy({
@@ -93,7 +88,9 @@ export function SendTestTransaction(props: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
"x-vault-access-token": args.accessToken,
...(props.isManagedVault
? { "x-secret-key": args.secretKey }
: { "x-vault-access-token": args.secretKey }),
},
method: "POST",
pathname: "/v1/write/transaction",
@@ -123,7 +120,7 @@ export function SendTestTransaction(props: {
const onSubmit = async (data: FormValues) => {
await sendDummyTxMutation.mutateAsync({
accessToken: data.accessToken,
secretKey: data.secretKey,
chainId: data.chainId,
walletAddress: selectedWallet.address,
});
@@ -141,44 +138,39 @@ export function SendTestTransaction(props: {
)}
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" />
{userAccessToken
? "Copy your Vault access token, you'll need it for every HTTP call to Engine."
: "Every wallet action requires your Vault access token."}
Every server wallet action requires your{" "}
{props.isManagedVault ? "project secret key" : "vault access token"}.
</p>
<div className="h-4" />
{/* Responsive container */}
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex-grow">
<div className="flex flex-col gap-2">
<p className="text-sm">Vault Access Token</p>
{userAccessToken ? (
<div className="flex flex-col gap-2 ">
<CopyTextButton
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
copyIconPosition="right"
textToCopy={userAccessToken}
textToShow={userAccessToken}
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This is a project-wide access token to access your server
wallets. You can create more access tokens using your admin
key, with granular scopes and permissions.
</p>
</div>
) : (
<Input
placeholder="vt_act_1234....ABCD"
type={userAccessToken ? "text" : "password"}
{...form.register("accessToken")}
className="text-xs"
disabled={isLoading}
/>
)}
<p className="text-sm">
{props.isManagedVault
? "Project Secret Key"
: "Vault Access Token"}
</p>
<Input
placeholder={
props.isManagedVault
? "Enter your project secret key"
: "Enter your vault access token"
}
type={"password"}
{...form.register("secretKey")}
className="text-xs"
disabled={isLoading}
/>
<p className="text-muted-foreground text-xs">
{props.isManagedVault
? "Your project secret key was generated when you created your project. If you lost it, you can regenerate one in the project settings."
: "Your vault access token was generated when you created your vault. If you lost it, you can regenerate one in the vault settings."}
</p>
</div>
</div>
</div>
<div className="h-4" />
<div className="h-6" />
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
@@ -268,8 +260,6 @@ export function SendTestTransaction(props: {
} else {
router.refresh();
}
// clear token from local storage after FTUX is complete
deleteUserAccessToken(props.project.id);
}}
variant="primary"
>
@@ -64,7 +64,6 @@ export function TransactionsTableUI(props: {
const pageSize = 10;
const transactionsQuery = useQuery({
enabled: !!props.wallets && props.wallets.length > 0,
placeholderData: keepPreviousData,
queryFn: () => props.getData({ page, status }),
queryKey: ["transactions", props.project.id, page, status],
@@ -1,21 +0,0 @@
export function storeUserAccessToken(projectId: string, accessToken: string) {
localStorage.setItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
accessToken,
);
}
export function getUserAccessToken(projectId: string) {
if (typeof localStorage === "undefined") {
return null;
}
return localStorage.getItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
);
}
export function deleteUserAccessToken(projectId: string) {
localStorage.removeItem(
`thirdweb:engine-cloud-user-access-token-${projectId}`,
);
}
@@ -1,8 +1,11 @@
"use client";
import { encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createServiceAccount,
createVaultClient,
rotateServiceAccount,
type VaultClient,
} from "@thirdweb-dev/vault-sdk";
import type { Project } from "@/api/projects";
@@ -27,6 +30,183 @@ export async function initVaultClient() {
return vc;
}
export async function createVaultAccountAndAccessToken(props: {
project: Project;
projectSecretKey?: string;
projectSecretHash?: string;
}) {
try {
const vaultClient = await initVaultClient();
const service = props.project.services.find(
(service) => service.name === "engineCloud",
);
const storedRotationCode = service?.rotationCode;
const storedEncryptedAdminKey = service?.encryptedAdminKey;
let adminKey: string | null = null;
let rotationCode: string | null = null;
if (storedRotationCode && storedEncryptedAdminKey) {
// if the project has a managed vault admin key, rotate it
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode: storedRotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
adminKey = rotateServiceAccountRes.data.newAdminKey;
rotationCode = rotateServiceAccountRes.data.newRotationCode;
} else {
// otherwise create a new service account
const serviceAccountResult = await createServiceAccount({
client: vaultClient,
request: {
options: {
metadata: {
projectId: props.project.id,
purpose: "Thirdweb Project Server Wallet Service Account",
teamId: props.project.teamId,
},
},
},
});
if (serviceAccountResult.success === false) {
throw new Error(
`Failed to create service account: ${serviceAccountResult.error}`,
);
}
const serviceAccount = serviceAccountResult.data;
adminKey = serviceAccount.adminKey;
rotationCode = serviceAccount.rotationCode;
}
const { managementToken, walletToken } =
await createAndEncryptVaultAccessTokens({
project: props.project,
projectSecretKey: props.projectSecretKey,
projectSecretHash: props.projectSecretHash,
vaultClient,
adminKey,
rotationCode,
});
return {
adminKey,
managementToken,
walletToken,
};
} catch (error) {
throw new Error(
`Failed to create vault account and access token: ${error}`,
);
}
}
async function createAndEncryptVaultAccessTokens(props: {
project: Project;
vaultClient: VaultClient;
projectSecretKey?: string;
projectSecretHash?: string;
adminKey: string;
rotationCode: string;
}) {
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
props;
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
createWalletAccessToken({ project, adminKey, vaultClient }),
]);
if (!managementTokenResult.success) {
throw new Error(
`Failed to create management token: ${managementTokenResult.error}`,
);
}
if (!walletTokenResult.success) {
throw new Error(
`Failed to create wallet token: ${walletTokenResult.error}`,
);
}
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
if (projectSecretKey) {
// verify that the project secret key is valid
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
const secretKeysHashed = [
...project.secretKeys,
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
];
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
throw new Error("Invalid project secret key");
}
// encrypt admin key and wallet token with project secret key
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey,
encryptedWalletAccessToken,
rotationCode: rotationCode,
},
],
},
);
} else {
// no secret key, only store the management token, remove any encrypted keys
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
name: "engineCloud",
actions: [],
managementAccessToken: managementToken.accessToken,
maskedAdminKey: maskSecret(adminKey),
encryptedAdminKey: null,
encryptedWalletAccessToken: null,
rotationCode: rotationCode,
},
],
},
);
}
return {
managementToken,
walletToken,
};
}
export async function createWalletAccessToken(props: {
project: Project;
adminKey: string;
@@ -243,13 +423,12 @@ export async function createWalletAccessToken(props: {
});
}
export async function createManagementAccessToken(props: {
async function createManagementAccessToken(props: {
project: Project;
adminKey: string;
rotationCode: string;
vaultClient: VaultClient;
}) {
const res = await createAccessToken({
return createAccessToken({
client: props.vaultClient,
request: {
auth: {
@@ -333,31 +512,26 @@ export async function createManagementAccessToken(props: {
},
},
});
if (res.success) {
const data = res.data;
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
actions: [],
managementAccessToken: data.accessToken,
maskedAdminKey: maskSecret(props.adminKey),
name: "engineCloud",
rotationCode: props.rotationCode,
},
],
},
);
return res;
}
throw new Error(`Failed to create management access token: ${res.error}`);
}
export function maskSecret(secret: string) {
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
}
async function hashSecretKey(secretKey: string) {
if (typeof window === "undefined" || !window.crypto?.subtle) {
throw new Error(
"This function can only be used in the browser environment with Web Crypto API support.",
);
}
const encoder = new TextEncoder();
const data = encoder.encode(secretKey);
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
return bufferToHex(hashBuffer);
}
function bufferToHex(buffer: ArrayBuffer) {
return [...new Uint8Array(buffer)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
}
@@ -7,10 +7,7 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { EngineChecklist } from "./analytics/ftux.client";
import { TransactionAnalyticsSummary } from "./analytics/summary";
import {
getTransactionAnalyticsSummary,
type TransactionSummaryData,
} from "./lib/analytics";
import { getTransactionAnalyticsSummary } from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
export default async function TransactionsAnalyticsPage(props: {
@@ -53,6 +50,7 @@ export default async function TransactionsAnalyticsPage(props: {
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const eoas = managementAccessToken
? await listEoas({
@@ -68,14 +66,10 @@ export default async function TransactionsAnalyticsPage(props: {
const wallets = eoas.data?.items as Wallet[] | undefined;
let initialData: TransactionSummaryData | undefined;
if (wallets && wallets.length > 0) {
const summary = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
}).catch(() => undefined);
initialData = summary;
}
const initialData = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
}).catch(() => undefined);
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
const client = getClientThirdwebClient({
@@ -86,6 +80,7 @@ export default async function TransactionsAnalyticsPage(props: {
return (
<div className="flex grow flex-col">
<EngineChecklist
isManagedVault={isManagedVault}
client={client}
hasTransactions={hasTransactions}
managementAccessToken={managementAccessToken ?? undefined}
@@ -203,7 +203,6 @@ const client = createThirdwebClient({
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// Prepare the transaction
@@ -241,7 +240,6 @@ const curlExample = () => `\
curl -X POST "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract" \\
-H "Content-Type: application/json" \\
-H "x-secret-key: <your-project-secret-key>" \\
-H "x-vault-access-token: <your-vault-access-token>" \\
-d '{
"executionOptions": {
"from": "<your-server-wallet-address>",
@@ -264,7 +262,6 @@ const response = await fetch(
headers: {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
},
body: JSON.stringify({
"executionOptions": {
@@ -290,7 +287,6 @@ url = "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract"
headers = {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
}
payload = {
"executionOptions": {
@@ -355,7 +351,6 @@ func main() {
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-secret-key", "<your-project-secret-key>")
req.Header.Set("x-vault-access-token", "<your-vault-access-token>")
// Send the request
client := &http.Client{}
@@ -408,7 +403,6 @@ class Program
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("x-secret-key", "<your-project-secret-key>");
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
var response = await httpClient.PostAsync(url, content);
var responseContent = await response.Content.ReadAsStringAsync();
@@ -1,13 +1,20 @@
export const embedCode = (clientId: string) => `\
import { createThirdwebClient } from "thirdweb";
import { PayEmbed } from "thirdweb/react";
import { BuyWidget } from "thirdweb/react";
import { ethereum } from "thirdweb/chains";
const client = createThirdwebClient({
clientId: "${clientId}",
});
export default function App() {
return <PayEmbed client={client} />;
return (
<BuyWidget
client={client}
chain={ethereum}
amount="0.1"
/>
);
}`;
export const sdkCode = (clientId: string) => `\
@@ -25,10 +25,10 @@ export default async function Layout(props: {
<div className="pt-4 lg:pt-6">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Universal Bridge
Payments
</h1>
<p className="max-w-3xl text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
Payments allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
<UnderlineLink
@@ -63,9 +63,7 @@ export default async function Page(props: {
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Universal Bridge
</h3>
<h3 className="font-medium text-lg">Get Started with Payments</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
</p>
@@ -1,7 +1,6 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { createServiceAccount } from "@thirdweb-dev/vault-sdk";
import {
CheckIcon,
DownloadIcon,
@@ -21,91 +20,74 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { storeUserAccessToken } from "../../transactions/analytics/utils";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
createVaultAccountAndAccessToken,
maskSecret,
} from "../../transactions/lib/vault.client";
export function CreateVaultAccountButton(props: {
project: Project;
onUserAccessTokenCreated?: (userAccessToken: string) => void;
}) {
export function CreateVaultAccountButton(props: { project: Project }) {
const [secretKeyModalOpen, setSecretKeyModalOpen] = useState(false);
const [secretKey, setSecretKey] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [manageSelfChecked, setManageSelfChecked] = useState(false);
const router = useDashboardRouter();
const initialiseProjectWithVaultMutation = useMutation({
mutationFn: async () => {
mutationFn: async (projectSecretKey: string | undefined) => {
setModalOpen(true);
setSecretKeyModalOpen(false);
const vaultClient = await initVaultClient();
const serviceAccount = await createServiceAccount({
client: vaultClient,
request: {
options: {
metadata: {
projectId: props.project.id,
purpose: "Thirdweb Project Server Wallet Service Account",
teamId: props.project.teamId,
},
},
},
});
if (!serviceAccount.success) {
throw new Error("Failed to create service account");
}
const managementAccessTokenPromise = createManagementAccessToken({
adminKey: serviceAccount.data.adminKey,
const result = await createVaultAccountAndAccessToken({
project: props.project,
rotationCode: serviceAccount.data.rotationCode,
vaultClient,
projectSecretKey,
});
const userAccesTokenPromise = createWalletAccessToken({
adminKey: serviceAccount.data.adminKey,
project: props.project,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
// save in local storage in case the user refreshes the page during FTUX
storeUserAccessToken(
props.project.id,
userAccessTokenRes.data.accessToken,
);
props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken);
return {
serviceAccount: serviceAccount.data,
userAccessToken: userAccessTokenRes.data,
};
return result;
},
onError: (error) => {
toast.error(error.message);
setErrorMessage(error.message);
setModalOpen(false);
},
});
const handleCreateServerWallet = async () => {
await initialiseProjectWithVaultMutation.mutateAsync();
const handleInitializeVault = async () => {
setSecretKeyModalOpen(true);
setErrorMessage("");
setSecretKey("");
setManageSelfChecked(false);
};
const handleSecretKeySubmit = async () => {
if (!manageSelfChecked && !secretKey.trim()) {
setErrorMessage(
"Please enter your project secret key or check the option to manage keys yourself",
);
return;
}
try {
setErrorMessage("");
await initialiseProjectWithVaultMutation.mutateAsync(
manageSelfChecked ? undefined : secretKey,
);
} catch (error) {
// Error will be handled by the mutation's onError
}
};
const handleCancelSecretKey = () => {
setSecretKeyModalOpen(false);
setSecretKey("");
setErrorMessage("");
setManageSelfChecked(false);
};
const handleDownloadKeys = () => {
@@ -113,7 +95,7 @@ export function CreateVaultAccountButton(props: {
return;
}
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.accessToken}\n`;
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.walletToken.accessToken}\n`;
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -147,11 +129,11 @@ export function CreateVaultAccountButton(props: {
const isLoading = initialiseProjectWithVaultMutation.isPending;
return (
<>
<div>
<Button
className="flex flex-row items-center gap-2"
disabled={isLoading}
onClick={handleCreateServerWallet}
onClick={handleInitializeVault}
variant={"primary"}
>
{isLoading ? (
@@ -162,6 +144,93 @@ export function CreateVaultAccountButton(props: {
{"Create Vault Admin Account"}
</Button>
{/* Secret Key Input Modal */}
<Dialog
modal={true}
onOpenChange={handleCancelSecretKey}
open={secretKeyModalOpen}
>
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="pt-6 px-6">
<DialogTitle>Create Your Vault</DialogTitle>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-3">
<Label htmlFor="secretKey">
Let thirdweb manage your Vault keys for you
</Label>
<p className="text-muted-foreground text-sm">
Lets you use your project secret key to access your vault like
any other thirdweb service (recommended).
</p>
<Input
id="secretKey"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
disabled={
initialiseProjectWithVaultMutation.isPending ||
manageSelfChecked
}
/>
<p className="text-muted-foreground text-xs">
Your project secret key was generated when you created your
project. If you lost it, you can regenerate one in your project
settings.
</p>
</div>
<div className="flex flex-col gap-2">
<hr className="mb-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={manageSelfChecked}
onCheckedChange={(v) => setManageSelfChecked(!!v)}
disabled={initialiseProjectWithVaultMutation.isPending}
/>
I want to manage my Vault keys myself (advanced)
</CheckboxWithLabel>
</div>
{errorMessage && (
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCancelSecretKey}
variant="outline"
disabled={initialiseProjectWithVaultMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleSecretKeySubmit}
variant="primary"
disabled={
(!secretKey.trim() && !manageSelfChecked) ||
initialiseProjectWithVaultMutation.isPending
}
>
{initialiseProjectWithVaultMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating vault...
</>
) : (
"Create vault"
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Keys Display Modal */}
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
<DialogContent
className="overflow-hidden p-0"
@@ -197,12 +266,10 @@ export function CreateVaultAccountButton(props: {
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
copyIconPosition="right"
textToCopy={
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey
initialiseProjectWithVaultMutation.data.adminKey
}
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey,
initialiseProjectWithVaultMutation.data.adminKey,
)}
tooltip="Copy Admin Key"
/>
@@ -212,32 +279,6 @@ export function CreateVaultAccountButton(props: {
</p>
</div>
</div>
{/* <div>
<h3 className="mb-2 font-medium text-sm">
Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div> */}
</div>
<Alert variant="destructive">
<AlertTitle>Secure your admin key</AlertTitle>
@@ -285,6 +326,6 @@ export function CreateVaultAccountButton(props: {
) : null}
</DialogContent>
</Dialog>
</>
</div>
);
}
@@ -9,15 +9,17 @@ import RotateAdminKeyButton from "./rotate-admin-key.client";
export function KeyManagement({
maskedAdminKey,
project,
isManagedVault,
}: {
maskedAdminKey?: string;
project: Project;
isManagedVault: boolean;
}) {
return (
<div className="flex flex-col gap-6">
{!maskedAdminKey && <CreateVaultAccountAlert project={project} />}
{maskedAdminKey && (
{maskedAdminKey && !isManagedVault && (
<>
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
@@ -37,13 +39,42 @@ export function KeyManagement({
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} />
<RotateAdminKeyButton
project={project}
isManagedVault={false}
/>
</div>
</div>
</div>
<ListAccessTokens project={project} />
</>
)}
{isManagedVault && (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
Managed Vault
</h2>
<p className="text-muted-foreground text-sm">
Your vault is currently managed by Thirdweb so you can access it
via you project secret key. You can eject and manage your own
vault keys at any time. Doing so means you'll need to pass your
own vault access token to the transactions API additionally to
your project secret key.
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} isManagedVault={true} />
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk";
import {
CheckIcon,
CircleAlertIcon,
DownloadIcon,
Loader2Icon,
LogOutIcon,
RefreshCcwIcon,
} from "lucide-react";
import { useState } from "react";
@@ -27,13 +27,14 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
createVaultAccountAndAccessToken,
maskSecret,
} from "../../transactions/lib/vault.client";
export default function RotateAdminKeyButton(props: { project: Project }) {
export default function RotateAdminKeyButton(props: {
project: Project;
isManagedVault: boolean;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
@@ -41,55 +42,15 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
const rotateAdminKeyMutation = useMutation({
mutationFn: async () => {
const vaultClient = await initVaultClient();
const rotationCode = props.project.services.find(
(service) => service.name === "engineCloud",
)?.rotationCode;
if (!rotationCode) {
throw new Error("Rotation code not found");
}
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
// need to recreate the management access token with the new admin key
const managementAccessTokenPromise = createManagementAccessToken({
adminKey: rotateServiceAccountRes.data.newAdminKey,
// passing no secret key means we're rotating the admin key and deleting any stored keys
const result = await createVaultAccountAndAccessToken({
project: props.project,
rotationCode: rotateServiceAccountRes.data.newRotationCode,
vaultClient,
});
const userAccesTokenPromise = createWalletAccessToken({
adminKey: rotateServiceAccountRes.data.newAdminKey,
project: props.project,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
return {
adminKey: rotateServiceAccountRes.data.newAdminKey,
adminKey: result.adminKey,
success: true,
userAccessToken: userAccessTokenRes.data,
userAccessToken: result.walletToken,
};
},
onError: (error) => {
@@ -144,8 +105,12 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
variant="outline"
>
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
{!isLoading && <RefreshCcwIcon className="size-4" />}
Rotate Admin Key
{!isLoading && props.isManagedVault ? (
<LogOutIcon className="size-4" />
) : (
<RefreshCcwIcon className="size-4" />
)}
{props.isManagedVault ? "Eject From Managed Vault" : "Rotate Admin Key"}
</Button>
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
@@ -22,11 +22,13 @@ export default async function VaultPage(props: {
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
return (
<div className="flex flex-col gap-8">
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
</div>
@@ -0,0 +1,95 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SearchType } from "./types";
export function AdvancedSearchInput(props: {
onSearch: (searchType: SearchType, query: string) => void;
onClear: () => void;
isLoading: boolean;
hasResults: boolean;
}) {
const [searchType, setSearchType] = useState<SearchType>("email");
const [query, setQuery] = useState("");
const handleSearch = () => {
if (query.trim()) {
props.onSearch(searchType, query.trim());
}
};
const handleClear = () => {
setQuery("");
props.onClear();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Select
value={searchType}
onValueChange={(value) => setSearchType(value as SearchType)}
>
<SelectTrigger className="w-[140px] bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="address">Address</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1">
<Input
className="bg-card pl-9"
placeholder={`Search by ${searchType}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
<Button
onClick={handleSearch}
disabled={!query.trim() || props.isLoading}
size="sm"
>
{props.isLoading ? "Searching..." : "Search"}
</Button>
</div>
{props.hasResults && (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="gap-2"
>
Clear Search & Return to List
</Button>
</div>
)}
</div>
);
}
@@ -1,22 +0,0 @@
"use client";
import { SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
export function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
className="bg-card pl-9"
onChange={(e) => props.onValueChange(e.target.value)}
placeholder={props.placeholder}
value={props.value}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { format } from "date-fns";
import type { ThirdwebClient } from "thirdweb";
import type { WalletUser } from "thirdweb/wallets";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const getUserIdentifier = (user: WalletUser) => {
const mainDetail = user.linkedAccounts[0]?.details;
return (
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id ??
user.id
);
};
export function SearchResults(props: {
results: WalletUser[];
client: ThirdwebClient;
}) {
if (props.results.length === 0) {
return (
<Card>
<CardContent className="py-8">
<div className="flex flex-col items-center gap-3">
<p className="text-sm">No users found</p>
<p className="text-muted-foreground text-sm">
Try searching with different criteria
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{props.results.map((user) => {
const walletAddress = user.wallets?.[0]?.address;
const createdAt = user.wallets?.[0]?.createdAt;
const mainDetail = user.linkedAccounts?.[0]?.details;
const email = mainDetail?.email as string | undefined;
const phone = mainDetail?.phone as string | undefined;
return (
<Card key={user.id}>
<CardHeader>
<CardTitle className="text-lg">User Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">
User Identifier
</p>
<p className="text-sm">{getUserIdentifier(user)}</p>
</div>
{walletAddress && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Wallet Address
</p>
<WalletAddress
address={walletAddress}
client={props.client}
/>
</div>
)}
{email && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Email
</p>
<p className="text-sm">{email}</p>
</div>
)}
{phone && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Phone
</p>
<p className="text-sm">{phone}</p>
</div>
)}
{createdAt && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Created
</p>
<p className="text-sm">
{format(new Date(createdAt), "MMM dd, yyyy")}
</p>
</div>
)}
<div>
<p className="text-sm font-medium text-muted-foreground">
Login Methods
</p>
<div className="flex flex-wrap gap-1">
{user.linkedAccounts?.map((account, index) => (
<TooltipProvider
key={`${user.id}-${account.type}-${index}`}
>
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs">
{account.type}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm space-y-1">
{Object.entries(account.details).map(
([key, value]) => (
<div key={key}>
<span className="font-medium">{key}:</span>{" "}
{String(value)}
</div>
),
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
@@ -21,7 +21,10 @@ import {
useAllEmbeddedWallets,
useEmbeddedWallets,
} from "@/hooks/useEmbeddedWallets";
import { SearchInput } from "./SearchInput";
import { AdvancedSearchInput } from "./AdvancedSearchInput";
import { SearchResults } from "./SearchResults";
import { searchUsers } from "./searchUsers";
import type { SearchType } from "./types";
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
@@ -35,11 +38,16 @@ const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const columnHelper = createColumnHelper<WalletUser>();
export function InAppWalletUsersPageContent(props: {
authToken: string;
projectClientId: string;
client: ThirdwebClient;
}) {
export function InAppWalletUsersPageContent(
props: {
authToken: string;
client: ThirdwebClient;
teamId: string;
} & (
| { projectClientId: string; ecosystemSlug?: never }
| { ecosystemSlug: string; projectClientId?: never }
),
) {
const columns = useMemo(() => {
return [
columnHelper.accessor("linkedAccounts", {
@@ -110,49 +118,57 @@ export function InAppWalletUsersPageContent(props: {
}, [props.client]);
const [activePage, setActivePage] = useState(1);
const [searchValue, setSearchValue] = useState("");
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearchResults, setHasSearchResults] = useState(false);
const walletsQuery = useEmbeddedWallets({
authToken: props.authToken,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
page: activePage,
});
const wallets = walletsQuery?.data?.users || [];
const filteredWallets = searchValue
? wallets.filter((wallet) => {
const term = searchValue.toLowerCase();
if (wallet.id.toLowerCase().includes(term)) {
return true;
}
if (
wallet.wallets?.some((w) => w.address?.toLowerCase().includes(term))
) {
return true;
}
if (
wallet.linkedAccounts?.some((acc) => {
return Object.values(acc.details).some(
(detail) =>
typeof detail === "string" &&
detail.toLowerCase().includes(term),
);
})
) {
return true;
}
return false;
})
: wallets;
const { mutateAsync: getAllEmbeddedWallets, isPending } =
useAllEmbeddedWallets({
authToken: props.authToken,
});
const handleSearch = async (searchType: SearchType, query: string) => {
setIsSearching(true);
try {
const results = await searchUsers(
props.authToken,
props.projectClientId,
props.ecosystemSlug,
props.teamId,
searchType,
query,
);
setSearchResults(results);
setHasSearchResults(true);
} catch (error) {
console.error("Search failed:", error);
setSearchResults([]);
setHasSearchResults(true);
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchResults([]);
setHasSearchResults(false);
};
const downloadCSV = useCallback(async () => {
if (wallets.length === 0 || !getAllEmbeddedWallets) {
return;
}
const usersWallets = await getAllEmbeddedWallets({
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
});
const csv = Papa.unparse(
usersWallets.map((row) => {
@@ -173,64 +189,79 @@ export function InAppWalletUsersPageContent(props: {
tempLink.href = csvUrl;
tempLink.setAttribute("download", "download.csv");
tempLink.click();
}, [wallets, props.projectClientId, getAllEmbeddedWallets]);
}, [
wallets,
props.projectClientId,
getAllEmbeddedWallets,
props.teamId,
props.ecosystemSlug,
]);
return (
<div>
<div className="flex flex-col gap-4">
{/* Top section */}
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-xs">
<SearchInput
onValueChange={setSearchValue}
placeholder="Search"
value={searchValue}
/>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-lg">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<div>
<TWTable
columns={columns}
data={filteredWallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
</div>
@@ -0,0 +1,56 @@
import type { WalletUser } from "thirdweb/wallets";
import { THIRDWEB_EWS_API_HOST } from "@/constants/urls";
import type { SearchType } from "./types";
export async function searchUsers(
authToken: string,
clientId: string | undefined,
ecosystemSlug: string | undefined,
teamId: string,
searchType: SearchType,
query: string,
): Promise<WalletUser[]> {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
// Add search parameter based on search type
switch (searchType) {
case "email":
url.searchParams.append("email", query);
break;
case "phone":
url.searchParams.append("phone", query);
break;
case "id":
url.searchParams.append("id", query);
break;
case "address":
url.searchParams.append("address", query);
break;
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to search users: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return data.users as WalletUser[];
}
@@ -0,0 +1 @@
export type SearchType = "email" | "phone" | "id" | "address";
@@ -34,6 +34,7 @@ export default async function Page(props: {
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
);
}
@@ -0,0 +1,231 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import type { WebhookConfig } from "@/api/webhook-configs";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import type { ChartConfig } from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter";
interface WebhookAnalyticsChartsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsCharts({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsChartsProps) {
// Data is already filtered server-side
const filteredRequestsData = requestsData;
const filteredLatencyData = latencyData;
// Process status code distribution data by individual status codes
const statusCodeData = useMemo(() => {
if (!filteredRequestsData.length) return [];
const groupedData = filteredRequestsData.reduce(
(acc, item) => {
const date = new Date(item.date).getTime();
if (!acc[date]) {
acc[date] = { time: date };
}
// Only include valid status codes (not 0) with actual request counts
if (item.httpStatusCode > 0 && item.totalRequests > 0) {
const statusKey = item.httpStatusCode.toString();
acc[date][statusKey] =
(acc[date][statusKey] || 0) + item.totalRequests;
}
return acc;
},
{} as Record<string, Record<string, number> & { time: number }>,
);
return Object.values(groupedData).sort(
(a, b) => (a.time || 0) - (b.time || 0),
);
}, [filteredRequestsData]);
// Process latency data for charts
const latencyChartData = useMemo(() => {
if (!filteredLatencyData.length) return [];
return filteredLatencyData
.map((item) => ({
p50: item.p50LatencyMs,
p90: item.p90LatencyMs,
p99: item.p99LatencyMs,
time: new Date(item.date).getTime(),
}))
.sort((a, b) => a.time - b.time);
}, [filteredLatencyData]);
// Chart configurations
const latencyChartConfig: ChartConfig = {
p50: {
color: "hsl(var(--chart-1))",
label: "P50 Latency",
},
p90: {
color: "hsl(var(--chart-2))",
label: "P90 Latency",
},
p99: {
color: "hsl(var(--chart-3))",
label: "P99 Latency",
},
};
// Generate status code chart config dynamically with class-based colors
const statusCodeConfig: ChartConfig = useMemo(() => {
const statusCodes = new Set<string>();
statusCodeData.forEach((item) => {
Object.keys(item).forEach((key) => {
if (key !== "time" && !Number.isNaN(Number.parseInt(key))) {
statusCodes.add(key);
}
});
});
const getColorForStatusCode = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) {
return "hsl(var(--chart-1))"; // Green for 2xx
} else if (statusCode >= 300 && statusCode < 400) {
return "hsl(var(--chart-2))"; // Yellow for 3xx
} else if (statusCode >= 400 && statusCode < 500) {
return "hsl(var(--chart-3))"; // Orange for 4xx
} else {
return "hsl(var(--chart-4))"; // Red for 5xx
}
};
const config: ChartConfig = {};
Array.from(statusCodes)
.sort((a, b) => {
const codeA = Number.parseInt(a);
const codeB = Number.parseInt(b);
return codeA - codeB;
})
.forEach((statusKey) => {
const statusCode = Number.parseInt(statusKey);
config[statusKey] = {
color: getColorForStatusCode(statusCode),
label: statusCode.toString(),
};
});
return config;
}, [statusCodeData]);
const hasData = statusCodeData.length > 0 || latencyChartData.length > 0;
const selectedWebhookConfig = webhookConfigs.find(
(w) => w.id === selectedWebhookId,
);
return (
<div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6">
<WebhookPicker
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
<DateRangeControls />
</div>
{/* Selected webhook URL */}
{selectedWebhookConfig && selectedWebhookId !== "all" && (
<div className="mb-6">
<CopyTextButton
className="font-mono text-sm"
copyIconPosition="right"
textToCopy={selectedWebhookConfig.destinationUrl}
textToShow={selectedWebhookConfig.destinationUrl}
tooltip="Copy webhook URL"
variant="outline"
/>
</div>
)}
{!hasData ? (
<div className="flex items-center justify-center p-8 rounded-xl border border-border bg-card">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">
No webhook data available
</h3>
<p className="text-sm text-muted-foreground">
Webhook analytics will appear here once you start receiving
webhook events.
</p>
</div>
</div>
) : (
<ResponsiveSuspense
fallback={
<div className="flex flex-col gap-6">
<Skeleton className="h-[350px] rounded-xl" />
<Skeleton className="h-[350px] rounded-xl" />
</div>
}
searchParamsUsed={["from", "to", "interval", "webhook"]}
>
<div className="flex flex-col gap-6">
{/* Status Code Distribution Chart */}
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
config={statusCodeConfig}
data={statusCodeData}
header={{
description: "Breakdown of responses by status code",
title: "Response Status Codes",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value} requests`}
variant="stacked"
/>
{/* Latency Chart */}
<ThirdwebAreaChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="w-full"
config={latencyChartConfig}
data={latencyChartData}
header={{
description: "Webhook response times in milliseconds",
title: "Response Latency",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value}ms`}
/>
</div>
</ResponsiveSuspense>
)}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
type SearchParams = {
from?: string;
to?: string;
webhook?: string;
interval?: "day" | "week";
};
interface WebhookAnalyticsFilterProps {
webhookConfigs: Array<{
id: string;
description: string | null;
}>;
selectedWebhookId: string;
}
export function WebhookPicker({
webhookConfigs,
selectedWebhookId,
}: WebhookAnalyticsFilterProps) {
const setResponsiveSearchParams = useSetResponsiveSearchParams();
return (
<Select
onValueChange={(value) => {
setResponsiveSearchParams((prev) => ({
...prev,
webhook: value,
}));
}}
value={selectedWebhookId}
>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Webhooks</SelectItem>
{webhookConfigs.map((config) => (
<SelectItem key={config.id} value={config.id}>
<span>{config.description || "Unnamed webhook"}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function DateRangeControls() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="flex items-center gap-3">
<DateRangeSelector
popoverAlign="end"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
return newParams;
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
interval: newInterval,
};
return newParams;
});
}}
/>
</div>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts";
interface WebhookAnalyticsServerProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsServer({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsServerProps) {
return (
<WebhookAnalyticsCharts
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer";
interface WebhooksAnalyticsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhooksAnalytics({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhooksAnalyticsProps) {
return (
<WebhookAnalyticsServer
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -0,0 +1,134 @@
import { notFound, redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getWebhookLatency, getWebhookRequests } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { getWebhookConfigs } from "@/api/webhook-configs";
import { getFiltersFromSearchParams } from "@/lib/time";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { WebhooksAnalytics } from "./components/WebhooksAnalytics";
export default async function WebhooksAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
webhook?: string | undefined | string[];
}>;
}) {
const [authToken, params, account] = await Promise.all([
getAuthToken(),
props.params,
getValidAccount(),
]);
if (!account || !authToken) {
notFound();
}
const [isFeatureEnabled, project] = await Promise.all([
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
notFound();
}
if (!isFeatureEnabled) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
const searchParams = await props.searchParams;
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-7",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
// Get webhook configs
const webhookConfigsResponse = await getWebhookConfigs({
projectIdOrSlug: params.project_slug,
teamIdOrSlug: params.team_slug,
}).catch(() => ({
body: "",
data: [],
reason: "Failed to fetch webhook configs",
status: "error" as const,
}));
if (
webhookConfigsResponse.status === "error" ||
webhookConfigsResponse.data.length === 0
) {
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">
No webhook configurations found.
</p>
</div>
</ResponsiveSearchParamsProvider>
);
}
// Get selected webhook ID from search params
const selectedWebhookId = Array.isArray(searchParams.webhook)
? searchParams.webhook[0] || "all"
: searchParams.webhook || "all";
// Fetch webhook analytics data
const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId;
const [requestsData, latencyData] = await Promise.all([
(async () => {
const res = await getWebhookRequests({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook requests:", res.error);
return [];
}
return res.data;
})(),
(async () => {
const res = await getWebhookLatency({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook latency:", res.error);
return [];
}
return res.data;
})(),
]);
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<WebhooksAnalytics
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigsResponse.data}
/>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,23 @@
import type { Topic } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface CreateWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
}
export function CreateWebhookConfigModal(props: CreateWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="create"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
);
}
@@ -0,0 +1,101 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import type { WebhookSummaryStats } from "@/types/analytics";
import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs";
interface DeleteWebhookModalProps {
webhookConfig: WebhookConfig | null;
metrics: WebhookSummaryStats | null;
onConfirm: () => void;
isPending: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DeleteWebhookModal(props: DeleteWebhookModalProps) {
if (!props.webhookConfig) {
return null;
}
// Use real metrics data
const requests24h = props.metrics?.totalRequests ?? 0;
const hasRecentActivity = requests24h > 0;
return (
<Dialog onOpenChange={props.onOpenChange} open={props.open}>
<DialogContent className="p-0">
<DialogHeader className="mb-4 p-6">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Delete Webhook Configuration
</DialogTitle>
<DialogDescription className="space-y-4">
<div>
Are you sure you want to delete this webhook configuration? This
action cannot be undone.
</div>
<div className="space-y-4 py-4">
<div>
<div className="font-medium text-sm">Description</div>
<div className="text-sm text-muted-foreground">
{props.webhookConfig.description || "No description"}
</div>
</div>
<div>
<div className="font-medium text-sm">URL</div>
<div className="text-sm text-muted-foreground break-all">
{props.webhookConfig.destinationUrl}
</div>
</div>
</div>
{hasRecentActivity && (
<Alert variant="warning">
<AlertTriangleIcon className="size-5" />
<AlertTitle className="text-sm">
Recent Activity Detected
</AlertTitle>
<AlertDescription className="mt-0.5 text-sm">
This webhook has received {requests24h} requests in the last
24 hours. Deleting it may impact your integrations.
</AlertDescription>
</Alert>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={props.isPending}
onClick={() => {
props.onOpenChange(false);
}}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isPending}
onClick={props.onConfirm}
variant="destructive"
>
{props.isPending && <Spinner className="size-4" />}
Delete Webhook
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,25 @@
import type { Topic, WebhookConfig } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface EditWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig: WebhookConfig;
}
export function EditWebhookConfigModal(props: EditWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="edit"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={props.webhookConfig}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { redirect } from "next/navigation";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { WebhookConfigsTable } from "./webhook-configs-table";
interface WebhooksOverviewProps {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}
export function WebhooksOverview({
teamId,
teamSlug,
projectId,
projectSlug,
webhookConfigs,
topics,
metricsMap,
}: WebhooksOverviewProps) {
// Feature is enabled (matches server component behavior)
const isFeatureEnabled = true;
// Redirect to contracts tab if feature is disabled
if (!isFeatureEnabled) {
redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`);
}
// Show full webhook functionality
return (
<WebhookConfigsTable
metricsMap={metricsMap}
projectId={projectId}
projectSlug={projectSlug}
teamId={teamId}
teamSlug={teamSlug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,195 @@
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { Topic } from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
const TOPIC_IDS_THAT_SUPPORT_FILTERS = [
"insight.event.confirmed",
"insight.transaction.confirmed",
];
interface TopicSelectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
topics: Topic[];
selectedTopics: { id: string; filters: object | null }[];
onSelectionChange: (topics: { id: string; filters: object | null }[]) => void;
}
export function TopicSelectorModal(props: TopicSelectorModalProps) {
const [tempSelection, setTempSelection] = useState<
{ id: string; filters: object | null }[]
>(props.selectedTopics);
// Initialize topicFilters with existing filters
const [topicFilters, setTopicFilters] = useState<Record<string, string>>(
() => {
const initialFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
initialFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
return initialFilters;
},
);
const groupedTopics = useMemo(() => {
const groups: Record<string, Topic[]> = {};
props.topics.forEach((topic) => {
const service = topic.id.split(".")[0] || "other";
if (!groups[service]) {
groups[service] = [];
}
groups[service].push(topic);
});
// Sort groups by service name and topics within each group
const sortedGroups: Record<string, Topic[]> = {};
Object.keys(groups)
.sort()
.forEach((service) => {
sortedGroups[service] =
groups[service]?.sort((a, b) => a.id.localeCompare(b.id)) || [];
});
return sortedGroups;
}, [props.topics]);
function handleTopicToggle(topicId: string, checked: boolean) {
if (checked) {
// Preserve existing filters if re-selecting a topic
const existingTopic = props.selectedTopics.find((t) => t.id === topicId);
setTempSelection((prev) => [
...prev,
{ id: topicId, filters: existingTopic?.filters || null },
]);
} else {
setTempSelection((prev) => prev.filter((topic) => topic.id !== topicId));
}
}
function handleSave() {
const processedSelection = tempSelection.map((topic) => {
const filters = topicFilters[topic.id];
if (filters) {
try {
return { ...topic, filters: JSON.parse(filters) };
} catch (_error) {
toast.error(`Invalid JSON in filters for ${topic.id}`);
throw new Error(`Invalid JSON in filters for ${topic.id}`);
}
}
return topic;
});
props.onSelectionChange(processedSelection);
props.onOpenChange(false);
}
function handleCancel() {
setTempSelection(props.selectedTopics);
// Reset filter texts to original values
const originalFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
originalFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
setTopicFilters(originalFilters);
props.onOpenChange(false);
}
return (
<Dialog onOpenChange={handleCancel} open={props.open}>
<DialogContent className="max-h-[80vh] max-w-2xl p-0 overflow-hidden flex flex-col">
<DialogHeader className="p-6 pb-4 flex-shrink-0">
<DialogTitle className="font-semibold text-xl tracking-tight">
Select Topics
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 min-h-0">
<div className="space-y-6 pb-4">
{Object.entries(groupedTopics).map(([service, topics]) => (
<div key={service}>
<h3 className="font-medium text-sm mb-4 text-foreground capitalize">
{service}
</h3>
<div className="space-y-3 ml-4">
{topics.map((topic) => (
<div className="flex items-start space-x-3" key={topic.id}>
<Checkbox
checked={tempSelection.some((t) => t.id === topic.id)}
id={topic.id}
onCheckedChange={(checked) =>
handleTopicToggle(topic.id, !!checked)
}
/>
<div className="grid gap-1.5 leading-none flex-1">
<label
className="text-sm font-mono font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
htmlFor={topic.id}
>
{topic.id}
</label>
<p className="text-xs text-muted-foreground">
{topic.description}
</p>
{/* Show textarea when selecting a topic that supports filters */}
{TOPIC_IDS_THAT_SUPPORT_FILTERS.includes(topic.id) &&
tempSelection.some((t) => t.id === topic.id) && (
<div className="mt-2">
<Textarea
placeholder={`{\n "key": "value"\n}`}
rows={3}
value={topicFilters[topic.id] || ""}
onChange={(e) => {
setTopicFilters((prev) => ({
...prev,
[topic.id]: e.target.value,
}));
}}
className="text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
Enter JSON filters (optional). Leave empty to
receive all events.
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2 flex-shrink-0">
<Button onClick={handleCancel} variant="outline">
Cancel
</Button>
<Button onClick={handleSave}>
Select {tempSelection.length} Topic
{tempSelection.length !== 1 ? "s" : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,346 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
createWebhookConfig,
type Topic,
updateWebhookConfig,
type WebhookConfig,
} from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Switch } from "@/components/ui/switch";
import { TopicSelectorModal } from "./topic-selector-modal";
const formSchema = z.object({
description: z.string().min(1, "Description is required"),
destinationUrl: z
.string()
.min(1, "Destination URL is required")
.url("Must be a valid URL")
.refine((url) => url.startsWith("https://"), {
message: "URL must start with https://",
}),
isPaused: z.boolean().default(false),
topics: z
.array(
z.object({ id: z.string(), filters: z.union([z.object({}), z.null()]) }),
)
.min(1, "At least one topic is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface WebhookConfigModalProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig?: WebhookConfig; // Only required for edit mode
}
export function WebhookConfigModal(props: WebhookConfigModalProps) {
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false);
const queryClient = useQueryClient();
const isEditMode = props.mode === "edit";
const webhookConfig = props.webhookConfig;
const form = useForm<FormValues>({
defaultValues: {
description: isEditMode ? webhookConfig?.description || "" : "",
destinationUrl: isEditMode ? webhookConfig?.destinationUrl || "" : "",
isPaused: isEditMode ? !!webhookConfig?.pausedAt : false,
topics:
isEditMode && webhookConfig?.topics
? webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
}))
: [],
},
resolver: zodResolver(formSchema),
});
const mutation = useMutation({
mutationFn: async (values: FormValues) => {
// Topics already have filters applied from the selector modal
const processedTopics = values.topics;
if (isEditMode && webhookConfig) {
const result = await updateWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookConfig.id,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
} else {
const result = await createWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
}
},
onError: (error) => {
toast.error(`Failed to ${isEditMode ? "update" : "create"} webhook`, {
description: error.message,
});
},
onSuccess: () => {
toast.success(
`Webhook ${isEditMode ? "updated" : "created"} successfully`,
);
props.onOpenChange(false);
if (!isEditMode) {
form.reset();
}
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
function onSubmit(values: FormValues) {
mutation.mutate(values);
}
function handleOpenChange(open: boolean) {
if (!open && !mutation.isPending) {
if (isEditMode && webhookConfig) {
// Reset form to original values when closing edit modal
form.reset({
description: webhookConfig.description || "",
destinationUrl: webhookConfig.destinationUrl || "",
isPaused: !!webhookConfig.pausedAt,
topics: webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
})),
});
} else {
// Reset to empty values for create modal
form.reset();
}
}
props.onOpenChange(open);
}
return (
<Dialog onOpenChange={handleOpenChange} open={props.open}>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="font-semibold text-2xl tracking-tight">
{isEditMode ? "Edit" : "Create"} Webhook Configuration
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={
isEditMode
? "Enter webhook description..."
: "Enter a description"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Destination URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhook"
type="url"
{...field}
/>
</FormControl>
<FormDescription>
{isEditMode
? "The URL where webhook events will be sent"
: "Enter your webhook URL. Only https:// is supported."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topics"
render={({ field }) => (
<FormItem>
<FormLabel>Topics</FormLabel>
<FormControl>
<Button
className="w-full justify-start text-left font-normal"
onClick={() => setIsTopicSelectorOpen(true)}
type="button"
variant="outline"
>
{field.value && field.value.length > 0
? `${field.value.length} topic${field.value.length !== 1 ? "s" : ""} selected`
: "Select topics to subscribe to"}
</Button>
</FormControl>
<FormDescription>
{isEditMode
? "Select the events you want to receive notifications for"
: "Select the events to trigger calls to your webhook."}
</FormDescription>
{field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{field.value.map((topic) => {
return (
<div
className="flex items-center gap-1 rounded-full bg-accent px-2 py-1 text-xs"
key={topic.id}
>
{topic.id}
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange(
field.value?.filter(
(t) => t.id !== topic.id,
),
);
}}
type="button"
>
×
</button>
</div>
);
})}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPaused"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
{isEditMode ? "Paused" : "Start Paused"}
</FormLabel>
<FormDescription className="text-xs">
{isEditMode
? "Pause webhook notifications"
: "Do not send events yet. You can unpause at any time."}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="mt-4 gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={mutation.isPending}
onClick={() => handleOpenChange(false)}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending && <Spinner className="size-4" />}
{isEditMode ? "Update" : "Create"} Webhook
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<TopicSelectorModal
onOpenChange={setIsTopicSelectorOpen}
onSelectionChange={(topics) => {
form.setValue("topics", topics);
}}
open={isTopicSelectorOpen}
selectedTopics={form.watch("topics")}
topics={props.topics}
/>
</Dialog>
);
}
@@ -0,0 +1,428 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArrowUpDownIcon,
CalendarIcon,
CheckIcon,
EditIcon,
GlobeIcon,
LetterTextIcon,
MoreHorizontalIcon,
PauseIcon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs";
import { CreateWebhookConfigModal } from "./create-webhook-config-modal";
import { DeleteWebhookModal } from "./delete-webhook-modal";
import { EditWebhookConfigModal } from "./edit-webhook-config-modal";
import { WebhookMetrics } from "./webhook-metrics";
type SortById = "description" | "createdAt" | "destinationUrl" | "pausedAt";
export function WebhookConfigsTable(props: {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}) {
const { webhookConfigs } = props;
const [sortBy, setSortBy] = useState<SortById>("createdAt");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(
null,
);
const [deletingWebhook, setDeletingWebhook] = useState<WebhookConfig | null>(
null,
);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async (webhookId: string) => {
const result = await deleteWebhookConfig({
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookId,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
},
onError: (error) => {
toast.error("Failed to delete webhook", {
description: error.message,
});
},
onSuccess: () => {
toast.success("Webhook deleted successfully");
setDeletingWebhook(null);
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
const sortedConfigs = useMemo(() => {
let _configsToShow = webhookConfigs;
if (sortBy === "description") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.description || "").localeCompare(b.description || ""),
);
} else if (sortBy === "createdAt") {
_configsToShow = _configsToShow.sort(
(a, b) =>
new Date(b.createdAt || 0).getTime() -
new Date(a.createdAt || 0).getTime(),
);
} else if (sortBy === "destinationUrl") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.destinationUrl || "").localeCompare(b.destinationUrl || ""),
);
} else if (sortBy === "pausedAt") {
_configsToShow = _configsToShow.sort((a, b) =>
a.pausedAt === b.pausedAt ? 0 : a.pausedAt ? 1 : -1,
);
}
return _configsToShow;
}, [sortBy, webhookConfigs]);
const pageSize = 10;
const [page, setPage] = useState(1);
const paginatedConfigs = sortedConfigs.slice(
(page - 1) * pageSize,
page * pageSize,
);
const showPagination = sortedConfigs.length > pageSize;
const totalPages = Math.ceil(sortedConfigs.length / pageSize);
const hasActiveFilters = sortBy !== "createdAt";
return (
<div>
<div className="relative flex flex-col gap-5 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
Configuration
</h2>
<p className="text-muted-foreground text-sm">
Manage your webhook endpoints.
</p>
</div>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SortDropdown
hasActiveFilters={hasActiveFilters}
onSortChange={(v) => {
setSortBy(v);
setPage(1);
}}
sortBy={sortBy}
/>
<Button
className="gap-1.5 rounded-full"
onClick={() => setIsCreateModalOpen(true)}
>
<PlusIcon className="size-4" />
Create Webhook
</Button>
</div>
</div>
{/* Webhook Configs Table */}
{paginatedConfigs.length === 0 ? (
<div className="flex min-h-[300px] grow items-center justify-center border rounded-lg border-dashed bg-card">
<div className="flex flex-col items-center">
<p className="text-center font-semibold text-lg">
No webhooks created yet.
</p>
</div>
</div>
) : (
<div className="flex grow flex-col">
<TableContainer className={cn(showPagination && "rounded-b-none")}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[150px]">Description</TableHead>
<TableHead className="w-[150px]">Destination URL</TableHead>
<TableHead>Topics</TableHead>
<TableHead>Activity (24h)</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedConfigs.map((config) => (
<TableRow
className="hover:bg-accent/50"
key={config.id}
linkBox
>
<TableCell>
<span className="font-medium text-sm">
{config.description || "No description"}
</span>
</TableCell>
<TableCell className="w-[200px]">
{config.destinationUrl ? (
<CopyTextButton
className="max-w-[200px] text-sm text-muted-foreground font-mono justify-start p-0 h-auto"
textToCopy={config.destinationUrl}
textToShow={
config.destinationUrl.length > 30
? `${config.destinationUrl.substring(0, 30)}...`
: config.destinationUrl
}
variant="ghost"
copyIconPosition="right"
tooltip={config.destinationUrl}
/>
) : (
<span className="text-sm text-muted-foreground">
No URL
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(config.topics || []).slice(0, 3).map((topic) => {
// Check if this topic has filters (for webhook configs from new API)
const hasFilters =
topic.filters &&
Object.keys(topic.filters).length > 0;
return (
<span
className={cn(
"rounded-full px-2 py-1 text-xs",
hasFilters
? "bg-blue-100 text-blue-800"
: "bg-accent",
)}
key={topic.id}
>
{topic.id}
{hasFilters && " *"}
</span>
);
})}
{(config.topics || []).length > 3 && (
<span className="rounded-full bg-accent px-2 py-1 text-xs">
+{(config.topics || []).length - 3}
</span>
)}
</div>
</TableCell>
<TableCell>
<WebhookMetrics
isPaused={!!config.pausedAt}
metrics={props.metricsMap.get(config.id) || null}
/>
</TableCell>
<TableCell className="text-muted-foreground">
{config.createdAt
? format(new Date(config.createdAt), "MMM d, yyyy")
: "Unknown"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-8 w-8 p-0" variant="ghost">
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
setEditingWebhook(config);
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => {
setDeletingWebhook(config);
}}
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{showPagination && (
<div className="rounded-b-lg border border-t-0 bg-card p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
)}
<CreateWebhookConfigModal
onOpenChange={setIsCreateModalOpen}
open={isCreateModalOpen}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
{editingWebhook && (
<EditWebhookConfigModal
onOpenChange={(open) => {
if (!open) setEditingWebhook(null);
}}
open={!!editingWebhook}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={editingWebhook}
/>
)}
<DeleteWebhookModal
isPending={deleteMutation.isPending}
metrics={
deletingWebhook
? props.metricsMap.get(deletingWebhook.id) || null
: null
}
onConfirm={() => {
if (deletingWebhook) {
deleteMutation.mutate(deletingWebhook.id);
}
}}
onOpenChange={(open) => {
if (!open) setDeletingWebhook(null);
}}
open={!!deletingWebhook}
webhookConfig={deletingWebhook}
/>
</div>
);
}
const sortByIcon: Record<SortById, React.FC<{ className?: string }>> = {
createdAt: CalendarIcon,
description: LetterTextIcon,
destinationUrl: GlobeIcon,
pausedAt: PauseIcon,
};
function SortDropdown(props: {
sortBy: SortById;
onSortChange: (value: SortById) => void;
hasActiveFilters: boolean;
}) {
const values: SortById[] = [
"description",
"createdAt",
"destinationUrl",
"pausedAt",
];
const valueToLabel: Record<SortById, string> = {
createdAt: "Creation Date",
description: "Description",
destinationUrl: "Destination URL",
pausedAt: "Pause Status",
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="gap-1.5 rounded-full bg-card" variant="outline">
<ArrowUpDownIcon className="size-4 text-muted-foreground" />
<span>Sort by</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-60 rounded-xl p-1.5 shadow-lg"
sideOffset={10}
>
<DropdownMenuRadioGroup
className="flex flex-col gap-1"
onValueChange={(v) => props.onSortChange(v as SortById)}
value={props.sortBy}
>
{values.map((value) => {
const Icon = sortByIcon[value];
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-lg py-2",
props.sortBy === value && "bg-accent",
)}
key={value}
onClick={() => props.onSortChange(value)}
>
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
{valueToLabel[value]}
</div>
{props.sortBy === value ? (
<CheckIcon className="size-4 text-foreground" />
) : (
<div className="size-4" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,38 @@
"use client";
import type { WebhookSummaryStats } from "@/types/analytics";
interface WebhookMetricsProps {
metrics: WebhookSummaryStats | null;
isPaused: boolean;
}
export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) {
if (isPaused) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
Paused
</span>
);
}
if (!metrics) {
return (
<div className="text-sm text-muted-foreground">No metrics available</div>
);
}
const totalRequests = metrics.totalRequests ?? 0;
const errorRequests = metrics.errorRequests ?? 0;
const errorRate =
totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0;
return (
<div className="text-sm">
<div className="font-medium">{totalRequests} requests</div>
<div className="text-xs text-muted-foreground">
{errorRate.toFixed(1)}% errors
</div>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
export default async function ContractsPage({
params,
}: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [authToken, resolvedParams] = await Promise.all([
getAuthToken(),
params,
]);
const project = await getProject(
resolvedParams.team_slug,
resolvedParams.project_slug,
);
if (!project || !authToken) {
notFound();
}
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
);
}
@@ -1,3 +1,5 @@
import { getValidAccount } from "@app/account/settings/getAccount";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { TabPathLinks } from "@/components/ui/tabs";
export default async function WebhooksLayout(props: {
@@ -7,6 +9,13 @@ export default async function WebhooksLayout(props: {
project_slug: string;
}>;
}) {
const account = await getValidAccount();
const isFeatureEnabled = await isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
});
const params = await props.params;
return (
<div className="flex grow flex-col">
@@ -21,20 +30,46 @@ export default async function WebhooksLayout(props: {
</div>
</div>
<TabPathLinks
links={[
{
exactMatch: true,
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
{
name: "Universal Bridge",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
{isFeatureEnabled ? (
<TabPathLinks
links={[
{
exactMatch: true,
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
{
exactMatch: true,
name: "Analytics",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/analytics`,
},
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
) : (
<TabPathLinks
links={[
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
scrollableClassName="container max-w-7xl"
/>
)}
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col">
{props.children}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -1,45 +1,97 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { getWebhookSummary } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "./contract-webhooks/contract-webhooks-page";
import { getAvailableTopics, getWebhookConfigs } from "@/api/webhook-configs";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { WebhooksOverview } from "./components/overview";
export default async function WebhooksPage({
params,
}: {
export default async function WebhooksPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [authToken, resolvedParams] = await Promise.all([
const [authToken, params, account] = await Promise.all([
getAuthToken(),
params,
props.params,
getValidAccount(),
]);
const project = await getProject(
resolvedParams.team_slug,
resolvedParams.project_slug,
);
if (!account || !authToken) {
notFound();
}
const [isFeatureEnabled, project] = await Promise.all([
isFeatureFlagEnabled({
flagKey: "centralized-webhooks",
accountId: account.id,
email: account.email,
}),
getProject(params.team_slug, params.project_slug),
]);
if (!project || !authToken) {
notFound();
}
if (!isFeatureEnabled) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
// Fetch webhook configs and topics in parallel
const [webhookConfigsResult, topicsResult] = await Promise.all([
getWebhookConfigs({
projectIdOrSlug: params.project_slug,
teamIdOrSlug: params.team_slug,
}),
getAvailableTopics(),
]);
if (
webhookConfigsResult.status === "error" ||
topicsResult.status === "error"
) {
notFound();
}
const webhookConfigs = webhookConfigsResult.data || [];
const topics = topicsResult.data || [];
// Fetch metrics for all webhooks in parallel
const webhookMetrics = await Promise.all(
webhookConfigs.map(async (config) => {
const metricsResult = await getWebhookSummary({
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
period: "day",
projectId: project.id,
teamId: project.teamId, // 24 hours ago
to: new Date(),
webhookId: config.id,
});
return {
metrics:
"error" in metricsResult ? null : (metricsResult.data[0] ?? null),
webhookId: config.id,
};
}),
);
// Create a map for easy lookup
const metricsMap = new Map(
webhookMetrics.map((item) => [item.webhookId, item.metrics]),
);
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
<WebhooksOverview
metricsMap={metricsMap}
projectId={project.id}
projectSlug={params.project_slug}
teamId={project.teamId}
teamSlug={params.team_slug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,7 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
export default function Loading() {
return <GenericLoadingPage className="border-none" />;
}
@@ -19,7 +19,7 @@ export default async function Page(props: {
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Universal Bridge Webhooks
Payments Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified for Bridge, Swap and Onramp events.{" "}
@@ -73,7 +73,7 @@ function Example() {
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
@@ -2,6 +2,34 @@ import type { Chain } from "thirdweb/chains";
import type { ThemeOverrides } from "thirdweb/react";
import type { Address } from "thirdweb/utils";
const CURRENCIES = [
"USD",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
] as const;
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
export type BridgeComponentsPlaygroundOptions = {
theme: {
type: "dark" | "light";
@@ -25,5 +53,9 @@ export type BridgeComponentsPlaygroundOptions = {
transactionData?: string; // Simplified for demo; could be more complex in real implementation
paymentMethods: ("crypto" | "card")[];
currency?: SupportedFiatCurrency;
showThirdwebBranding: boolean;
};
};
@@ -17,6 +17,13 @@ import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";
@@ -134,6 +141,51 @@ export function LeftSection(props: {
/>
</section>
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<Select
value={payOptions.currency || "USD"}
onValueChange={(value) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
currency: value,
},
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
</section>
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
{(!payOptions.widget ||
payOptions.widget === "buy" ||
@@ -464,6 +516,23 @@ export function LeftSection(props: {
}}
theme={options.theme}
/>
<div className="my-4 flex items-center gap-2">
<Checkbox
checked={payOptions.showThirdwebBranding}
id={"branding"}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
showThirdwebBranding: checked === true,
},
}));
}}
/>
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
@@ -64,6 +64,8 @@ export function RightSection(props: {
theme={themeObj}
title={props.options.payOptions.title}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -87,6 +89,8 @@ export function RightSection(props: {
seller={props.options.payOptions.sellerAddress}
theme={themeObj}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -106,6 +110,8 @@ export function RightSection(props: {
to: account?.address || "",
tokenId: 2n,
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -18,6 +18,8 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
title: "",
transactionData: "",
widget: "buy",
currency: "USD",
showThirdwebBranding: true,
},
theme: {
darkColorOverrides: {},
-8
View File
@@ -51,10 +51,6 @@ const links = [
href: "/insight",
name: "Insight",
},
{
href: "/nebula",
name: "Nebula",
},
{
href: "/vault",
name: "Vault",
@@ -127,10 +123,6 @@ export const connectLinks: Array<{
] as const;
const apisLinks = [
{
href: "/nebula/api-reference",
name: "Nebula",
},
{
href: "https://insight.thirdweb.com/reference",
name: "Insight",
+1 -14
View File
@@ -14,7 +14,6 @@ import { BridgeIcon } from "../icons/products/BridgeIcon";
import { ConnectIcon } from "../icons/products/ConnectIcon";
import { EngineIcon } from "../icons/products/EngineIcon";
import { InsightIcon } from "../icons/products/InsightIcon";
import { NebulaIcon } from "../icons/products/NebulaIcon";
import { PlaygroundIcon } from "../icons/products/PlaygroundIcon";
import { cn } from "../lib/utils";
import DocsHeroDark from "./_images/docs-hero-dark.png";
@@ -148,12 +147,6 @@ function ReferenceSection() {
iconClassName="text-muted-foreground"
title="Bundler"
/>
<SDKCard
href="/nebula/api-reference"
icon={NebulaIcon} // TODO: actual openAPI docs
iconClassName="text-muted-foreground"
title="Nebula"
/>
</Grid>
</section>
);
@@ -194,16 +187,10 @@ function LearningResourcesSection() {
icon={InsightIcon}
title="Insight"
/>
<ArticleCardIndex
description="API interface for LLMs"
href="/nebula"
icon={NebulaIcon}
title="Nebula"
/>
<ArticleCardIndex
description="Non-custodial key management service"
href="/vault"
icon={NebulaIcon}
icon={ConnectIcon}
title="Vault"
/>
</Grid>
+28
View File
@@ -1,5 +1,33 @@
# @thirdweb-dev/nebula
## 0.2.15
### Patch Changes
- Updated dependencies [[`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb), [`d62e997`](https://github.com/thirdweb-dev/js/commit/d62e9971ee7707211b513509de80a5b0abdaa4d0)]:
- thirdweb@5.105.15
## 0.2.14
### Patch Changes
- Updated dependencies [[`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73)]:
- thirdweb@5.105.14
## 0.2.13
### Patch Changes
- Updated dependencies [[`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934), [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f)]:
- thirdweb@5.105.13
## 0.2.12
### Patch Changes
- Updated dependencies [[`bfd728a`](https://github.com/thirdweb-dev/js/commit/bfd728a9d1aab7d0044791b218abf9ac9dbc9bf6)]:
- thirdweb@5.105.12
## 0.2.11
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.11"
"version": "0.2.15"
}
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/service-utils
## 0.10.3
### Patch Changes
- [#7570](https://github.com/thirdweb-dev/js/pull/7570) [`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add encryption utilities
## 0.10.2
### Patch Changes
+1 -1
View File
@@ -69,5 +69,5 @@
}
},
"typings": "dist/types/index.d.ts",
"version": "0.10.2"
"version": "0.10.3"
}
+2
View File
@@ -226,6 +226,8 @@ export type ProjectService =
maskedAdminKey?: string | null;
managementAccessToken?: string | null;
rotationCode?: string | null;
encryptedAdminKey?: string | null;
encryptedWalletAccessToken?: string | null;
}
| ProjectBundlerService
| ProjectEmbeddedWalletsService;
@@ -0,0 +1,96 @@
export async function encrypt(secret: string, password: string) {
// Generate random salt and IV
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
// Encrypt the secret
const encodedSecret = new TextEncoder().encode(secret);
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encodedSecret,
);
// Combine salt, IV, and encrypted data
const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
result.set(salt, 0);
result.set(iv, salt.length);
result.set(new Uint8Array(encrypted), salt.length + iv.length);
// Return as base64 string
return btoa(String.fromCharCode(...result));
}
export async function decrypt(encryptedData: string, password: string) {
// Decode base64 string
const data = new Uint8Array(
atob(encryptedData)
.split("")
.map((char) => char.charCodeAt(0)),
);
// Extract salt, IV, and encrypted data
const salt = data.slice(0, 16);
const iv = data.slice(16, 28);
const encrypted = data.slice(28);
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
// Decrypt the data
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encrypted,
);
// Return as string
return new TextDecoder().decode(decrypted);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export type {
} from "./core/api.js";
export { fetchTeamAndProject } from "./core/api.js";
export { authorizeBundleId, authorizeDomain } from "./core/authorize/client.js";
export { decrypt, encrypt } from "./core/encryption.js";
export { rateLimit } from "./core/rateLimit/index.js";
export { rateLimitSlidingWindow } from "./core/rateLimit/strategies/sliding-window.js";
export * from "./core/services.js";
+28
View File
@@ -1,5 +1,33 @@
# thirdweb
## 5.105.15
### Patch Changes
- [#7570](https://github.com/thirdweb-dev/js/pull/7570) [`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Make vault access token optional
- [#7586](https://github.com/thirdweb-dev/js/pull/7586) [`d62e997`](https://github.com/thirdweb-dev/js/commit/d62e9971ee7707211b513509de80a5b0abdaa4d0) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix inapp wallets showing up in all wallets list
## 5.105.14
### Patch Changes
- [#7581](https://github.com/thirdweb-dev/js/pull/7581) [`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73) Thanks [@d4mr](https://github.com/d4mr)! - fix engine server wallet usage with session keys
## 5.105.13
### Patch Changes
- [#7578](https://github.com/thirdweb-dev/js/pull/7578) [`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Handle different fiat currencies in payment widgets
- [#7580](https://github.com/thirdweb-dev/js/pull/7580) [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update wallet definitions
## 5.105.12
### Patch Changes
- [#7575](https://github.com/thirdweb-dev/js/pull/7575) [`bfd728a`](https://github.com/thirdweb-dev/js/commit/bfd728a9d1aab7d0044791b218abf9ac9dbc9bf6) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Adds the ability to hide thirdweb branding in the payment widgets with showThirdwebBranding
## 5.105.11
### Patch Changes
+1 -1
View File
@@ -404,5 +404,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.11"
"version": "5.105.15"
}
+1 -1
View File
@@ -7,5 +7,5 @@ export type Token = {
symbol: string;
name: string;
iconUri?: string;
priceUsd: number;
prices: Record<string, number>;
};
@@ -1,4 +1,5 @@
import { verifyTypedData } from "src/auth/verify-typed-data.js";
import { toWei } from "src/utils/units.js";
import { beforeAll, describe, expect, it } from "vitest";
import { TEST_CLIENT } from "../../test/src/test-clients.js";
import { TEST_ACCOUNT_B } from "../../test/src/test-wallets.js";
@@ -8,6 +9,9 @@ import { baseSepolia } from "../chains/chain-definitions/base-sepolia.js";
import { sepolia } from "../chains/chain-definitions/sepolia.js";
import { getContract } from "../contract/contract.js";
import { setContractURI } from "../extensions/common/__generated__/IContractMetadata/write/setContractURI.js";
import { claimTo as claimToERC20 } from "../extensions/erc20/drops/write/claimTo.js";
import { getBalance } from "../extensions/erc20/read/getBalance.js";
import { transfer } from "../extensions/erc20/write/transfer.js";
import { setApprovalForAll } from "../extensions/erc1155/__generated__/IERC1155/write/setApprovalForAll.js";
import { claimTo } from "../extensions/erc1155/drops/write/claimTo.js";
import { getAllActiveSigners } from "../extensions/erc4337/__generated__/IAccountPermissions/read/getAllActiveSigners.js";
@@ -217,7 +221,7 @@ describe.runIf(
).rejects.toThrow();
});
it("should send a session key tx", async () => {
it("should send a basic session key tx", async () => {
const sessionKeyAccountAddress = process.env
.ENGINE_CLOUD_WALLET_ADDRESS_EOA as string;
const personalAccount = await generateAccount({
@@ -272,5 +276,117 @@ describe.runIf(
});
expect(tx).toBeDefined();
});
it("should send a session key tx with ERC20 claiming and transfer", async () => {
// The EOA is the session key signer, ie, it has session key permissions on the generated smart account
const sessionKeyAccountAddress = process.env
.ENGINE_CLOUD_WALLET_ADDRESS_EOA as string;
const personalAccount = await generateAccount({
client: TEST_CLIENT,
});
const smart = smartWallet({
chain: arbitrumSepolia,
sessionKey: {
address: sessionKeyAccountAddress,
permissions: {
approvedTargets: "*",
},
},
sponsorGas: true,
});
const smartAccount = await smart.connect({
client: TEST_CLIENT,
personalAccount,
});
expect(smartAccount.address).toBeDefined();
const signers = await getAllActiveSigners({
contract: getContract({
address: smartAccount.address,
chain: arbitrumSepolia,
client: TEST_CLIENT,
}),
});
expect(signers.map((s) => s.signer)).toContain(sessionKeyAccountAddress);
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: arbitrumSepolia,
client: TEST_CLIENT,
executionOptions: {
entrypointVersion: "0.6",
signerAddress: sessionKeyAccountAddress,
smartAccountAddress: smartAccount.address,
type: "ERC4337",
},
vaultAccessToken: process.env.VAULT_TOKEN as string,
});
// Get the ERC20 contract
const erc20Contract = getContract({
// this ERC20 on arbitrumSepolia has infinite free public claim phase
address: "0xd4d3D9261e2da56c4cC618a06dD5BDcB1A7a21d7",
chain: arbitrumSepolia,
client: TEST_CLIENT,
});
// Check initial signer balance
const initialSignerBalance = await getBalance({
address: sessionKeyAccountAddress,
contract: erc20Contract,
});
// Claim 10 tokens to the smart account
const claimTx = claimToERC20({
contract: erc20Contract,
to: smartAccount.address,
quantity: "10",
});
const claimResult = await sendTransaction({
account: serverWallet,
transaction: claimTx,
});
expect(claimResult).toBeDefined();
// Check balance after claim
const balanceAfterClaim = await getBalance({
address: smartAccount.address,
contract: erc20Contract,
});
// Verify the smart account now has 10 tokens (since it started with 0)
expect(balanceAfterClaim.value).toBe(toWei("10"));
// Transfer tokens from smart account to signer
const transferTx = transfer({
contract: erc20Contract,
to: sessionKeyAccountAddress,
amount: "10",
});
const transferResult = await sendTransaction({
account: serverWallet,
transaction: transferTx,
});
expect(transferResult).toBeDefined();
// Check final balances
const finalSmartAccountBalance = await getBalance({
address: smartAccount.address,
contract: erc20Contract,
});
const finalSignerBalance = await getBalance({
address: sessionKeyAccountAddress,
contract: erc20Contract,
});
// Verify the transfer worked correctly
// Smart account should be back to 0 balance
expect(finalSmartAccountBalance.value).toBe(0n);
// Signer should have gained 10 tokens
expect(
BigInt(finalSignerBalance.value) - BigInt(initialSignerBalance.value),
).toBe(toWei("10"));
});
},
);
+19 -6
View File
@@ -35,9 +35,10 @@ export type ServerWalletOptions = {
*/
client: ThirdwebClient;
/**
* The vault access token to use your server wallet.
* Optional vault access token to use your server wallet.
* If not provided, the server wallet will use the project secret key to authenticate.
*/
vaultAccessToken: string;
vaultAccessToken?: string;
/**
* The server wallet address to use for sending transactions inside engine.
*/
@@ -152,9 +153,11 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet {
const { client, vaultAccessToken, address, chain, executionOptions } =
options;
const headers: HeadersInit = {
"x-vault-access-token": vaultAccessToken,
};
const headers: HeadersInit = vaultAccessToken
? {
"x-vault-access-token": vaultAccessToken,
}
: {};
const getExecutionOptionsWithChainId = (
chainId: number,
@@ -266,8 +269,18 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet {
return data.transactions.map((t) => t.id);
};
const getAddress = () => {
if (
executionOptions?.type === "ERC4337" &&
executionOptions.smartAccountAddress
) {
return executionOptions.smartAccountAddress;
}
return address;
};
return {
address,
address: getAddress(),
enqueueBatchTransaction: async (args: {
transactions: PreparedTransaction[];
}) => {
@@ -300,14 +300,14 @@ export async function getBuyWithCryptoQuote(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
amountWei: quote.originAmount.toString(),
token: {
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -323,7 +323,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -337,7 +337,7 @@ export async function getBuyWithCryptoQuote(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
gasCostUSDCents: 0,
slippageBPS: 0,
@@ -348,7 +348,7 @@ export async function getBuyWithCryptoQuote(
firstStep.destinationToken.decimals,
),
) *
firstStep.destinationToken.priceUsd *
(firstStep.destinationToken.prices["USD"] || 0) *
100,
toAmountUSDCents:
Number(
@@ -357,7 +357,7 @@ export async function getBuyWithCryptoQuote(
firstStep.destinationToken.decimals,
),
) *
firstStep.destinationToken.priceUsd *
(firstStep.destinationToken.prices["USD"] || 0) *
100,
},
fromAddress: quote.intent.sender,
@@ -372,7 +372,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -395,7 +395,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.destinationToken.chainId,
decimals: firstStep.destinationToken.decimals,
name: firstStep.destinationToken.name,
priceUSDCents: firstStep.destinationToken.priceUsd * 100,
priceUSDCents: (firstStep.destinationToken.prices["USD"] || 0) * 100,
symbol: firstStep.destinationToken.symbol,
tokenAddress: firstStep.destinationToken.address,
},
@@ -198,14 +198,14 @@ export async function getBuyWithCryptoTransfer(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
amountWei: quote.originAmount.toString(),
token: {
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -226,7 +226,7 @@ export async function getBuyWithCryptoTransfer(
firstStep.originToken.decimals,
),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100
: 0,
amountWei:
@@ -237,7 +237,7 @@ export async function getBuyWithCryptoTransfer(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -367,12 +367,12 @@ export async function getBuyWithFiatQuote(
decimals: number;
symbol: string;
name: string;
priceUsd: number;
prices: Record<string, number>;
}): PayTokenInfo => ({
chainId: token.chainId,
decimals: token.decimals,
name: token.name,
priceUSDCents: Math.round(token.priceUsd * 100),
priceUSDCents: Math.round((token.prices["USD"] || 0) * 100),
symbol: token.symbol,
tokenAddress: token.address,
});
@@ -408,7 +408,7 @@ export async function getBuyWithFiatQuote(
const onRampTokenObject = {
amount: onRampTokenAmount,
amountUSDCents: Math.round(
Number(onRampTokenAmount) * onRampTokenRaw.priceUsd * 100,
Number(onRampTokenAmount) * (onRampTokenRaw.prices["USD"] || 0) * 100,
),
amountWei: onRampTokenAmountWei.toString(),
token: tokenToPayTokenInfo(onRampTokenRaw),
@@ -434,7 +434,7 @@ export async function getBuyWithFiatQuote(
routingTokenObject = {
amount: routingAmount,
amountUSDCents: Math.round(
Number(routingAmount) * routingTokenRaw.priceUsd * 100,
Number(routingAmount) * (routingTokenRaw.prices["USD"] || 0) * 100,
),
amountWei: routingAmountWei.toString(),
token: tokenToPayTokenInfo(routingTokenRaw),
@@ -26,7 +26,6 @@ export type ConvertCryptoToFiatParams = {
chain: Chain;
/**
* The fiat symbol. e.g "USD"
* Only USD is supported at the moment.
*/
to: SupportedFiatCurrency;
};
@@ -56,7 +55,7 @@ export type ConvertCryptoToFiatParams = {
export async function convertCryptoToFiat(
options: ConvertCryptoToFiatParams,
): Promise<{ result: number }> {
const { client, fromTokenAddress, chain, fromAmount } = options;
const { client, fromTokenAddress, chain, fromAmount, to } = options;
if (Number(fromAmount) === 0) {
return { result: 0 };
}
@@ -74,10 +73,11 @@ export async function convertCryptoToFiat(
);
}
const token = await getToken(client, fromTokenAddress, chain.id);
if (token.priceUsd === 0) {
const price = token?.prices[to] || 0;
if (!token || price === 0) {
throw new Error(
`Error: Failed to fetch price for token ${fromTokenAddress} on chainId: ${chain.id}`,
);
}
return { result: token.priceUsd * fromAmount };
return { result: price * fromAmount };
}
@@ -13,7 +13,6 @@ export type ConvertFiatToCryptoParams = {
client: ThirdwebClient;
/**
* The fiat symbol. e.g: "USD"
* Currently only USD is supported.
*/
from: SupportedFiatCurrency;
/**
@@ -57,7 +56,7 @@ export type ConvertFiatToCryptoParams = {
export async function convertFiatToCrypto(
options: ConvertFiatToCryptoParams,
): Promise<{ result: number }> {
const { client, to, chain, fromAmount } = options;
const { client, to, chain, fromAmount, from } = options;
if (Number(fromAmount) === 0) {
return { result: 0 };
}
@@ -73,10 +72,11 @@ export async function convertFiatToCrypto(
throw new Error("Invalid `to`. Expected a valid EVM contract address");
}
const token = await getToken(client, to, chain.id);
if (!token || token.priceUsd === 0) {
const price = token?.prices[from] || 0;
if (!token || price === 0) {
throw new Error(
`Error: Failed to fetch price for token ${to} on chainId: ${chain.id}`,
);
}
return { result: fromAmount / token.priceUsd };
return { result: fromAmount / price };
}
+21 -7
View File
@@ -1,16 +1,30 @@
const SUPPORTED_FIAT_CURRENCIES = [
const CURRENCIES = [
"USD",
"CAD",
"GBP",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
] as const;
/**
* @internal
*/
export type SupportedFiatCurrency = (typeof SUPPORTED_FIAT_CURRENCIES)[number];
export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) {
switch (showBalanceInFiat) {
@@ -106,7 +106,9 @@ export function usePaymentMethods(options: {
decimals: b.decimals,
iconUri: "",
name: b.name,
priceUsd: 0,
prices: {
USD: 0,
},
symbol: b.symbol,
} as Token,
}));
@@ -183,10 +185,10 @@ export function usePaymentMethods(options: {
validOwnedTokens.sort((a, b) => {
const aDollarBalance =
Number.parseFloat(toTokens(a.balance, a.originToken.decimals)) *
a.originToken.priceUsd;
(a.originToken.prices["USD"] || 0);
const bDollarBalance =
Number.parseFloat(toTokens(b.balance, b.originToken.decimals)) *
b.originToken.priceUsd;
(b.originToken.prices["USD"] || 0);
return bDollarBalance - aDollarBalance;
});

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