Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e0ace736a8 Version Packages (#8281)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-21 18:31:35 -07:00
Jonas DanielsandGitHub b30369f3cc Change getContractMetadata() return type from any to unknown (#8289) 2025-10-21 17:43:43 -07:00
FirekeeperandGitHub f2121e9519 [Dashboard] Add userId support across wallet APIs and UI (#8290) 2025-10-22 06:35:10 +07:00
Joaquim VergesandGitHub 29d05a50bf [Dashboard] Add nexus to framer-rewrites.js (#8287) 2025-10-20 16:19:28 -07:00
Jonas DanielsandGitHub f00f559a12 Add force-dynamic to Stripe-related pages (#8288) 2025-10-20 16:19:16 -07:00
MananTank ceba6835dd SDK: Fix process not defined error in Vite (#8280)
<!--

## 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 addresses a `process not defined` error that occurs when using the `thirdweb/contract` import in Vite by ensuring that the `process` variable is only accessed when it is defined.

### Detailed summary

- Updated `PUBLISHED_PRIVATE_KEY` export in `packages/thirdweb/src/utils/any-evm/zksync/constants.ts` to check if `process` is defined before accessing `process.env.ZKSYNC_PUBLISHED_PRIVATE_KEY`.

> ✨ 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

- **Chores**
  - Improved robustness of environment configuration handling to avoid runtime issues in non-Node environments.

- **Bug Fixes**
  - Fixed a "process not defined" error that could occur when importing the library in Vite, included in a patch release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-20 13:55:09 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
b33c1a30ac Version Packages (#8272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-19 18:33:02 -07:00
Joaquim VergesandGitHub 72fbb1550f Resolve TokenIcon automatically when available (#8279) 2025-10-19 18:27:35 -07:00
MananTank 1e7df5ad3d [MNY-252] Dashboard: Update WalletAddress component UI (#8276)
<!--

## 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 `WalletAddress` component and its UI by improving the handling of social profiles, adding new story variants, and refining the UI elements for better user experience.

### Detailed summary
- Changed `retry` in `useSocialProfiles` from `3` to `false`.
- Added new Storybook stories for `WalletAddress` and `WalletAddressUI`.
- Introduced a `Skeleton` component for loading states.
- Refactored `WalletAddress` to use `useSocialProfiles` directly.
- Updated UI structure and styling for displaying social profiles.
- Improved the handling of address display and copy functionality.

> ✨ 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 Storybook examples showcasing Wallet Address states (loaded, loading, empty, invalid, zero address, ENS)

* **Refactor**
  * Split wallet address UI for clearer rendering and moved profile-driven rendering to a dedicated UI component
  * Updated copy button styling to use a unified copy control
  * Improved social profiles panel layout and presentation

* **Bug Fixes / Behavior**
  * Adjusted profile loading behavior to avoid automatic retry on failure, surfacing empty/error states sooner
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-19 10:54:54 +00:00
MananTank ca16ec783b [MNY-275] Surface the error from bridge API in SwapWidget UI (#8277)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling in the `swap-ui.tsx` component by providing more detailed error messages when fetching data fails.

### Detailed summary
- Added `multiline` property to a component.
- Updated error message for `preparedResultQuery.error` to display the specific error message if available, falling back to "Failed to get a quote".
- Retained existing error messages for `buyTokenQuery` and `sellTokenQuery`.

> ✨ 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

* **Bug Fixes**
  * Improved error display in the swap widget: error text now shows a more specific message when available, falls back to a generic failure message otherwise, and supports multiline wrapping so longer errors are fully visible to users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-18 00:05:28 +00:00
MananTank b1a780832e [MNY-274] Dashboard: Generate swap token pages for popular tokens (#8278)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview

This PR focuses on refactoring the `BuyAndSwapEmbed` component and related functionalities to improve the handling of buy and swap operations across different tokens and chains, enhancing the user experience in the bridge application.

### Detailed summary

- Removed `chain` and `tokenAddress` props from `BuyAndSwapEmbed`.
- Introduced `buyTab` and `swapTab` props to manage buy/sell token information.
- Updated `UniversalBridgeEmbed` to accept new props structure.
- Added token pair management in `slug-map.ts`.
- Created utility functions for token pair data retrieval.
- Refactored `BridgePageUI` to accommodate the new props and structure.
- Enhanced error handling and reporting in `BuyAndSwapEmbed`.

> ✨ 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 a Bridge UI and dedicated token-pair pages with static routes, metadata, and token-pair utilities for cross-chain slugs.
  - Introduced a Bridge page UI with FAQ, data summary, and an embedded universal bridge widget.

- **Improvements**
  - Unified buy/sell input structure for embeds and prefills, simplifying embed wiring and page rendering.
  - More robust handling of chain/token data and reporting for buy/swap flows; removed legacy testnet flag usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 23:49:31 +00:00
MananTank 9bb65ad5b2 [PRO-48] Dashboard: Bridge page UI updates (#8274)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring components related to FAQ sections, modifying link types for analytics, and enhancing the header components in the bridge application.

### Detailed summary
- Changed `linkType` in `reportBridgePageLinkClick` from `"integrate-bridge"` to `"bridge-docs"`.
- Renamed `FaqSection` to `FaqAccordion` and updated its implementation.
- Introduced a new `FaqSection` component wrapping `FaqAccordion`.
- Renamed `PageHeader` to `BridgePageHeader`.
- Updated `PillLink` references to use `FaqAccordion`.
- Enhanced styling and structure of header and FAQ sections.

> ✨ 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**
  * Theme toggle added to dashboard headers
  * Documentation quick-access added to headers and bridge area

* **UI/Layout Changes**
  * Redesigned bridge page with a new hero/heading and data pill layout
  * FAQ section refactored into an accordion-style component
  * Header navigation updated to include trending tokens and documentation controls
  * Removed legacy pill-style external link component
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 21:53:57 +00:00
MananTank db291d2d38 [MNY-268] Bridge Status UI updates (#8273)
<!--

## 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 UI components in the dashboard by improving styles for better text handling and introducing a new `LinkWithCopyButton` component for copying links and displaying tooltips.

### Detailed summary
- Updated `className` in `CopyTextButton` and `WalletAddress` for better text truncation.
- Introduced `LinkWithCopyButton` component for link handling and copying functionality.
- Adjusted styles in `BridgeStatus` and `TokenInfo` for improved UI consistency.
- Replaced `CopyTextButton` with `LinkWithCopyButton` for token address and transaction hash.

> ✨ 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 a combined copy-and-open-link component with tooltip and temporary visual feedback for quick copying and opening external links.

* **Style**
  * Improved text truncation for wallet names, addresses and copy controls to prevent overflow.
  * Updated spacing, typography and section layout across the dashboard for clearer hierarchy.
  * Added external-link arrows for clearer link affordance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 20:23:13 +00:00
Jonas DanielsandGitHub 9dd0b17f15 Remove Chain Infrastructure section from team sidebar (#8275) 2025-10-17 15:21:34 -05:00
MananTank 12082fd5fd Dashboard: Fix autoconnect in /bridge page (#8271)
<!--

## 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 simplifies the `TWAutoConnect` component by removing the optional `accountAbstraction` prop and streamlining its usage in the `BridgeProviders` component.

### Detailed summary
- Removed the `accountAbstraction` prop from the `TWAutoConnect` component.
- Updated the `TWAutoConnect` function signature to only require the `client` prop.
- Integrated `TWAutoConnect` into the `BridgeProviders` component, passing the `thirdwebClient`.

> ✨ 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

* **Refactor**
  * Streamlined the auto-connect component API so it only requires the client, removing an unused configuration parameter.
  * Integrated the simplified auto-connect into the app provider stack to ensure automatic wallet/client initialization earlier in the startup sequence.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 16:38:49 +00:00
MananTank 5ff6063961 [MNY-269] SDK: autofocus token search input when token selector modal opens in BuyWidget, SwapWidget and BridgeWidget (#8266)
<!--

## 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 user experience by adding autofocus functionality to token selection inputs in various components and refactoring the `BuyWidget` and `SwapWidget` to utilize a common `Variant` component for rendering with different themes.

### Detailed summary
- Added `autoFocus` to token search inputs in `BuyWidget`, `SwapWidget`, and `BridgeWidget`.
- Refactored `BuyWidget` to use a `Variant` component for theme rendering.
- Refactored `SwapWidget` to use a `Variant` component for theme rendering.
- Introduced `autoFocusCrossIcon` prop in `Modal` component.
- Updated `SearchInput` to accept `autoFocus` prop.

> ✨ 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**
  * Token search input now auto-focuses on desktop when opening token selector modals in Buy, Swap, and Bridge widgets.
  * Modal gained a configurable option to control initial focus of the close (cross) button.

* **Bug Fixes / UX**
  * Prevents unwanted focus on the modal close icon by default when configured, improving keyboard/voicing navigation.

* **Chores**
  * Storybook examples updated for consistent light/dark variants and layout metadata.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 14:31:13 +00:00
MananTank 3db3982fab [MNY-270] Remove in-app wallet options from bridge page connect modal (#8270)
<!--

## 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 `appMetadata` constant for consistent branding across various components and refactors the code to utilize this constant, reducing redundancy and improving maintainability.

### Detailed summary
- Added `appMetadata` constant in `connect.ts`.
- Refactored `NebulaConnectButton` to use `appMetadata`.
- Updated `PageHeader` to include `wallets` prop in `PublicPageConnectButton`.
- Created `bridgeWallets` array in `UniversalBridgeEmbed` using `createWallet` with `appMetadata`.
- Modified multiple components to utilize `appMetadata` instead of hardcoded values.

> ✨ 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**
  * Wallet selection is now customizable for Buy, Swap, and Bridge flows.
  * App branding metadata centralized for consistent presentation across all wallet connection points.
  * Bridge now includes preset wallet options to streamline onboarding and autoconnect behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-17 03:46:41 +00:00
MananTank bf90104330 Dashboard: Remove playwright tests, checkly (#8269)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on the removal of various test files and configurations related to the `dashboard` application, alongside updates to the `package.json` and `pnpm-lock.yaml` files to reflect changes in dependencies.

### Detailed summary
- Deleted multiple test files in `apps/dashboard/tests`.
- Removed configuration files: `setup.ts`, `README.md`, `checkly.config.ts`, and `playwright.config.ts`.
- Updated `package.json` to remove dependencies related to `checkly` and `playwright`.
- Adjusted `pnpm-lock.yaml` to reflect the removal of certain packages and their dependencies.

> 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 -->

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

* **Chores**
  * Removed end-to-end testing infrastructure, configuration, helper utilities, test runner scripts, and related dev dependencies from the dashboard app.
* **Documentation**
  * Deleted the dashboard E2E testing README and related test documentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-16 20:57:55 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8003e93f19 Version Packages (#8264)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-16 14:05:56 -05:00
Jonas DanielsandGitHub 51c8318df7 Fetch native currency from chain API when required (#8267) 2025-10-16 14:00:59 -05:00
Joaquim VergesandGitHub f043f1eef4 [Docs] Rename listDiscoveryResources to listPayableServices in MCP tools (#8268) 2025-10-16 11:21:12 -07:00
Manan TankandGitHub d68da2239b [MNY-256] SDK: Update onSuccess prop of BuyWidget, CheckoutWidget, SwapWidget, BridgeWidget components (#8259) 2025-10-16 10:20:44 -07:00
Manan TankandGitHub 6a6b0fba96 [MNY-254] SDK: Preserve token and amount selection in BuyWidget when going back to initial screen (#8260) 2025-10-16 10:20:18 -07:00
Manan TankandGitHub c934102c51 [MNY-253] Dashboard: Add tracking for token row in /tokens page, SDK: BridgeWidget component (#8254) 2025-10-16 09:46:20 -07:00
MananTank 8c58c131ac [MNY-250] Fix Save button not enabled after changing Royalty BPS in contract settings page (#8252)
<!--

## 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 modification to the form handling in two components, adding the `shouldDirty` option to the `setValue` function calls for better form state management.

### Detailed summary
- In `royalties.tsx`, added `shouldDirty: true` to `form.setValue` for `seller_fee_basis_points`.
- In `platform-fees.tsx`, added `shouldDirty: true` to `form.setValue` for `platform_fee_basis_points`.

> ✨ 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

* **Bug Fixes**
  * Platform Fees and Royalties settings now mark field edits as dirty immediately, enabling the Save action when values are changed.
  * Unsaved-changes indicators and prompts update right away, reducing the risk of losing edits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-16 11:15:37 +00:00
Joaquim VergesandGitHub 2779afc15f [Docs] Update X402 agent example to use listDiscoveryResources (#8265) 2025-10-16 01:08:03 -07:00
Prithvish BaidyaandGitHub fe50abd5ab Add Solana transactions and wallets tables (#8263) 2025-10-16 04:26:26 +07:00
147 changed files with 9122 additions and 3448 deletions
-30
View File
@@ -1,30 +0,0 @@
import { defineConfig } from "checkly";
import { Frequency } from "checkly/constructs";
export default defineConfig({
checks: {
activated: true,
browserChecks: {
frequency: Frequency.EVERY_24H,
testMatch: "./tests/**/*.spec.ts",
},
checkMatch: "./**/*.check.ts",
frequency: Frequency.EVERY_24H,
ignoreDirectoriesMatch: [],
locations: ["us-east-1", "eu-west-1"],
muted: false,
playwrightConfig: {
use: {
baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com",
},
},
runtimeId: "2023.09",
tags: ["website"],
},
cli: {
runLocation: "eu-west-1",
},
logicalId: "thirdweb-www",
projectName: "thirdweb.com",
repoUrl: "https://github.com/thirdweb-dev/dashboard",
});
+1
View File
@@ -11,6 +11,7 @@ module.exports = [
"/account-abstraction",
"/payments",
"/payments/x402",
"/nexus",
"/auth",
"/in-app-wallets",
"/transactions",
+1 -5
View File
@@ -82,7 +82,6 @@
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
"@playwright/test": "1.53.2",
"@storybook/addon-docs": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
@@ -99,7 +98,6 @@
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"checkly": "6.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
@@ -122,13 +120,11 @@
"format": "biome format ./src --write",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"playwright": "playwright test",
"postbuild": "next-sitemap",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
"typecheck": "tsc --noEmit",
"update-checkly": "npx checkly deploy"
"typecheck": "tsc --noEmit"
},
"version": "3.0.0"
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Run tests in files in parallel */
fullyParallel: true,
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
testDir: "./tests",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Run your local dev server before starting the tests */
webServer: process.env.CI
? undefined
: {
command: "pnpm run start",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:3000",
},
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
});
+16 -1
View File
@@ -683,7 +683,22 @@ export function reportProductFeedback(properties: {
*
*/
export function reportBridgePageLinkClick(params: {
linkType: "integrate-bridge" | "trending-tokens";
linkType: "bridge-docs" | "trending-tokens";
}) {
posthog.capture("bridge page link clicked", params);
}
/**
* ### Why do we need to report this event?
* - To track which tokens are users are clicking on in the token list page
*
* ### Who is responsible for this event?
* @MananTank
*
*/
export function reportTokenRowClicked(params: {
chainId: number;
tokenAddress: string;
}) {
posthog.capture("token row clicked", params);
}
@@ -82,7 +82,7 @@ const CHAIN_PLAN_TO_INFRA = {
],
};
export async function getChainSubscriptions(slug: string) {
async function getChainSubscriptions(slug: string) {
const allSubscriptions = await getTeamSubscriptions(slug);
if (!allSubscriptions) {
return null;
@@ -21,7 +21,7 @@ export async function getUniversalBridgeTokens(props: {
headers: {
"Content-Type": "application/json",
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
} as Record<string, string>,
},
method: "GET",
});
@@ -21,7 +21,7 @@ export async function addUniversalBridgeTokenRoute(props: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
},
method: "POST",
});
@@ -1,9 +1,11 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Chain } from "thirdweb";
import { defineChain } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import type { Wallet } from "thirdweb/wallets";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
@@ -25,17 +27,46 @@ import {
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { appMetadata } from "../../constants/connect";
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain";
export function BuyAndSwapEmbed(props: {
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
export type BuyAndSwapEmbedProps = {
buyTab:
| {
buyToken:
| {
tokenAddress: string;
chainId: number;
amount?: string;
}
| undefined;
}
| undefined;
swapTab:
| {
sellToken:
| {
chainId: number;
tokenAddress: string;
amount?: string;
}
| undefined;
buyToken:
| {
chainId: number;
tokenAddress: string;
amount?: string;
}
| undefined;
}
| undefined;
pageType: PageType;
isTestnet: boolean | undefined;
}) {
wallets?: Wallet[];
};
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
@@ -84,24 +115,39 @@ export function BuyAndSwapEmbed(props: {
{tab === "buy" && (
<BuyWidget
amount={props.buyAmount || "1"}
chain={props.chain}
amount={props.buyTab?.buyToken?.amount || "1"}
chain={
props.buyTab?.buyToken?.chainId
? defineChain(props.buyTab.buyToken.chainId)
: undefined
}
tokenAddress={
props.buyTab?.buyToken?.tokenAddress as `0x${string}` | undefined
}
className="!rounded-2xl !border-none"
title=""
client={client}
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
onError={(e, quote) => {
const errorMessage = parseError(e);
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyFailed({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
@@ -114,21 +160,27 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
error: errorMessage,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
onCancel={(quote) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuyCancelled({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
@@ -141,24 +193,30 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
const buyChainId =
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined;
if (!buyChainId) {
return;
}
reportTokenBuySuccessful({
buyTokenChainId:
quote.type === "buy"
? quote.intent.destinationChainId
: quote.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenChainId: buyChainId,
buyTokenAddress:
quote.type === "buy"
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote.type === "onramp"
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
@@ -167,14 +225,13 @@ export function BuyAndSwapEmbed(props: {
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
chainId: buyChainId,
contractType: undefined,
is_testnet: props.isTestnet,
is_testnet: false,
});
}
}}
theme={themeObj}
tokenAddress={props.tokenAddress as `0x${string}`}
paymentMethods={["card"]}
/>
)}
@@ -184,18 +241,14 @@ export function BuyAndSwapEmbed(props: {
client={client}
theme={themeObj}
className="!rounded-2xl !border-none"
connectOptions={{
autoConnect: false,
wallets: props.wallets,
appMetadata: appMetadata,
}}
prefill={{
// buy this token by default
buyToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// sell the native token by default (but if buytoken is a native token, don't set)
sellToken: props.tokenAddress
? {
chainId: props.chain.id,
}
: undefined,
buyToken: props.swapTab?.buyToken,
sellToken: props.swapTab?.sellToken,
}}
onError={(error, quote) => {
const errorMessage = parseError(error);
@@ -208,7 +261,7 @@ export function BuyAndSwapEmbed(props: {
pageType: props.pageType,
});
}}
onSuccess={(quote) => {
onSuccess={({ quote }) => {
reportTokenSwapSuccessful({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
@@ -5,25 +5,20 @@ import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { cn } from "@/lib/utils";
export function FaqSection(props: {
export function FaqAccordion(props: {
faqs: Array<{ title: string; description: string }>;
}) {
return (
<section>
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
Frequently asked questions
</h2>
<div className="flex flex-col">
{props.faqs.map((faq, faqIndex) => (
<FaqItem
key={faq.title}
title={faq.title}
description={faq.description}
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
/>
))}
</div>
</section>
<div className="flex flex-col">
{props.faqs.map((faq, faqIndex) => (
<FaqItem
key={faq.title}
title={faq.title}
description={faq.description}
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
/>
))}
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { useClipboard } from "@/hooks/useClipboard";
import { cn } from "@/lib/utils";
export function SolanaAddress(props: {
address: string;
shortenAddress?: boolean;
className?: string;
}) {
const shortenedAddress = useMemo(() => {
return props.shortenAddress !== false
? `${props.address.slice(0, 4)}...${props.address.slice(-4)}`
: props.address;
}, [props.address, props.shortenAddress]);
const lessShortenedAddress = useMemo(() => {
return `${props.address.slice(0, 8)}...${props.address.slice(-8)}`;
}, [props.address]);
const { onCopy, hasCopied } = useClipboard(props.address, 2000);
return (
<HoverCard>
<HoverCardTrigger asChild tabIndex={-1}>
<Button
className={cn(
"flex flex-row items-center gap-2 px-0",
props.className,
)}
onClick={(e) => e.stopPropagation()}
variant="link"
>
<div className="flex size-5 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span className="cursor-pointer font-mono text-sm">
{shortenedAddress}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent
className="w-80 border-border"
onClick={(e) => {
// do not close the hover card when clicking anywhere in the content
e.stopPropagation();
}}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Solana Public Key</h3>
<Button
className="flex items-center gap-2"
onClick={onCopy}
size="sm"
variant="outline"
>
{hasCopied ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
{hasCopied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="break-all rounded bg-muted p-3 text-center font-mono text-xs leading-relaxed">
{lessShortenedAddress}
</p>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-muted-foreground text-xs leading-relaxed">
Solana public key for blockchain transactions.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
@@ -0,0 +1,120 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import type { SocialProfile } from "thirdweb/react";
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
import { WalletAddress, WalletAddressUI } from "./wallet-address";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "blocks/WalletAddress",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {},
};
function Story() {
const client = storybookThirdwebClient as unknown as ThirdwebClient;
return (
<div className="container flex max-w-4xl flex-col gap-8 py-10">
<BadgeContainer label="Social Profiles Loaded">
<WalletAddressUI
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
client={client}
profiles={{
data: vitalikEth,
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="No Social Profiles">
<WalletAddressUI
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
client={client}
shortenAddress
profiles={{
data: [],
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="Loading Social Profiles">
<WalletAddressUI
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
client={client}
shortenAddress
profiles={{
data: [],
isPending: true,
}}
/>
</BadgeContainer>
<BadgeContainer label="Zero address">
<WalletAddressUI
address={ZERO_ADDRESS}
client={client}
profiles={{
data: [],
isPending: false,
}}
/>
</BadgeContainer>
<BadgeContainer label="Real Component - Invalid Address">
<WalletAddress address="not-an-address" client={client} />
</BadgeContainer>
<BadgeContainer label="Real Component - vitalik.eth">
<WalletAddress
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
client={client}
/>
</BadgeContainer>
</div>
);
}
const vitalikEth: SocialProfile[] = [
{
type: "ens",
name: "vitalik.eth",
bio: "mi pinxe lo crino tcati",
avatar: "https://euc.li/vitalik.eth",
metadata: {
name: "vitalik.eth",
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
avatar: "https://euc.li/vitalik.eth",
description: "mi pinxe lo crino tcati",
url: "https://vitalik.ca",
},
},
{
type: "farcaster",
name: "vitalik.eth",
bio: undefined,
avatar:
"https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
metadata: {
fid: 5650,
bio: undefined,
pfp: "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
username: "vitalik.eth",
addresses: [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0x96B6bB2bd2Eba3b4Fbefd7DbAC448ad7B6CBf279",
],
},
},
];
@@ -1,11 +1,10 @@
"use client";
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
import { CircleSlashIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { Img } from "@/components/blocks/Img";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
HoverCard,
@@ -13,22 +12,49 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useClipboard } from "@/hooks/useClipboard";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { CopyTextButton } from "../ui/CopyTextButton";
import { Skeleton } from "../ui/skeleton";
export function WalletAddress(props: {
type WalletAddressProps = {
address: string | undefined;
shortenAddress?: boolean;
className?: string;
iconClassName?: string;
client: ThirdwebClient;
fallbackIcon?: React.ReactNode;
}) {
};
export function WalletAddress(props: WalletAddressProps) {
const profiles = useSocialProfiles({
address: props.address,
client: props.client,
});
return (
<WalletAddressUI
{...props}
profiles={{
data: profiles.data || [],
isPending: profiles.isPending,
}}
/>
);
}
export function WalletAddressUI(
props: WalletAddressProps & {
profiles: {
data: SocialProfile[];
isPending: boolean;
};
},
) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
const [shortenedAddress, lessShortenedAddress] = useMemo(() => {
const [shortenedAddress, _lessShortenedAddress] = useMemo(() => {
return [
props.shortenAddress !== false
? `${address.slice(0, 6)}...${address.slice(-4)}`
@@ -37,17 +63,10 @@ export function WalletAddress(props: {
];
}, [address, props.shortenAddress]);
const profiles = useSocialProfiles({
address: address,
client: props.client,
});
const { onCopy, hasCopied } = useClipboard(address, 2000);
if (!isAddress(address)) {
return (
<ToolTipLabel hoverable label={address}>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<span className="flex items-center gap-2 underline-offset-4 hover:underline w-fit">
<div className="flex size-5 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
@@ -78,7 +97,7 @@ export function WalletAddress(props: {
<HoverCardTrigger asChild tabIndex={-1}>
<Button
className={cn(
"flex flex-row items-center gap-2 px-0",
"flex flex-row items-center gap-2 px-0 max-w-full truncate",
props.className,
)}
onClick={(e) => e.stopPropagation()}
@@ -88,89 +107,82 @@ export function WalletAddress(props: {
<WalletAvatar
address={address}
iconClassName={props.iconClassName}
profiles={profiles.data || []}
profiles={props.profiles.data}
thirdwebClient={props.client}
fallbackIcon={props.fallbackIcon}
/>
)}
<span className="cursor-pointer font-mono">
{profiles.data?.[0]?.name || shortenedAddress}
<span className="cursor-pointer font-mono max-w-full truncate">
{props.profiles.data?.[0]?.name || shortenedAddress}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent
className="w-80 border-border"
className="w-[calc(100vw-2rem)] lg:w-[450px] border-border rounded-xl p-4 lg:p-6"
onClick={(e) => {
// do not close the hover card when clicking anywhere in the content
e.stopPropagation();
}}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Wallet Address</h3>
<Button
className="flex items-center gap-2"
onClick={onCopy}
size="sm"
variant="outline"
>
{hasCopied ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
{hasCopied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="rounded bg-muted p-2 text-center font-mono text-sm">
{lessShortenedAddress}
</p>
<h3 className="font-semibold text-lg">Social Profiles</h3>
{profiles.isPending ? (
<p className="text-muted-foreground text-sm">Loading profiles...</p>
) : !profiles.data?.length ? (
<p className="text-muted-foreground text-sm">No profiles found</p>
) : (
profiles.data?.map((profile) => {
const walletAvatarLink = resolveSchemeWithErrorHandler({
client: props.client,
uri: profile.avatar,
});
<div className="space-y-1">
<h3 className="font-medium text-sm">Wallet Address</h3>
return (
<div
className="flex flex-row items-center gap-2"
key={profile.type + profile.name}
>
{walletAvatarLink && (
<Avatar>
<AvatarImage
alt={profile.name}
className="object-cover"
src={walletAvatarLink}
/>
{profile.name && (
<AvatarFallback>
{profile.name.slice(0, 2)}
</AvatarFallback>
)}
</Avatar>
)}
<div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-row items-center justify-between gap-4">
<h4 className="font-semibold text-md">{profile.name}</h4>
<Badge variant="outline">{profile.type}</Badge>
<CopyTextButton
textToShow={address}
textToCopy={address}
tooltip="Copy address"
copyIconPosition="right"
variant="ghost"
className="text-muted-foreground -translate-x-1.5"
/>
</div>
<div className="space-y-1 border-t pt-5 border-dashed">
<h3 className="font-medium text-sm">Social Profiles</h3>
{props.profiles.isPending ? (
<Skeleton className="h-4 w-[50%]" />
) : !props.profiles.data?.length ? (
<p className="text-muted-foreground text-sm">No profiles found</p>
) : (
<div className="!mt-2">
{props.profiles.data?.map((profile) => {
const walletAvatarLink = resolveSchemeWithErrorHandler({
client: props.client,
uri: profile.avatar,
});
return (
<div
className="flex flex-row items-center gap-3 py-2"
key={profile.type + profile.name}
>
<Avatar className="size-9">
<AvatarImage
alt={profile.name}
className="object-cover"
src={walletAvatarLink}
/>
{profile.name && (
<AvatarFallback>
{profile.name.slice(0, 2)}
</AvatarFallback>
)}
</Avatar>
<div className="space-y-0.5">
<h4 className="text-sm leading-none">{profile.name}</h4>
<span className="text-muted-foreground text-xs leading-none capitalize">
{profile.type === "ens" ? "ENS" : profile.type}
</span>
</div>
</div>
{profile.bio && (
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
{profile.bio}
</p>
)}
</div>
</div>
);
})
)}
);
})}
</div>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
@@ -17,6 +17,7 @@ import { resetAnalytics } from "@/analytics/reset";
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
import { Button } from "@/components/ui/button";
import { appMetadata } from "@/constants/connect";
import { popularChains } from "@/constants/popularChains";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
@@ -138,11 +139,7 @@ export const CustomConnectWallet = (props: {
return (
<>
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
chain={props.chain}
chains={allChainsV5}
client={client}
@@ -280,11 +277,7 @@ export function useCustomConnectModal() {
return useCallback(
(options: { chain?: Chain; client: ThirdwebClient }) => {
return connect({
appMetadata: {
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
},
appMetadata,
chain: options?.chain,
client: options.client,
privacyPolicyUrl: "/privacy-policy",
@@ -14,6 +14,15 @@ import {
} from "@/components/ui/select";
import type { SearchType } from "./types";
const searchTypeLabels: Record<SearchType, string> = {
email: "Email",
phone: "Phone",
id: "Auth Identifier",
address: "Address",
externalWallet: "External Wallet",
userId: "User Identifier",
};
export function AdvancedSearchInput(props: {
onSearch: (searchType: SearchType, query: string) => void;
onClear: () => void;
@@ -52,7 +61,8 @@ export function AdvancedSearchInput(props: {
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="id">Auth Identifier</SelectItem>
<SelectItem value="userId">User Identifier</SelectItem>
<SelectItem value="address">Address</SelectItem>
<SelectItem value="externalWallet">External Wallet</SelectItem>
</SelectContent>
@@ -62,7 +72,7 @@ export function AdvancedSearchInput(props: {
<div className="relative flex-1">
<Input
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
placeholder={`Search by ${searchType}...`}
placeholder={`Search by ${searchTypeLabels[searchType]}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
@@ -12,18 +12,25 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CopyTextButton } from "../ui/CopyTextButton";
const getUserIdentifier = (user: WalletUser) => {
const getAuthIdentifier = (user: WalletUser) => {
const mainDetail = user.linkedAccounts[0]?.details;
return (
mainDetail?.id ??
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id ??
user.id
mainDetail?.address
);
};
const truncateIdentifier = (value: string) => {
if (value.length <= 18) {
return value;
}
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function SearchResults(props: {
results: WalletUser[];
client: ThirdwebClient;
@@ -51,6 +58,8 @@ export function SearchResults(props: {
const mainDetail = user.linkedAccounts?.[0]?.details;
const email = mainDetail?.email as string | undefined;
const phone = mainDetail?.phone as string | undefined;
const authIdentifier = getAuthIdentifier(user);
const userIdentifier = user.id;
// Get external wallet addresses from linkedAccounts where type is 'siwe'
const externalWalletAccounts =
@@ -68,7 +77,34 @@ export function SearchResults(props: {
<p className="text-sm font-medium text-muted-foreground">
User Identifier
</p>
<p className="text-sm">{getUserIdentifier(user)}</p>
{userIdentifier ? (
<CopyTextButton
textToShow={truncateIdentifier(userIdentifier)}
textToCopy={userIdentifier}
tooltip="Copy User Identifier"
copyIconPosition="left"
variant="ghost"
/>
) : (
<p className="text-sm">N/A</p>
)}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Auth Identifier
</p>
{authIdentifier ? (
<CopyTextButton
textToShow={truncateIdentifier(authIdentifier)}
textToCopy={authIdentifier}
tooltip="Copy Auth Identifier"
copyIconPosition="left"
variant="ghost"
/>
) : (
<p className="text-sm">N/A</p>
)}
</div>
{walletAddress && (
@@ -28,22 +28,42 @@ import { SearchResults } from "./SearchResults";
import { searchUsers } from "./searchUsers";
import type { SearchType } from "./types";
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const getAuthIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
return (
mainDetail?.id ??
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id
mainDetail?.address
);
};
const getExternalWallets = (accounts: WalletUser["linkedAccounts"]) => {
return accounts?.filter((account) => account.type === "siwe") || [];
const getPrimaryEmail = (accounts: WalletUser["linkedAccounts"]) => {
const emailFromPrimary = accounts[0]?.details?.email;
if (emailFromPrimary) {
return emailFromPrimary;
}
const emailAccount = accounts.find((account) => {
return typeof account.details?.email === "string" && account.details.email;
});
if (emailAccount && typeof emailAccount.details.email === "string") {
return emailAccount.details.email;
}
return undefined;
};
const columnHelper = createColumnHelper<WalletUser>();
const truncateIdentifier = (value: string) => {
if (value.length <= 18) {
return value;
}
return `${value.slice(0, 8)}...${value.slice(-4)}`;
};
export function InAppWalletUsersPageContent(
props: {
authToken: string;
@@ -56,9 +76,30 @@ export function InAppWalletUsersPageContent(
) {
const columns = useMemo(() => {
return [
columnHelper.accessor("id", {
cell: (cell) => {
const userId = cell.getValue();
if (!userId) {
return "N/A";
}
return (
<CopyTextButton
textToShow={truncateIdentifier(userId)}
textToCopy={userId}
tooltip="Copy User Identifier"
copyIconPosition="left"
variant="ghost"
/>
);
},
header: "User Identifier",
id: "user_identifier",
}),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const identifier = getUserIdentifier(cell.getValue());
const identifier = getAuthIdentifier(cell.getValue());
if (!identifier) {
return "N/A";
@@ -66,21 +107,17 @@ export function InAppWalletUsersPageContent(
return (
<CopyTextButton
textToShow={
identifier.length > 30
? `${identifier.slice(0, 30)}...`
: identifier
}
textToShow={truncateIdentifier(identifier)}
textToCopy={identifier}
tooltip="Copy User Identifier"
tooltip="Copy Auth Identifier"
copyIconPosition="left"
variant="ghost"
/>
);
},
enableColumnFilter: true,
header: "User Identifier",
id: "user_identifier",
header: "Auth Identifier",
id: "auth_identifier",
}),
columnHelper.accessor("wallets", {
cell: (cell) => {
@@ -94,33 +131,24 @@ export function InAppWalletUsersPageContent(
}),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const externalWallets = getExternalWallets(cell.getValue());
if (externalWallets.length === 0) {
return <span className="text-muted-foreground text-sm">None</span>;
const email = getPrimaryEmail(cell.getValue());
if (!email) {
return <span className="text-muted-foreground text-sm">N/A</span>;
}
return (
<div className="space-y-1">
{externalWallets.slice(0, 2).map((account) => {
const address = account.details?.address as string | undefined;
return address ? (
<div
key={`external-${address}-${account.details?.id}`}
className="text-xs"
>
<WalletAddress address={address} client={props.client} />
</div>
) : null;
})}
{externalWallets.length > 2 && (
<span className="text-muted-foreground text-xs">
+{externalWallets.length - 2} more
</span>
)}
</div>
<CopyTextButton
textToShow={email}
textToCopy={email}
tooltip="Copy Email"
copyIconPosition="left"
variant="ghost"
/>
);
},
header: "External Wallets",
id: "external_wallets",
header: "Email",
id: "email",
}),
columnHelper.accessor("wallets", {
cell: (cell) => {
@@ -230,20 +258,17 @@ export function InAppWalletUsersPageContent(
});
const csv = Papa.unparse(
usersWallets.map((row) => {
const externalWallets = getExternalWallets(row.linkedAccounts);
const externalWalletAddresses = externalWallets
.map((account) => account.details?.address)
.filter(Boolean)
.join(", ");
const email = getPrimaryEmail(row.linkedAccounts);
return {
address: row.wallets[0]?.address || "Uninitialized",
created: row.wallets[0]?.createdAt
? new Date(row.wallets[0].createdAt).toISOString()
: "Wallet not created yet",
external_wallets: externalWalletAddresses || "None",
email: email || "N/A",
login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "),
user_identifier: getUserIdentifier(row.linkedAccounts),
auth_identifier: getAuthIdentifier(row.linkedAccounts) || "N/A",
user_identifier: row.id || "N/A",
};
}),
);
@@ -18,10 +18,10 @@ configure({
type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0];
type APIProfile = APIWallet["profiles"][0];
// Transform API response to match existing WalletUser format
// Transform API response to match wallet user format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
@@ -105,6 +105,9 @@ export async function searchUsers(
case "externalWallet":
queryParams.externalWalletAddress = query;
break;
case "userId":
queryParams.userId = query;
break;
}
// Use the generated API function with Bearer authentication
@@ -3,4 +3,5 @@ export type SearchType =
| "phone"
| "id"
| "address"
| "externalWallet";
| "externalWallet"
| "userId";
@@ -41,7 +41,7 @@ export function CopyTextButton(props: {
<Button
aria-label={props.tooltip}
className={cn(
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground",
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground max-w-full truncate",
props.className,
)}
onClick={(e) => {
@@ -0,0 +1,45 @@
"use client";
import { ArrowUpRightIcon, CheckIcon, CopyIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
export function LinkWithCopyButton(props: {
href: string;
textToShow: string;
textToCopy: string;
copyTooltip: string;
className?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<div className={cn("flex items-center gap-1", props.className)}>
<ToolTipLabel label={props.copyTooltip}>
<Button
variant="ghost"
size="sm"
className="p-1 h-auto shrink-0 opacity-70 hover:opacity-100 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(props.textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
<Icon className="size-3" />
</Button>
</ToolTipLabel>
<Link
href={props.href}
target="_blank"
className="text-sm text-muted-foreground hover:underline flex items-center gap-1 tabular-nums flex-1 truncate hover:text-foreground group"
>
<span className="max-w-full truncate">{props.textToShow}</span>
<ArrowUpRightIcon className="size-3.5 opacity-70 shrink-0 group-hover:opacity-100" />
</Link>
</div>
);
}
@@ -0,0 +1,5 @@
export const appMetadata = {
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
};
@@ -59,7 +59,10 @@ export async function fetchDashboardContractMetadata(
return {
contractType: compilerMetadata?.name || "",
image: contractMetadata.image || "",
image:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: "",
name: contractName,
symbol: contractSymbol,
};
@@ -76,10 +76,10 @@ const fetchAccountList = ({
};
};
// Transform API response to match existing WalletUser format
// Transform API response to match the wallet user format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
@@ -1,18 +1,26 @@
"use client";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
export function BuyFundsSection(props: { chain: ChainMetadata }) {
return (
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
isTestnet={props.chain.testnet}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
tokenAddress={undefined}
swapTab={{
sellToken: {
chainId: props.chain.chainId,
tokenAddress: NATIVE_TOKEN_ADDRESS,
},
buyToken: undefined,
}}
buyTab={{
buyToken: {
chainId: props.chain.chainId,
tokenAddress: NATIVE_TOKEN_ADDRESS,
},
}}
pageType="chain"
/>
</GridPatternEmbedContainer>
@@ -1,6 +1,6 @@
import { CircleAlertIcon } from "lucide-react";
import { getRawAccount } from "@/api/account/get-account";
import { FaqSection } from "@/components/blocks/faq-section";
import { FaqAccordion } from "@/components/blocks/faq-section";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain, getCustomChainMetadata } from "../../utils";
import { fetchChainSeo } from "./apis/chain-seo";
@@ -67,10 +67,21 @@ export default async function Page(props: Props) {
)}
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
<div className="py-10">
<FaqSection faqs={chainSeo.faqs} />
</div>
<FaqSection faqs={chainSeo.faqs} />
)}
</div>
);
}
function FaqSection(props: {
faqs: Array<{ title: string; description: string }>;
}) {
return (
<section className="py-10">
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
Frequently asked questions
</h2>
<FaqAccordion faqs={props.faqs} />
</section>
);
}
@@ -10,8 +10,7 @@ import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import type { OverrideEntry } from "thirdweb/utils";
import { maxUint256 } from "thirdweb/utils";
import { isRecord, maxUint256, type OverrideEntry } from "thirdweb/utils";
import type { z } from "zod";
import type {
ClaimCondition as LegacyClaimCondition,
@@ -86,7 +85,7 @@ export async function getClaimPhasesInLegacyFormat(
]);
const snapshot = await fetchSnapshot(
condition.merkleRoot,
contractMetadata.merkle,
isRecord(contractMetadata.merkle) ? contractMetadata.merkle : {},
options.contract.client,
);
return {
@@ -47,7 +47,10 @@ export default async function Image({
chainName: info.chainMetadata.name,
contractAddress: info.serverContract.address,
displayName: contractDisplayName,
logo: contractMetadata.image,
logo:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined,
});
} catch {
return contractOGImageTemplate({
@@ -2,6 +2,8 @@
import { useTheme } from "next-themes";
import { ConnectButton } from "thirdweb/react";
import type { Wallet } from "thirdweb/wallets";
import { appMetadata } from "@/constants/connect";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { getSDKTheme } from "@/utils/sdk-component-theme";
@@ -10,6 +12,7 @@ const client = getClientThirdwebClient();
export function PublicPageConnectButton(props: {
connectButtonClassName?: string;
wallets?: Wallet[];
}) {
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
@@ -17,11 +20,7 @@ export function PublicPageConnectButton(props: {
return (
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
autoConnect={false}
chains={allChainsV5}
client={client}
@@ -38,6 +37,7 @@ export function PublicPageConnectButton(props: {
}}
// we have an AutoConnect already added in root layout with AA configuration
theme={getSDKTheme(t)}
wallets={props.wallets}
/>
);
}
@@ -89,16 +89,31 @@ export async function ERC20PublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
image={
contractMetadata.image &&
typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined
}
isDashboardUser={isDashboardUser}
name={contractMetadata.name}
name={
contractMetadata.name && typeof contractMetadata.name === "string"
? contractMetadata.name
: // if we do not have a contract name fall back to the address
props.clientContract.address
}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
symbol={
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string"
? contractMetadata.symbol
: undefined
}
/>
</div>
</div>
@@ -143,21 +158,25 @@ export async function ERC20PublicPage(props: {
</div>
)}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
{showBuyEmbed &&
contractMetadata.name &&
typeof contractMetadata.name === "string" &&
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
@@ -167,12 +186,15 @@ export async function ERC20PublicPage(props: {
contractAddress={props.clientContract.address}
/>
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
{contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
)}
</div>
</div>
</div>
@@ -199,11 +221,21 @@ function BuyEmbed(props: {
if (!props.claimConditionMeta) {
return (
<BuyAndSwapEmbed
chain={props.clientContract.chain}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
// chain={props.clientContract.chain}
swapTab={{
sellToken: {
chainId: props.clientContract.chain.id,
tokenAddress: props.clientContract.address,
},
buyToken: undefined,
}}
buyTab={{
buyToken: {
chainId: props.clientContract.chain.id,
tokenAddress: props.clientContract.address,
},
}}
pageType="asset"
isTestnet={props.chainMetadata.testnet}
/>
);
}
@@ -53,6 +53,13 @@ export async function NFTPublicPage(props: {
const _isTokenByIndexSupported =
props.type === "erc721" && isTokenByIndexSupported(functionSelectors);
// FIXME: this is technically a bad fallback but we gotta do what we gotta do
const contractMetadataWithNameAndSymbolFallback = {
...contractMetadata,
// fall back to the contract address if the name is not set
name: contractMetadata.name || props.clientContract.address,
symbol: contractMetadata.symbol || "",
};
const buyNFTDropCard = nftDropClaimParams ? (
<BuyNFTDropCardServer
chainMetadata={props.chainMetadata}
@@ -73,7 +80,7 @@ export async function NFTPublicPage(props: {
<NFTsGrid
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
gridClassName={
buyNFTDropCard
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
@@ -110,7 +117,7 @@ export async function NFTPublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
contractMetadata={contractMetadata}
contractMetadata={contractMetadataWithNameAndSymbolFallback}
isDashboardUser={isDashboardUser}
>
<ResponsiveLayout
@@ -138,7 +145,7 @@ export async function NFTPublicPage(props: {
<PageLoadTokenViewerSheet
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
tokenByIndexSupported={_isTokenByIndexSupported}
tokenId={BigInt(props.tokenId)}
type={props.type}
@@ -37,7 +37,7 @@ export function TokenViewerSheet(
clientContract: ThirdwebContract;
chainMetadata: ChainMetadata;
collectionMetadata: {
name: string;
name: string | null;
image?: string;
};
type: "erc1155" | "erc721";
@@ -57,6 +57,11 @@ export function TokenViewerSheet(
) {
const tokenId = props.variant === "fetch-data" ? props.tokenId : props.nft.id;
const collectionMetadataWithNameFallback = {
...props.collectionMetadata,
name: props.collectionMetadata.name || props.clientContract.address,
};
return (
<Dialog
onOpenChange={(open) => {
@@ -78,7 +83,7 @@ export function TokenViewerSheet(
<FetchAndRenderTokenInfo
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
tokenByIndexSupported={props.tokenByIndexSupported}
tokenId={props.tokenId}
type={props.type}
@@ -86,7 +91,7 @@ export function TokenViewerSheet(
) : (
<TokenInfoUI
chainMetadata={props.chainMetadata}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
contract={props.clientContract}
data={props.nft}
tokenId={tokenId}
@@ -84,7 +84,10 @@ export const SettingsMetadata = ({
console.error(err);
}
}
let image: string | undefined = metadata.data?.image;
let image: string | undefined =
metadata.data?.image && typeof metadata.data.image === "string"
? metadata.data.image
: undefined;
try {
image = image
? // eslint-disable-next-line no-restricted-syntax
@@ -137,6 +137,7 @@ export function SettingsPlatformFees({
onChange={(value) =>
form.setValue("platform_fee_basis_points", value, {
shouldValidate: true,
shouldDirty: true,
})
}
value={form.watch("platform_fee_basis_points")}
@@ -151,6 +151,7 @@ export function SettingsRoyalties({
onChange={(value) =>
form.setValue("seller_fee_basis_points", value, {
shouldValidate: true,
shouldDirty: true,
})
}
value={form.watch("seller_fee_basis_points")}
@@ -3,7 +3,12 @@ import { useQuery } from "@tanstack/react-query";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { Img } from "@workspace/ui/components/img";
import { Spinner } from "@workspace/ui/components/spinner";
import { ArrowRightIcon, CircleCheckIcon, CircleXIcon } from "lucide-react";
import {
ArrowRightIcon,
ArrowUpRightIcon,
CircleCheckIcon,
CircleXIcon,
} from "lucide-react";
import Link from "next/link";
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
import type { Status, Token } from "thirdweb/bridge";
@@ -11,6 +16,7 @@ import { status } from "thirdweb/bridge";
import { toTokens } from "thirdweb/utils";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { LinkWithCopyButton } from "@/components/ui/link-with-copy-button";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { fetchChain } from "@/utils/fetchChain";
@@ -46,9 +52,9 @@ export function BridgeStatus(props: {
/>
)}
<div className="px-6 lg:px-8 py-7 space-y-1.5">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground "> Status </p>
<div className="px-6 lg:px-8 py-7 space-y-4">
<div className="space-y-1">
<p className="text-sm text-foreground "> Status </p>
<div
className={cn(
@@ -75,14 +81,15 @@ export function BridgeStatus(props: {
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground ">Payment ID</p>
<div className="space-y-1">
<p className="text-sm text-foreground ">Payment ID</p>
<CopyTextButton
textToCopy={bridgeStatus.paymentId}
textToShow={`${bridgeStatus.paymentId.slice(0, 6)}...${bridgeStatus.paymentId.slice(-4)}`}
textToShow={bridgeStatus.paymentId}
tooltip="Payment ID"
className="text-sm translate-x-1.5"
className="text-sm -translate-x-1.5 tabular-nums text-muted-foreground"
copyIconPosition="left"
iconClassName="text-muted-foreground/70"
variant="ghost"
/>
</div>
@@ -90,7 +97,7 @@ export function BridgeStatus(props: {
{purchaseDataString && (
<div className="px-6 lg:px-8 py-7 space-y-2 border-t border-dashed">
<p className="text-sm text-muted-foreground ">Purchase Data</p>
<p className="text-sm text-foreground ">Purchase Data</p>
<CodeClient
code={purchaseDataString}
lang="json"
@@ -113,6 +120,8 @@ function TokenInfo(props: {
txHash: string | undefined;
}) {
const chainQuery = useChainQuery(props.token.chainId);
const isNativeToken =
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
return (
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
@@ -121,8 +130,9 @@ function TokenInfo(props: {
{props.label}
</h3>
</div>
<div className="h-6" />
<div className="pb-7 pt-6 border-b border-dashed">
<div>
<div className="flex items-center gap-3">
<div className="relative hover:ring-2 hover:ring-offset-2 hover:ring-offset-card hover:ring-foreground/30 rounded-full">
<Link
@@ -178,10 +188,11 @@ function TokenInfo(props: {
render={(data) => (
<Link
href={`/${chainQuery.data?.slug || props.token.chainId}`}
className="text-sm text-muted-foreground leading-none hover:underline"
className="text-sm text-muted-foreground leading-none hover:underline flex items-center gap-1"
target="_blank"
>
{data}
{data}{" "}
<ArrowUpRightIcon className="size-3.5 text-muted-foreground/70 shrink-0" />
</Link>
)}
/>
@@ -189,41 +200,44 @@ function TokenInfo(props: {
</div>
</div>
<div className="h-6" />
<div className="h-8" />
<div className="space-y-1.5">
<div className="flex gap-2.5 justify-between">
<p className="text-sm text-muted-foreground ">{props.addressLabel}</p>
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-foreground ">{props.addressLabel}</p>
<WalletAddress
address={props.walletAddress}
client={props.client}
className="py-0.5 h-auto text-sm [&>*]:!font-sans"
iconClassName="size-3.5"
className="py-0.5 h-auto text-sm [&>*]:!font-sans tabular-nums [&>*]:font-normal text-muted-foreground hover:text-foreground"
iconClassName="size-3"
shortenAddress={false}
/>
</div>
<div className="flex gap-2 justify-between">
<p className="text-sm text-muted-foreground ">Token Address</p>
<CopyTextButton
<div className="space-y-1">
<p className="text-sm text-foreground ">Token Address</p>
<LinkWithCopyButton
href={
isNativeToken
? `/${chainQuery.data?.slug || props.token.chainId}`
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
}
textToShow={props.token.address}
textToCopy={props.token.address}
textToShow={`${props.token.address.slice(0, 6)}...${props.token.address.slice(-4)}`}
tooltip="Token Address"
className="text-sm translate-x-1.5"
copyIconPosition="left"
variant="ghost"
copyTooltip="Copy Token Address"
className="-translate-x-1"
/>
</div>
<div className="flex gap-2 justify-between">
<p className="text-sm text-muted-foreground ">Transaction Hash</p>
<div className="space-y-1">
<p className="text-sm text-foreground ">Transaction Hash</p>
{props.txHash ? (
<CopyTextButton
<LinkWithCopyButton
className="-translate-x-1"
href={`/${chainQuery.data?.slug || props.token.chainId}/tx/${props.txHash}`}
textToShow={`${props.txHash.slice(0, 12)}...${props.txHash.slice(-4)}`}
textToCopy={props.txHash}
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
tooltip="Transaction Hash"
className="text-sm translate-x-1.5"
copyIconPosition="left"
variant="ghost"
copyTooltip="Copy Transaction Hash"
/>
) : (
<p className="text-sm text-muted-foreground ">N/A</p>
@@ -288,10 +302,8 @@ function FailedBridgeStatusContent(props: {
}) {
return (
<div className="px-6 lg:px-8 py-7 space-y-1.5 border-b border-dashed">
<h3 className="text-base font-medium tracking-tight mb-3">
Transactions
</h3>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-medium tracking-tight mb-4">Transactions</h3>
<div className="space-y-4">
{props.transactions.map((tx) => {
return (
<TxHashRow
@@ -328,7 +340,7 @@ function TxHashRow(props: {
const chainQuery = useChainQuery(props.chainId);
return (
<div className="flex items-center gap-2 justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Img
src={resolveSchemeWithErrorHandler({
@@ -336,26 +348,23 @@ function TxHashRow(props: {
uri: chainQuery.data?.icon?.url,
})}
alt={chainQuery.data?.name}
className="size-4 rounded-full"
className="size-3.5 rounded-full"
fallback={
<div className="size-4 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
<div className="size-3.5 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
}
/>
<SkeletonContainer
skeletonData={`Chain ${props.chainId}`}
loadedData={chainQuery.data?.name}
render={(data) => (
<p className="text-sm text-muted-foreground">{data}</p>
)}
render={(data) => <p className="text-sm text-foreground">{data}</p>}
/>
</div>
<CopyTextButton
<LinkWithCopyButton
href={`/${chainQuery.data?.slug || props.chainId}/tx/${props.txHash}`}
textToShow={props.txHash}
textToCopy={props.txHash}
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
tooltip="Copy Transaction Hash"
className="text-sm"
copyIconPosition="right"
variant="ghost"
copyTooltip="Copy Transaction Hash"
className="-translate-x-0.5"
/>
</div>
);
@@ -311,7 +311,10 @@ function GeneericTxDetails(props: {
label="Burnt fees"
tooltip="The amount of fees that were burnt by the transaction."
>
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
<p>
{toEther((block.baseFeePerGas || 0n) * receipt.gasUsed)}{" "}
{chain.nativeCurrency?.symbol}
</p>
</GridItem>
</section>
@@ -1,5 +1,10 @@
"use client";
import { BookOpenIcon, MoonIcon, SunIcon } from "lucide-react";
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { useTheme } from "next-themes";
import { ClientOnly } from "@/components/blocks/client-only";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
@@ -9,7 +14,7 @@ export function PageHeader(props: { containerClassName?: string }) {
<div className="border-b">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
"container flex max-w-7xl justify-between py-4",
props.containerClassName,
)}
>
@@ -22,18 +27,49 @@ export function PageHeader(props: { containerClassName?: string }) {
</Link>
</div>
<div className="flex items-center gap-5">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<ToggleThemeButton />
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 lg:gap-2">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
aria-label="View Documentation"
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
>
<BookOpenIcon className="size-5" />
</Link>
<ToggleThemeButton />
</div>
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
function ToggleThemeButton(props: { className?: string }) {
const { setTheme, theme } = useTheme();
return (
<ClientOnly
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
>
<Button
aria-label="Toggle theme"
className={cn(
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
props.className,
)}
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
variant="ghost"
>
{theme === "light" ? (
<SunIcon className="size-5 " />
) : (
<MoonIcon className="size-5 " />
)}
</Button>
</ClientOnly>
);
}
@@ -3,6 +3,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { reportTokenRowClicked } from "@/analytics/report";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -122,6 +123,12 @@ export function TokensTable(props: {
className="gap-2 rounded-full bg-card hover:border-foreground/50 text-muted-foreground hover:bg-inverted hover:text-inverted-foreground"
>
<Link
onClick={() =>
reportTokenRowClicked({
chainId: token.chainId,
tokenAddress: token.address,
})
}
aria-label={`Buy ${token.symbol}`}
href={
token.address.toLowerCase() ===
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
import { getPlanCancelUrl } from "../../utils/billing";
export const dynamic = "force-dynamic";
export default async function CancelPlanPage(props: {
params: Promise<{
team_id: string;
@@ -7,6 +7,8 @@ import {
getInvoicePaymentUrl,
} from "../../../utils/billing";
export const dynamic = "force-dynamic";
export default async function CheckoutPage(props: {
params: Promise<{
team_slug: string;
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
import { getBillingPortalUrl } from "../../utils/billing";
export const dynamic = "force-dynamic";
export default async function ManageBillingPage(props: {
params: Promise<{
team_slug: string;
@@ -1,16 +1,7 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { AutoConnect } from "thirdweb/react";
import type { SmartWalletOptions } from "thirdweb/wallets";
export function TWAutoConnect(props: {
accountAbstraction?: SmartWalletOptions;
client: ThirdwebClient;
}) {
return (
<AutoConnect
accountAbstraction={props.accountAbstraction}
client={props.client}
/>
);
export function TWAutoConnect(props: { client: ThirdwebClient }) {
return <AutoConnect client={props.client} />;
}
@@ -76,7 +76,9 @@ export default async function DropPage({
]);
const thumbnail =
project.thumbnail || nft.metadata.image || contractMetadata.image || "";
project.thumbnail ||
nft.metadata.image ||
(typeof contractMetadata.image === "string" ? contractMetadata.image : "");
const displayName = contractMetadata.name || nft.metadata.name || "";
@@ -10,7 +10,6 @@ import {
HelpCircleIcon,
HomeIcon,
SettingsIcon,
WalletCardsIcon,
} from "lucide-react";
import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout";
@@ -18,11 +17,6 @@ export function TeamSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
ecosystems: Array<{ name: string; slug: string }>;
chainSubscriptions: Array<{
chainId: number;
chainName: string;
slug: string;
}>;
}) {
const { layoutPath, children } = props;
@@ -72,24 +66,6 @@ export function TeamSidebarLayout(props: {
{
separator: true,
} as const,
// infrastructure below here
// TODO: make this one link to an overview page that has a list of all the chains currently deployed
{
links: [
...props.chainSubscriptions.map((chainSubscription) => ({
href: `${layoutPath}/~/infrastructure/${chainSubscription.slug}`,
label: chainSubscription.chainName,
})),
{
href: `${layoutPath}/~/infrastructure/deploy`,
label: "Deploy Infrastructure",
},
],
subMenu: {
icon: WalletCardsIcon,
label: "Chain Infrastucture",
},
},
]}
footerSidebarLinks={[
{
@@ -4,13 +4,11 @@ import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProjects } from "@/api/project/projects";
import { fetchEcosystemList } from "@/api/team/ecosystems";
import { getTeamBySlug, getTeams } from "@/api/team/get-team";
import { getChainSubscriptions } from "@/api/team/team-subscription";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { siwaExamplePrompts } from "@/constants/siwa-example-prompts";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getChain } from "../../../(dashboard)/(chain)/utils";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
import { StaffModeNotice } from "./_components/StaffModeNotice";
import { TeamSidebarLayout } from "./TeamSidebarLayout";
@@ -21,52 +19,26 @@ export default async function TeamLayout(props: {
}) {
const params = await props.params;
const [
accountAddress,
account,
teams,
authToken,
team,
ecosystems,
chainSubscriptions,
] = await Promise.all([
getAuthTokenWalletAddress(),
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
getAuthToken(),
getTeamBySlug(params.team_slug),
fetchEcosystemList(params.team_slug),
getChainSubscriptions(params.team_slug),
]);
const [accountAddress, account, teams, authToken, team, ecosystems] =
await Promise.all([
getAuthTokenWalletAddress(),
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
getAuthToken(),
getTeamBySlug(params.team_slug),
fetchEcosystemList(params.team_slug),
]);
if (!teams || !accountAddress || !authToken || !team) {
redirect("/login");
}
const [teamsAndProjects, chainSidebarLinks] = await Promise.all([
Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
),
chainSubscriptions
? await Promise.all(
chainSubscriptions.map(async (chainSubscription) => {
if (!chainSubscription.chainId) {
throw new Error("Chain ID is required");
}
const chain = await getChain(chainSubscription.chainId);
return {
chainId: chain.chainId,
chainName: chain.name,
slug: chain.slug,
};
}),
).catch(() => [])
: [],
]);
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
projects: await getProjects(team.slug),
team,
})),
);
const client = getClientThirdwebClient({
jwt: authToken,
@@ -92,9 +64,6 @@ export default async function TeamLayout(props: {
</div>
<TeamSidebarLayout
chainSubscriptions={chainSidebarLinks.sort(
(a, b) => a.chainId - b.chainId,
)}
ecosystems={ecosystems.map((ecosystem) => ({
name: ecosystem.name,
slug: ecosystem.slug,
@@ -11,6 +11,7 @@ import {
useActiveWalletConnectionStatus,
} from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { appMetadata } from "@/constants/connect";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
@@ -65,11 +66,7 @@ export const NebulaConnectWallet = (props: {
const { customDetailsButton } = props;
return (
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
appMetadata={appMetadata}
autoConnect={false}
chains={allChainsV5}
client={props.client}
@@ -48,7 +48,11 @@ export function ProjectSidebarLayout(props: {
{
href: `${props.layoutPath}/transactions`,
icon: ArrowLeftRightIcon,
label: "Transactions",
label: (
<span className="flex items-center gap-2">
Transactions <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/contracts`,
@@ -81,11 +85,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: (
<span className="flex items-center gap-2">
Tokens <Badge>New</Badge>
</span>
),
label: "Tokens",
},
],
},
@@ -1,10 +1,10 @@
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { TransactionAnalyticsFilter } from "./filter";
import { TransactionsChartCard } from "./tx-chart/tx-chart";
import { TransactionsTable } from "./tx-table/tx-table";
export function TransactionsAnalyticsPageContent(props: {
searchParams: {
@@ -34,11 +34,10 @@ export function TransactionsAnalyticsPageContent(props: {
/>
</>
)}
<TransactionsTable
<UnifiedTransactionsTable
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
wallets={props.wallets}
/>
</div>
</ResponsiveSearchParamsProvider>
@@ -5,15 +5,19 @@ import type { Project } from "@/api/project/projects";
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
import { SendTestSolanaTransaction } from "./send-test-solana-tx.client";
import { SendTestTransaction } from "./send-test-tx.client";
interface Props {
managementAccessToken: string | undefined;
project: Project;
wallets: Wallet[];
solanaWallets: SolanaWallet[];
hasTransactions: boolean;
teamSlug: string;
testTxWithWallet?: string | undefined;
testSolanaTxWithWallet?: string | undefined;
client: ThirdwebClient;
isManagedVault: boolean;
}
@@ -85,6 +89,19 @@ export const EngineChecklist: React.FC<Props> = (props) => {
);
}
if (props.testSolanaTxWithWallet) {
return (
<SendTestSolanaTransaction
isManagedVault={props.isManagedVault}
client={props.client}
project={props.project}
teamSlug={props.teamSlug}
walletId={props.testSolanaTxWithWallet}
wallets={props.solanaWallets}
/>
);
}
if (finalSteps.length === 0 || isComplete) {
return null;
}
@@ -0,0 +1,343 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ExternalLinkIcon, Loader2Icon, LockIcon } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import * as z from "zod";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { TryItOut } from "../server-wallets/components/try-it-out";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
const formSchema = z.object({
secretKey: z.string().min(1, "Secret key is required"),
walletIndex: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
function SendTestSolanaTransactionModal(props: {
wallets?: SolanaWallet[];
project: Project;
teamSlug: string;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [, setTxChain] = useQueryState("txChain", {
history: "push",
});
const form = useForm<FormValues>({
defaultValues: {
secretKey: "",
walletIndex:
props.wallets && props.walletId
? props.wallets
.findIndex((w) => w.id === props.walletId)
?.toString()
.replace("-1", "0")
: "0",
},
resolver: zodResolver(formSchema),
});
const selectedWalletIndex = Number.parseInt(form.watch("walletIndex"));
const selectedWallet = props.wallets?.[selectedWalletIndex];
const sendDummySolanaTxMutation = useMutation({
mutationFn: async (args: { walletAddress: string; secretKey: string }) => {
const response = await engineCloudProxy({
body: JSON.stringify({
executionOptions: {
chainId: "solana:devnet",
signerAddress: args.walletAddress,
maxBlockhashRetries: 0,
commitment: "confirmed",
},
instructions: [
{
programId: "11111111111111111111111111111111", // System Program
accounts: [
{
pubkey: args.walletAddress,
isSigner: true,
isWritable: true,
},
{
pubkey: args.walletAddress,
isSigner: false,
isWritable: true,
},
],
data: "AgAAAAAAAAAAAAAAAA==", // Transfer 0 SOL (base64)
encoding: "base64",
},
],
}),
headers: {
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
"x-team-id": props.project.teamId,
...(props.isManagedVault
? { "x-secret-key": args.secretKey }
: { "x-vault-access-token": args.secretKey }),
},
method: "POST",
pathname: "/v1/solana/transaction",
});
if (!response.ok) {
const errorMsg = response.error ?? "Failed to send Solana transaction";
throw new Error(errorMsg);
}
return response.data;
},
onError: (error) => {
toast.error(error.message);
},
onSuccess: () => {
toast.success("Test Solana transaction sent successfully!");
// Set the transaction chain to solana so the table switches
setTxChain("solana");
// Close the modal after successful transaction
setTimeout(() => {
props.onOpenChange(false);
}, 1000);
},
});
const isLoading = sendDummySolanaTxMutation.isPending;
// Early return in render phase
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
return null;
}
const onSubmit = async (data: FormValues) => {
await sendDummySolanaTxMutation.mutateAsync({
secretKey: data.secretKey,
walletAddress: selectedWallet.publicKey,
});
queryClient.invalidateQueries({
queryKey: ["solana-transactions", props.project.id],
});
};
return (
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Send Test Solana Transaction</DialogTitle>
<DialogDescription>
Test your Solana server wallet by sending a 0 SOL transfer transaction
on Solana Devnet
</DialogDescription>
</DialogHeader>
{/* Funding Alert */}
<Alert>
<AlertDescription className="flex flex-col gap-2">
<p className="font-medium">⚠️ Fund your wallet first</p>
<p className="text-sm">
Your Solana wallet needs SOL on Devnet to pay for transaction fees.
Get free Devnet SOL from the Solana faucet:
</p>
<Link
href="https://faucet.solana.com/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-link-foreground hover:underline text-sm"
>
<ExternalLinkIcon className="size-4" />
https://faucet.solana.com/
</Link>
</AlertDescription>
</Alert>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-4">
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<p className="text-sm">Select Wallet</p>
<Select
disabled={props.wallets.length === 1}
value={form.watch("walletIndex")}
onValueChange={(value) => form.setValue("walletIndex", value)}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem
key={wallet.id}
value={index.toString()}
className="font-mono"
>
{wallet.metadata.label || `Wallet ${index + 1}`} (
{wallet.publicKey.slice(0, 6)}...
{wallet.publicKey.slice(-4)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Wallet Address Display */}
<div className="flex flex-col gap-2">
<p className="text-sm">Public Key</p>
<code className="text-xs font-mono bg-muted p-2 rounded break-all">
{selectedWallet.publicKey}
</code>
</div>
{/* Network Info */}
<div className="flex flex-col gap-2">
<p className="text-sm">Network</p>
<div className="flex items-center gap-2 bg-muted p-2 rounded">
<div className="size-4 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span className="text-sm">Solana Devnet</span>
</div>
</div>
{/* Secret Key Input */}
<div className="flex flex-col gap-2">
<p className="text-sm">
{props.isManagedVault ? "Secret Key" : "Vault Access Token"}
</p>
<div className="relative">
<Input
{...form.register("secretKey")}
className="pr-10 font-mono"
placeholder={
props.isManagedVault
? "Enter your secret key"
: "Enter your vault access token"
}
type="password"
/>
<LockIcon className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
{form.formState.errors.secretKey && (
<p className="text-destructive text-sm">
{form.formState.errors.secretKey.message}
</p>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
className="w-full md:w-auto"
onClick={() => props.onOpenChange(false)}
variant="secondary"
type="button"
>
Close
</Button>
<Button
className="w-full min-w-[200px] md:w-auto"
disabled={isLoading || !form.formState.isValid}
type="submit"
variant="primary"
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send Transaction"
)}
</Button>
</div>
</div>
</form>
</DialogContent>
);
}
export function SendTestSolanaTransaction(props: {
wallets?: SolanaWallet[];
project: Project;
teamSlug: string;
expanded?: boolean;
walletId?: string;
isManagedVault: boolean;
client: ThirdwebClient;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useDashboardRouter();
// Early return in render phase
if (!props.wallets || props.wallets.length === 0) {
return null;
}
return (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<TryItOut useEngineAPI={false} />
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="w-full md:w-auto">
Test Request
</Button>
</DialogTrigger>
<SendTestSolanaTransactionModal
{...props}
open={isModalOpen}
onOpenChange={setIsModalOpen}
/>
</Dialog>
<Button
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
);
} else {
localStorage.setItem("solanEngineFtuxCompleted", "true");
router.refresh();
}
}}
variant="primary"
>
{props.walletId ? "Close" : "Complete Setup"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
export type SolanaTransactionStatus =
| "QUEUED"
| "SUBMITTED"
| "CONFIRMED"
| "FAILED";
type SolanaTransactionParamsSerialized = {
instructions: Array<{
programId: string;
keys: Array<{
pubkey: string;
isSigner: boolean;
isWritable: boolean;
}>;
data: string;
}>;
};
type SolanaExecutionParamsSerialized = {
type: "SOLANA";
signerAddress: string;
chainId: string;
commitment?: "processed" | "confirmed" | "finalized";
computeUnitLimit?: number;
computeUnitPrice?: number;
skipPreflight?: boolean;
};
type SolanaExecutorError = {
errorCode?: string;
message?: string;
innerError?: unknown;
reason?: string;
[key: string]: unknown;
};
type SolanaTransactionMeta = {
err?: unknown | null;
status?: {
Ok?: unknown | null;
};
fee: number;
preBalances: number[];
postBalances: number[];
innerInstructions: unknown[];
logMessages: string[];
preTokenBalances: unknown[];
postTokenBalances: unknown[];
rewards: unknown[];
loadedAddresses: {
writable: string[];
readonly: string[];
};
computeUnitsConsumed?: number;
costUnits?: number;
};
type SolanaTransactionDetails = {
transaction: {
signatures: string[];
message: {
header: {
numRequiredSignatures: number;
numReadonlySignedAccounts: number;
numReadonlyUnsignedAccounts: number;
};
accountKeys: string[];
recentBlockhash: string;
instructions: Array<{
programIdIndex: number;
accounts: number[];
data: string;
stackHeight?: number;
}>;
addressTableLookups: unknown[];
};
};
meta: SolanaTransactionMeta;
version: number | "legacy";
};
type SolanaExecutionResultSerialized =
| {
status: "QUEUED";
}
| {
status: "FAILED";
error: SolanaExecutorError;
}
| {
status: "SUBMITTED";
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
signature: string;
submissionAttemptNumber: number;
}
| ({
status: "CONFIRMED";
signature: string;
signerAddress: string;
chainId: string;
submissionAttemptNumber: number;
slot: number;
blockTime: number | null;
transaction: SolanaTransactionDetails;
} & (
| {
onchainStatus: "SUCCESS";
}
| {
onchainStatus: "REVERTED";
error?: string;
}
));
export type SolanaTransaction = {
id: string;
clientId: string;
chainId: string;
signerAddress: string;
transactionParams: SolanaTransactionParamsSerialized;
signature: string | null;
status: SolanaTransactionStatus | null;
confirmedAt: Date | null;
confirmedAtSlot: string | null;
blockTime: number | null;
enrichedData: unknown[];
executionParams: SolanaExecutionParamsSerialized;
executionResult: SolanaExecutionResultSerialized | null;
createdAt: Date;
errorMessage: string | null;
cancelledAt: Date | null;
};
export type SolanaTransactionsResponse = {
transactions: SolanaTransaction[];
pagination: {
totalCount: number;
page: number;
limit: number;
};
};
@@ -1,447 +0,0 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useId, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "./types";
// TODO - add Status selector dropdown here
export function TransactionsTableUI(props: {
getData: (params: {
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}) => Promise<TransactionsResponse>;
project: Project;
teamSlug: string;
wallets?: Wallet[];
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<TransactionStatus | undefined>(
undefined,
);
const [id, setId] = useState<string | undefined>(undefined);
const [from, setFrom] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
const pageSize = 10;
const transactionsQuery = useQuery({
placeholderData: keepPreviousData,
queryFn: () => props.getData({ page, status, id, from }),
queryKey: ["transactions", props.project.id, page, status, id, from],
refetchInterval: autoUpdate ? 4_000 : false,
});
const transactions = transactionsQuery.data?.transactions ?? [];
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
const autoUpdateId = useId();
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="font-semibold text-2xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
<Label htmlFor={autoUpdateId}>Auto Update</Label>
<Switch
checked={autoUpdate}
id={autoUpdateId}
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<Input
className="max-w-[250px]"
onChange={(e) => {
const value = e.target.value.trim();
setId(value || undefined);
setPage(1);
}}
placeholder="Filter by Queue ID"
value={id || ""}
/>
<Input
className="max-w-[250px]"
onChange={(e) => {
const value = e.target.value.trim();
setFrom(value || undefined);
setPage(1);
}}
placeholder="Filter by wallet address"
value={from || ""}
/>
<div>
<StatusSelector
setStatus={(v) => {
setStatus(v);
// reset page
setPage(1);
}}
status={status}
/>
</div>
</div>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/transactions/tx/${tx.id}`,
);
}}
>
{/* Queue ID */}
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id ? tx.id : ""}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
{/* Chain Id */}
<TableCell>
<TxChainCell
chainId={tx.chainId || undefined}
client={props.client}
/>
</TableCell>
{/* Status */}
<TableCell>
<TxStatusCell transaction={tx} />
</TableCell>
{/* From Address */}
<TableCell>
{tx.from ? (
<WalletAddress
address={tx.from}
client={props.client}
/>
) : (
"N/A"
)}
</TableCell>
{/* Tx Hash */}
<TableCell>
<TxHashCell transaction={tx} />
</TableCell>
{/* Queued At */}
<TableCell>
<TxQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
);
}
export const statusDetails = {
CONFIRMED: {
name: "Confirmed",
type: "success",
},
FAILED: {
name: "Failed",
type: "destructive",
},
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
} as const;
function StatusSelector(props: {
status: TransactionStatus | undefined;
setStatus: (value: TransactionStatus | undefined) => void;
}) {
const statuses = Object.keys(statusDetails) as TransactionStatus[];
return (
<Select
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus);
}
}}
value={props.status || "all"}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
function TxChainCell(props: {
chainId: string | undefined;
client: ThirdwebClient;
}) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
function TxStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails[status].type}>
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
function TxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
textToShow={shortHash}
tooltip="Copy transaction hash"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
function TxQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return;
}
const date = new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
@@ -1,72 +0,0 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsTableUI } from "./tx-table-ui";
import type { TransactionStatus, TransactionsResponse } from "./types";
export function TransactionsTable(props: {
project: Project;
wallets?: Wallet[];
teamSlug: string;
client: ThirdwebClient;
}) {
return (
<TransactionsTableUI
client={props.client}
getData={async ({ page, status, id, from }) => {
return await getTransactions({
page,
project: props.project,
status,
id,
from,
});
}}
project={props.project}
teamSlug={props.teamSlug}
wallets={props.wallets}
/>
);
}
async function getTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}) {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
method: "GET",
pathname: `/v1/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
},
);
if (!transactions.ok) {
throw new Error(transactions.error);
}
return transactions.data.result;
}
@@ -97,3 +97,22 @@ export type TransactionsResponse = {
transactions: Transaction[];
pagination: Pagination;
};
export const statusDetails = {
CONFIRMED: {
name: "Confirmed",
type: "success",
},
FAILED: {
name: "Failed",
type: "destructive",
},
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
} as const;
@@ -0,0 +1,774 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
CheckIcon,
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { updateDefaultProjectWallet } from "../lib/vault.client";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client";
import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
type WalletChain = "evm" | "solana";
interface ServerWalletsTableProps {
evmWallets: EVMWallet[];
evmTotalRecords: number;
evmCurrentPage: number;
evmTotalPages: number;
solanaWallets: SolanaWallet[];
solanaTotalRecords: number;
solanaCurrentPage: number;
solanaTotalPages: number;
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
client: ThirdwebClient;
solanaPermissionError?: boolean;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
const {
evmWallets,
solanaWallets,
project,
teamSlug,
managementAccessToken,
evmTotalRecords,
evmCurrentPage,
evmTotalPages,
solanaTotalRecords,
solanaCurrentPage,
solanaTotalPages,
client,
solanaPermissionError,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
const wallets = activeChain === "evm" ? evmWallets : solanaWallets;
const currentPage =
activeChain === "evm" ? evmCurrentPage : solanaCurrentPage;
const totalPages = activeChain === "evm" ? evmTotalPages : solanaTotalPages;
const totalRecords =
activeChain === "evm" ? evmTotalRecords : solanaTotalRecords;
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for your project
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="flex flex-row gap-2.5">
{activeChain === "evm" && (
<>
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
<SingleNetworkSelector
chainId={selectedChainId}
onChange={setSelectedChainId}
client={client}
disableChainId
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
/>
</>
)}
{activeChain === "solana" && (
<CreateSolanaWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
disabled={solanaPermissionError}
/>
)}
</div>
{activeChain === "evm" && (
<div className="flex items-center gap-2.5">
<Label
className="text-sm text-muted-foreground"
htmlFor="showSmartAccount"
>
Show ERC4337 Smart Account
</Label>
<Switch
id="showSmartAccount"
checked={showSmartAccount}
onCheckedChange={setShowSmartAccount}
size="sm"
/>
</div>
)}
</div>
</div>
{/* Chain Tabs */}
<div className="px-6">
<TabButtons
tabs={[
{
name: (
<span className="flex items-center gap-2">
EVM Wallets
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{evmTotalRecords}
</Badge>
</span>
),
onClick: () => setActiveChain("evm"),
isActive: activeChain === "evm",
},
{
name: (
<span className="flex items-center gap-2">
Solana Wallets
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{solanaTotalRecords}
</Badge>
</span>
),
onClick: () => setActiveChain("solana"),
isActive: activeChain === "solana",
},
]}
/>
</div>
{/* Table Content */}
{activeChain === "solana" && solanaPermissionError ? (
<div className="p-6">
<UpgradeSolanaPermissions project={project} />
</div>
) : (
<>
<TableContainer className="rounded-none border-x-0 border-b-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead className="w-[280px]">
{activeChain === "evm"
? showSmartAccount
? "Smart Account Address"
: "Wallet Address"
: "Public Key"}
</TableHead>
<TableHead className="min-w-[80px]">
<div className="flex w-[180px] items-center gap-1.5">
Balance
{wallets.length > 0 && (
<ToolTipLabel
contentClassName="capitalize font-normal tracking-normal leading-normal"
label="Refresh Balance"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey:
activeChain === "evm"
? ["walletBalance", selectedChainId]
: ["solanaWalletBalance"],
});
buttonEl.setAttribute("data-pending", "false");
}}
size="sm"
variant="ghost"
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
</div>
</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeChain === "evm" &&
evmWallets.map((wallet) => (
<EVMWalletRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
chainId={selectedChainId}
showSmartAccount={showSmartAccount}
/>
))}
{activeChain === "solana" &&
solanaWallets.map((wallet) => (
<SolanaWalletRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
/>
))}
</TableBody>
</Table>
{wallets.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
<div className="p-2 rounded-full bg-background border border-border">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground">
No {activeChain === "evm" ? "EVM" : "Solana"} wallets found
</p>
</div>
)}
</TableContainer>
{totalPages > 1 && (
<WalletsPagination
activeChain={activeChain}
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
teamSlug={teamSlug}
projectSlug={project.slug}
/>
)}
</>
)}
</div>
</div>
);
}
// Wallets Pagination Component
function WalletsPagination({
activeChain,
currentPage,
totalPages,
totalRecords,
teamSlug,
projectSlug,
}: {
activeChain: WalletChain;
currentPage: number;
totalPages: number;
totalRecords: number;
teamSlug: string;
projectSlug: string;
}) {
const pageParam = activeChain === "evm" ? "page" : "solana_page";
return (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
// EVM Wallet Row Component
function EVMWalletRow({
wallet,
project,
teamSlug,
client,
chainId,
showSmartAccount,
}: {
wallet: EVMWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
showSmartAccount: boolean;
}) {
const chain = useV5DashboardChain(chainId);
const smartAccountQuery = useQuery({
queryFn: async () => {
return await predictSmartAccountAddress({
adminAddress: wallet.address,
chain: chain,
client: client,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
},
enabled: showSmartAccount,
queryKey: ["smart-account-address", wallet.address, chainId],
});
const engineService = project.services.find((s) => s.name === "engineCloud");
const isDefault =
engineService?.projectWalletAddress &&
wallet.address.toLowerCase() ===
engineService.projectWalletAddress.toLowerCase();
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefault && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</TableCell>
<TableCell>
{showSmartAccount ? (
smartAccountQuery.isPending ? (
<Skeleton className="h-7 w-24" />
) : smartAccountQuery.data ? (
<WalletAddress address={smartAccountQuery.data} client={client} />
) : (
<span className="text-muted-foreground">N/A</span>
)
) : (
<WalletAddress address={wallet.address} client={client} />
)}
</TableCell>
<TableCell>
{showSmartAccount ? (
smartAccountQuery.isPending ? (
<Skeleton className="h-5 w-16" />
) : smartAccountQuery.data ? (
<WalletBalance
address={smartAccountQuery.data}
chainId={chainId}
client={client}
/>
) : (
<span className="text-muted-foreground">N/A</span>
)
) : (
<WalletBalance
address={wallet.address}
chainId={chainId}
client={client}
/>
)}
</TableCell>
<TableCell>
<DateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<EVMWalletActions
project={project}
teamSlug={teamSlug}
wallet={wallet}
client={client}
chainId={chainId}
fundAddress={
showSmartAccount ? smartAccountQuery.data : wallet.address
}
/>
</TableCell>
</TableRow>
);
}
// Solana Wallet Row Component
function SolanaWalletRow({
wallet,
project,
teamSlug,
client,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
}) {
const engineService = project.services.find(
(s) => s.name === "engineCloud",
) as { projectSolanaWalletPublicKey?: string } | undefined;
const isDefault =
engineService?.projectSolanaWalletPublicKey &&
wallet.publicKey.toLowerCase() ===
engineService.projectSolanaWalletPublicKey.toLowerCase();
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefault && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</TableCell>
<TableCell>
<SolanaAddress address={wallet.publicKey} shortenAddress={true} />
</TableCell>
<TableCell>
<SolanaWalletBalance publicKey={wallet.publicKey} />
</TableCell>
<TableCell>
<DateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<SolanaWalletActions
project={project}
teamSlug={teamSlug}
wallet={wallet}
client={client}
/>
</TableCell>
</TableRow>
);
}
// Shared Components
function DateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function EVMWalletActions({
wallet,
project,
teamSlug,
client,
chainId,
fundAddress,
}: {
wallet: EVMWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
fundAddress: string | undefined;
}) {
const [showFundModal, setShowFundModal] = useState(false);
const router = useDashboardRouter();
const setDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project,
projectWalletAddress: wallet.address,
});
},
onSuccess: () => {
toast.success("Wallet set as project wallet");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to set default wallet",
);
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?testTxWithWallet=${wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
{fundAddress && (
<DropdownMenuItem
onClick={() => setShowFundModal(true)}
className="flex items-center gap-2 h-9 rounded-lg"
>
<WalletProductIcon className="size-4 text-muted-foreground" />
Fund wallet
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDefaultMutation.mutate()}
disabled={setDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{fundAddress && (
<FundWalletModal
open={showFundModal}
onOpenChange={setShowFundModal}
title="Fund server wallet"
description="Send funds to the server wallet"
recipientAddress={fundAddress}
client={client}
defaultChainId={chainId}
checkoutWidgetTitle={
wallet.metadata.label
? `Fund ${wallet.metadata.label}`
: "Fund server wallet"
}
/>
)}
</>
);
}
function SolanaWalletActions({
wallet,
project,
teamSlug,
}: {
wallet: SolanaWallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?testSolanaTxWithWallet=${wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function WalletBalance({
address,
chainId,
client,
}: {
address: string;
chainId: number;
client: ThirdwebClient;
}) {
const chain = useV5DashboardChain(chainId);
const balance = useWalletBalance({
address,
chain,
client,
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
const balance = useQuery({
queryFn: async () => {
// TODO: Implement actual Solana balance fetching
return {
displayValue: "0",
symbol: "SOL",
};
},
queryKey: ["solanaWalletBalance", publicKey],
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
@@ -0,0 +1,877 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { useId, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/project/projects";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type {
SolanaTransaction,
SolanaTransactionStatus,
SolanaTransactionsResponse,
} from "../analytics/solana-tx-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "../analytics/tx-table/types";
import { getSolanaNetworkName, getSolscanUrl } from "../lib/solana-utils";
type TransactionChain = "evm" | "solana";
interface UnifiedTransactionsTableProps {
project: Project;
teamSlug: string;
client: ThirdwebClient;
}
export function UnifiedTransactionsTable({
project,
teamSlug,
client,
}: UnifiedTransactionsTableProps) {
const router = useDashboardRouter();
// Use nuqs to manage chain selection in URL - this is the source of truth
const [activeChainParam, setActiveChainParam] = useQueryState("txChain", {
defaultValue: "evm",
history: "push",
});
const activeChain = (activeChainParam || "evm") as TransactionChain;
const setActiveChain = (chain: TransactionChain) =>
setActiveChainParam(chain);
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<
TransactionStatus | SolanaTransactionStatus | undefined
>(undefined);
const [id, setId] = useState<string | undefined>(undefined);
const [from, setFrom] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
// Fetch EVM transactions
const evmTransactionsQuery = useQuery({
enabled: activeChain === "evm",
placeholderData: keepPreviousData,
queryFn: () =>
getEVMTransactions({
page,
project,
status: status as TransactionStatus | undefined,
id,
from,
}),
queryKey: ["evm-transactions", project.id, page, status, id, from],
refetchInterval: autoUpdate && activeChain === "evm" ? 4_000 : false,
});
// Fetch Solana transactions
const solanaTransactionsQuery = useQuery({
enabled: activeChain === "solana",
placeholderData: keepPreviousData,
queryFn: () =>
getSolanaTransactions({
page,
project,
status: status as SolanaTransactionStatus | undefined,
id,
from,
}),
queryKey: ["solana-transactions", project.id, page, status, id, from],
refetchInterval: autoUpdate && activeChain === "solana" ? 4_000 : false,
});
const autoUpdateId = useId();
return (
<div className="overflow-hidden rounded-xl border border-border bg-card">
{/* Unified Header & Filters */}
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="font-semibold text-2xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
<Label htmlFor={autoUpdateId}>Auto Update</Label>
<Switch
checked={autoUpdate}
id={autoUpdateId}
onCheckedChange={(v) => setAutoUpdate(!!v)}
/>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
<Input
className="w-full sm:max-w-[200px]"
onChange={(e) => {
const value = e.target.value.trim();
setId(value || undefined);
setPage(1);
}}
placeholder="Filter by Queue ID"
value={id || ""}
/>
<Input
className="w-full sm:max-w-[200px]"
onChange={(e) => {
const value = e.target.value.trim();
setFrom(value || undefined);
setPage(1);
}}
placeholder="Filter by wallet address"
value={from || ""}
/>
<div className="flex gap-3">
<ChainSelector
activeChain={activeChain}
setActiveChain={(chain) => {
setActiveChain(chain);
setPage(1);
setStatus(undefined);
}}
/>
<StatusSelector
activeChain={activeChain}
setStatus={(v) => {
setStatus(v);
setPage(1);
}}
status={status}
/>
</div>
</div>
</div>
{/* Render different table structures based on chain */}
{activeChain === "evm" ? (
<EVMTransactionsTable
client={client}
page={page}
project={project}
query={evmTransactionsQuery}
router={router}
setPage={setPage}
teamSlug={teamSlug}
/>
) : (
<SolanaTransactionsTable
page={page}
project={project}
query={solanaTransactionsQuery}
router={router}
setPage={setPage}
teamSlug={teamSlug}
/>
)}
</div>
);
}
// EVM Transactions Table Component
function EVMTransactionsTable(props: {
query: ReturnType<typeof useQuery<TransactionsResponse>>;
page: number;
setPage: (page: number) => void;
client: ThirdwebClient;
router: ReturnType<typeof useDashboardRouter>;
teamSlug: string;
project: Project;
}) {
const { query, page, setPage, client, router, teamSlug, project } = props;
const pageSize = 10;
const transactions = query.data?.transactions ?? [];
const totalCount = query.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(query.isPlaceholderData && query.isFetching) ||
(query.isLoading && !query.isPlaceholderData);
return (
<>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
);
}}
>
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
<TableCell>
<EVMChainCell
chainId={tx.chainId || undefined}
client={client}
/>
</TableCell>
<TableCell>
<EVMStatusCell transaction={tx} />
</TableCell>
<TableCell>
{tx.from ? (
<WalletAddress address={tx.from} client={client} />
) : (
"N/A"
)}
</TableCell>
<TableCell>
<EVMTxHashCell transaction={tx} />
</TableCell>
<TableCell>
<EVMQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</>
);
}
// Solana Transactions Table Component
function SolanaTransactionsTable(props: {
query: ReturnType<typeof useQuery<SolanaTransactionsResponse>>;
page: number;
setPage: (page: number) => void;
router: ReturnType<typeof useDashboardRouter>;
teamSlug: string;
project: Project;
}) {
const { query, page, setPage, router, teamSlug, project } = props;
const pageSize = 10;
const transactions = query.data?.transactions ?? [];
const totalCount = query.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(query.isPlaceholderData && query.isFetching) ||
(query.isLoading && !query.isPlaceholderData);
return (
<>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Network</TableHead>
<TableHead>Status</TableHead>
<TableHead>Signer</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton
? new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={i} />
))
: transactions.map((tx) => (
<TableRow
className="cursor-pointer hover:bg-accent/50"
key={`${tx.id}${tx.chainId}`}
onClick={() => {
router.push(
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
);
}}
>
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id}
className="text-muted-foreground"
copyIconPosition="left"
variant="ghost"
/>
</TableCell>
<TableCell>
<SolanaChainCell chainId={tx.chainId} />
</TableCell>
<TableCell>
<SolanaStatusCell transaction={tx} />
</TableCell>
<TableCell>
<SolanaAddress
address={tx.signerAddress}
shortenAddress={true}
/>
</TableCell>
<TableCell>
<SolanaTxHashCell transaction={tx} />
</TableCell>
<TableCell>
<SolanaQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</>
);
}
// Chain selector component
function ChainSelector(props: {
activeChain: TransactionChain;
setActiveChain: (chain: TransactionChain) => void;
}) {
return (
<Select
onValueChange={(v) => props.setActiveChain(v as TransactionChain)}
value={props.activeChain}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="Select Chain" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="evm">EVM</SelectItem>
<SelectItem value="solana">Solana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}
// Status selector component
const evmStatusDetails = {
CONFIRMED: { name: "Confirmed", type: "success" as const },
FAILED: { name: "Failed", type: "destructive" as const },
QUEUED: { name: "Queued", type: "warning" as const },
SUBMITTED: { name: "Submitted", type: "warning" as const },
};
const solanaStatusDetails = {
CONFIRMED: { name: "Confirmed", type: "success" as const },
FAILED: { name: "Failed", type: "destructive" as const },
QUEUED: { name: "Queued", type: "warning" as const },
SUBMITTED: { name: "Submitted", type: "warning" as const },
};
function StatusSelector(props: {
activeChain: TransactionChain;
status: TransactionStatus | SolanaTransactionStatus | undefined;
setStatus: (
value: TransactionStatus | SolanaTransactionStatus | undefined,
) => void;
}) {
const statusDetails =
props.activeChain === "evm" ? evmStatusDetails : solanaStatusDetails;
const statuses = Object.keys(statusDetails) as (
| TransactionStatus
| SolanaTransactionStatus
)[];
return (
<Select
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus | SolanaTransactionStatus);
}
}}
value={props.status || "all"}
>
<SelectTrigger className="min-w-[160px] rounded-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
// Skeleton row component
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
// EVM Chain cell component
function EVMChainCell(props: {
chainId: string | undefined;
client: ThirdwebClient;
}) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
// Solana Chain cell component
function SolanaChainCell(props: { chainId: string | undefined }) {
const { chainId } = props;
if (!chainId) {
return "N/A";
}
const network = getSolanaNetworkName(chainId);
const displayName = network.charAt(0).toUpperCase() + network.slice(1);
return (
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
<span>Solana {displayName}</span>
</div>
);
}
// EVM Status cell component
function EVMStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
const statusDetails = evmStatusDetails[status] || {
name: status,
type: "default" as const,
};
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails.type}>
{statusDetails.name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
// Solana Status cell component
function SolanaStatusCell(props: { transaction: SolanaTransaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const status =
(transaction.executionResult?.status as SolanaTransactionStatus) ??
transaction.status ??
"QUEUED";
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && transaction.confirmedAt
? `Completed ${format(new Date(transaction.confirmedAt), "PP pp")}`
: undefined;
const statusDetails = solanaStatusDetails[status] || {
name: status || "QUEUED",
type: "default" as const,
};
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge className="gap-2" variant={statusDetails.type}>
{statusDetails.name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
// EVM Tx Hash cell component
function EVMTxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={transactionHash}
textToShow={shortHash}
tooltip="Copy transaction hash"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
// Solana Tx Hash cell component
function SolanaTxHashCell(props: { transaction: SolanaTransaction }) {
const { chainId, signature, executionResult } = props.transaction;
// Get signature from executionResult if available, otherwise use top-level signature
let hash = signature;
if (
executionResult &&
"signature" in executionResult &&
executionResult.signature
) {
hash = executionResult.signature;
}
if (!hash) {
return "N/A";
}
const shortHash = `${hash.slice(0, 6)}...${hash.slice(-4)}`;
const explorerUrl = chainId ? getSolscanUrl(hash, chainId) : null;
if (!explorerUrl) {
return (
<CopyTextButton
className="font-mono text-muted-foreground text-sm"
copyIconPosition="left"
textToCopy={hash}
textToShow={shortHash}
tooltip="Copy transaction signature"
variant="ghost"
/>
);
}
return (
<Button
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
variant="ghost"
>
<Link
href={explorerUrl}
onClick={(e) => {
e.stopPropagation();
}}
rel="noopener noreferrer"
target="_blank"
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
// EVM Queued At cell component
function EVMQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
// Solana Queued At cell component
function SolanaQueuedAtCell(props: { transaction: SolanaTransaction }) {
const value = props.transaction.createdAt;
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
async function getEVMTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: TransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}): Promise<TransactionsResponse> {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
},
method: "GET",
pathname: `/v1/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
},
);
if (!transactions.ok) {
return {
transactions: [],
pagination: {
totalCount: 0,
page: 1,
limit: 20,
},
};
}
return transactions.data.result;
}
async function getSolanaTransactions({
project,
page,
status,
id,
from,
}: {
project: Project;
page: number;
status: SolanaTransactionStatus | undefined;
id: string | undefined;
from: string | undefined;
}): Promise<SolanaTransactionsResponse> {
const transactions = await engineCloudProxy<{
result: SolanaTransactionsResponse;
}>({
headers: {
"Content-Type": "application/json",
"x-client-id": project.publishableKey,
"x-team-id": project.teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "GET",
pathname: `/v1/solana/transactions`,
searchParams: {
limit: "20",
page: page.toString(),
status: status ?? undefined,
id: id ?? undefined,
from: from ?? undefined,
},
});
if (!transactions.ok) {
return {
transactions: [],
pagination: {
totalCount: 0,
page: 1,
limit: 20,
},
};
}
return transactions.data.result;
}
@@ -205,6 +205,64 @@ export async function getSingleTransaction({
return data.transactions[0];
}
export async function getSingleSolanaTransaction({
teamId,
clientId,
transactionId,
}: {
teamId: string;
clientId: string;
transactionId: string;
}): Promise<
import("../analytics/solana-tx-table/types").SolanaTransaction | undefined
> {
const authToken = await getAuthToken();
const filters = {
filters: [
{
field: "id",
operation: "OR",
values: [transactionId],
},
],
};
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/search`,
{
body: JSON.stringify(filters),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "POST",
},
);
if (!response.ok) {
if (response.status === 401) {
return undefined;
}
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(
`Error fetching single Solana transaction data: ${response.status} ${
response.statusText
} - ${errorText}`,
);
}
const rawData = await response.json();
const data =
rawData.result as import("../analytics/solana-tx-table/types").SolanaTransactionsResponse;
return data.transactions[0];
}
// Activity log types
export type ActivityLogEntry = {
id: string;
@@ -260,18 +318,45 @@ export async function getTransactionActivityLogs({
);
if (!response.ok) {
if (response.status === 401) {
if (response.status === 401 || response.status === 404) {
return [];
}
return [];
}
const data = (await response.json()) as ActivityLogsResponse;
return data.result.activityLogs;
}
export async function getSolanaTransactionActivityLogs({
teamId,
clientId,
transactionId,
}: {
teamId: string;
clientId: string;
transactionId: string;
}): Promise<ActivityLogEntry[]> {
const authToken = await getAuthToken();
const response = await fetch(
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/activity-logs?transactionId=${transactionId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-team-id": teamId,
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
},
method: "GET",
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 404) {
return [];
}
// Don't throw on 404 - activity logs might not exist for all transactions
if (response.status === 404) {
return [];
}
console.error(
`Error fetching activity logs: ${response.status} ${response.statusText}`,
);
return [];
}
@@ -0,0 +1,29 @@
/**
* Get the Solscan explorer URL for a given transaction signature and chain ID
* @param signature - The transaction signature
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
* @returns The Solscan URL for the transaction
*/
export function getSolscanUrl(signature: string, chainId: string): string {
const network = chainId.split(":")[1] || "mainnet";
// Solscan uses different subdomains for different networks
switch (network.toLowerCase()) {
case "devnet":
return `https://solscan.io/tx/${signature}?cluster=devnet`;
case "testnet":
return `https://solscan.io/tx/${signature}?cluster=testnet`;
default:
return `https://solscan.io/tx/${signature}`;
}
}
/**
* Get the display network name from a Solana chain ID (capitalized)
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
* @returns The capitalized network name for display (e.g., "Devnet")
*/
export function getSolanaNetworkName(chainId: string): string {
const network = chainId.split(":")[1] || "mainnet";
return network.charAt(0).toUpperCase() + network.slice(1);
}
@@ -1,6 +1,6 @@
"use client";
import { encrypt } from "@thirdweb-dev/service-utils";
import { decrypt, encrypt } from "@thirdweb-dev/service-utils";
import {
createAccessToken,
createEoa,
@@ -514,29 +514,6 @@ export async function createWalletAccessToken(props: {
},
type: "eoa:signStructuredMessage",
},
{
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
type: "eoa:read",
},
{
requiredMetadataPatterns: [
{
@@ -795,6 +772,187 @@ async function createManagementAccessToken(props: {
});
}
/**
* Upgrades existing access tokens to include Solana permissions
* This is needed when a project was created before Solana support was added
*
* Returns an object with success/error instead of throwing for Next.js server actions
*/
export async function upgradeAccessTokensForSolana(props: {
project: Project;
projectSecretKey?: string;
}): Promise<{
success: boolean;
error?: string;
data?: {
managementToken: string;
walletToken?: string;
};
}> {
const { project, projectSecretKey } = props;
try {
// Find the engineCloud service
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
if (!engineCloudService) {
return {
success: false,
error: "No engineCloud service found on project",
};
}
const vaultClient = await initVaultClient();
const hasEncryptedAdminKey = !!engineCloudService.encryptedAdminKey;
// Check if this is an ejected vault (no encrypted admin key stored)
if (!hasEncryptedAdminKey) {
// For ejected vaults, we only need to update the management token
// User manages their own admin key, so we can't create wallet tokens
// We need the admin key from the user
if (!projectSecretKey) {
return {
success: false,
error: "Admin key required. Please enter your vault admin key.",
};
}
// For ejected vault, the "secret key" parameter is actually the admin key
const managementTokenResult = await createManagementAccessToken({
project,
adminKey: projectSecretKey,
vaultClient,
});
if (!managementTokenResult.success) {
return {
success: false,
error: `Failed to create management token: ${managementTokenResult.error}`,
};
}
// Update only the management token for ejected vaults
// Keep everything else the same (no encrypted keys to update)
await updateProjectClient(
{
projectId: project.id,
teamId: project.teamId,
},
{
services: [
...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
...engineCloudService,
managementAccessToken: managementTokenResult.data.accessToken,
},
],
},
);
return {
success: true,
data: {
managementToken: managementTokenResult.data.accessToken,
},
};
}
// For non-ejected vaults (with encrypted admin key)
if (!projectSecretKey) {
return {
success: false,
error: "Project secret key is required to upgrade tokens",
};
}
// Verify the project secret key
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
if (!project.secretKeys.some((key) => key?.hash === projectSecretKeyHash)) {
return {
success: false,
error: "Invalid project secret key",
};
}
// Decrypt the admin key (we know it exists from the hasEncryptedAdminKey check)
const adminKey = await decrypt(
engineCloudService.encryptedAdminKey as string,
projectSecretKey,
);
// Create new tokens with Solana permissions
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
createWalletAccessToken({ project, adminKey, vaultClient }),
]);
if (!managementTokenResult.success) {
return {
success: false,
error: `Failed to create management token: ${managementTokenResult.error}`,
};
}
if (!walletTokenResult.success) {
return {
success: false,
error: `Failed to create wallet token: ${walletTokenResult.error}`,
};
}
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
// Encrypt the new wallet token
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
// Update the project with new tokens
await updateProjectClient(
{
projectId: project.id,
teamId: project.teamId,
},
{
services: [
...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
...engineCloudService,
managementAccessToken: managementToken.accessToken,
encryptedAdminKey,
encryptedWalletAccessToken,
},
],
},
);
return {
success: true,
data: {
managementToken: managementToken.accessToken,
walletToken: walletToken.accessToken,
},
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to upgrade access tokens",
};
}
}
export function maskSecret(secret: string) {
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
}
@@ -9,9 +9,13 @@ 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 { ServerWalletsTable } from "./components/server-wallets-table.client";
import { getTransactionAnalyticsSummary } from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
import { ServerWalletsTable } from "./server-wallets/wallet-table/wallet-table";
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
export const dynamic = "force-dynamic";
export default async function TransactionsAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -20,7 +24,9 @@ export default async function TransactionsAnalyticsPage(props: {
to?: string | string[] | undefined;
interval?: string | string[] | undefined;
testTxWithWallet?: string | string[] | undefined;
testSolanaTxWithWallet?: string | string[] | undefined;
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
@@ -58,6 +64,7 @@ export default async function TransactionsAnalyticsPage(props: {
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
@@ -77,6 +84,32 @@ export default async function TransactionsAnalyticsPage(props: {
const wallets = eoas.data?.items as Wallet[] | undefined;
// Fetch Solana accounts - gracefully handle permission errors
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check if error is a permission error
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const initialData = await getTransactionAnalyticsSummary({
clientId: project.publishableKey,
teamId: project.teamId,
@@ -127,39 +160,63 @@ export default async function TransactionsAnalyticsPage(props: {
project={project}
teamSlug={params.team_slug}
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
testSolanaTxWithWallet={
searchParams.testSolanaTxWithWallet as string | undefined
}
wallets={wallets ?? []}
solanaWallets={solanaAccounts.data.items}
/>
{hasTransactions && !searchParams.testTxWithWallet && (
<TransactionAnalyticsSummary
clientId={project.publishableKey}
initialData={initialData}
teamId={project.teamId}
/>
)}
{hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet && (
<TransactionAnalyticsSummary
clientId={project.publishableKey}
initialData={initialData}
teamId={project.teamId}
/>
)}
{/* transactions */}
<TransactionsAnalyticsPageContent
client={client}
project={project}
searchParams={searchParams}
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
showAnalytics={
hasTransactions &&
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet
}
teamSlug={params.team_slug}
wallets={wallets}
/>
{/* server wallets */}
{/* Server Wallets (EVM + Solana) */}
{eoas.error ? (
<div>Error: {eoas.error.message}</div>
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
currentPage={currentPage}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
managementAccessToken={managementAccessToken ?? undefined}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
totalRecords={eoas.data.totalRecords}
wallets={eoas.data.items as Wallet[]}
solanaPermissionError={isSolanaPermissionError}
/>
)}
</div>
@@ -1,519 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
CheckIcon,
MoreVerticalIcon,
RefreshCcwIcon,
SendIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { updateDefaultProjectWallet } from "../../lib/vault.client";
import CreateServerWallet from "../components/create-server-wallet.client";
import type { Wallet } from "./types";
export function ServerWalletsTableUI({
wallets,
project,
teamSlug,
managementAccessToken,
totalRecords,
currentPage,
totalPages,
client,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
client: ThirdwebClient;
}) {
const [selectedChainId, setSelectedChainId] = useState<number>(1);
const [showSmartAccount, setShowSmartAccount] = useState(false);
const queryClient = useQueryClient();
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
<div>
<div className="flex mb-3">
<div className="p-2 rounded-full bg-background border border-border">
<WalletProductIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for your project
</p>
</div>
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
<div className="flex flex-row gap-2.5">
<CreateServerWallet
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
/>
<SingleNetworkSelector
chainId={selectedChainId}
onChange={setSelectedChainId}
client={client}
disableChainId
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
placeholder="Select network"
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
/>
</div>
<div className="flex items-center gap-2.5">
<Label
className="text-sm text-muted-foreground"
htmlFor="showSmartAccount"
>
Show ERC4337 Smart Account
</Label>
<Switch
id="showSmartAccount"
checked={showSmartAccount}
onCheckedChange={setShowSmartAccount}
size="sm"
/>
</div>
</div>
</div>
<TableContainer className="rounded-none border-x-0 border-b-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead className="w-[280px]">
{showSmartAccount
? "Smart Account Address"
: "Wallet Address"}
</TableHead>
<TableHead className="min-w-[80px]">
<div className="flex w-[180px] items-center gap-1.5">
Balance
{wallets.length > 0 && (
<ToolTipLabel
contentClassName="capitalize font-normal tracking-normal leading-normal"
label="Refresh Balance"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey: ["walletBalance", selectedChainId],
});
buttonEl.setAttribute("data-pending", "false");
}}
size="sm"
variant="ghost"
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
</div>
</TableHead>
<TableHead>Created </TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wallets.map((wallet) => (
<ServerWalletTableRow
key={wallet.id}
wallet={wallet}
project={project}
teamSlug={teamSlug}
client={client}
chainId={selectedChainId}
showSmartAccount={showSmartAccount}
/>
))}
</TableBody>
</Table>
{wallets.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
<div className="p-2 rounded-full bg-background border border-border">
<XIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No server wallets found</p>
</div>
)}
</TableContainer>
{totalPages > 1 && (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} server wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div>
);
}
function ServerWalletTableRow(props: {
wallet: Wallet;
project: Project;
teamSlug: string;
client: ThirdwebClient;
chainId: number;
showSmartAccount: boolean;
}) {
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
props;
const chain = useV5DashboardChain(chainId);
const smartAccountAddressQuery = useQuery({
queryFn: async () => {
const smartAccountAddress = await predictSmartAccountAddress({
adminAddress: wallet.address,
chain: chain,
client: client,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
return smartAccountAddress;
},
enabled: showSmartAccount,
queryKey: ["smart-account-address", wallet.address, chainId],
});
// Get the project wallet address
const engineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const defaultWalletAddress = engineCloudService?.projectWalletAddress;
const isDefaultWallet =
defaultWalletAddress &&
wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase();
return (
<TableRow key={wallet.id}>
{/* Label */}
<TableCell>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm text-foreground",
!wallet.metadata.label && "text-muted-foreground",
)}
>
{wallet.metadata.label || "N/A"}
</span>
{isDefaultWallet && (
<Badge variant="success" className="text-xs">
default
</Badge>
)}
</div>
</TableCell>
{/* Address */}
<TableCell>
{showSmartAccount ? (
<div>
{smartAccountAddressQuery.data ? (
<WalletAddress
address={smartAccountAddressQuery.data}
client={client}
/>
) : smartAccountAddressQuery.isPending ? (
<Skeleton className="h-7 w-24" />
) : (
<span className="text-muted-foreground">N/A</span>
)}
</div>
) : (
<WalletAddress address={wallet.address} client={client} />
)}
</TableCell>
{/* Balance */}
<TableCell>
{showSmartAccount && (
// biome-ignore lint/complexity/noUselessFragments: keep for readability
<>
{smartAccountAddressQuery.isPending ? (
<Skeleton className="h-5 w-16" />
) : smartAccountAddressQuery.data ? (
<WalletBalanceCell
address={smartAccountAddressQuery.data}
chainId={chainId}
client={client}
/>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</>
)}
{!showSmartAccount && (
<WalletBalanceCell
address={wallet.address}
chainId={chainId}
client={client}
/>
)}
</TableCell>
<TableCell>
<WalletDateCell date={wallet.createdAt} />
</TableCell>
<TableCell>
<WalletActionsDropdown
project={project}
teamSlug={teamSlug}
fundWalletAddress={
showSmartAccount ? smartAccountAddressQuery.data : wallet.address
}
wallet={wallet}
client={client}
chainId={chainId}
/>
</TableCell>
</TableRow>
);
}
function WalletDateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function WalletActionsDropdown(props: {
fundWalletAddress: string | undefined;
wallet: Wallet;
teamSlug: string;
project: Project;
client: ThirdwebClient;
chainId: number;
}) {
const [showFundModal, setShowFundModal] = useState(false);
const router = useDashboardRouter();
const setAsDefaultMutation = useMutation({
mutationFn: async () => {
await updateDefaultProjectWallet({
project: props.project,
projectWalletAddress: props.wallet.address,
});
},
onSuccess: () => {
toast.success("Wallet set as project wallet");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to set default wallet",
);
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="size-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="rounded-xl">
<DropdownMenuItem asChild>
<Link
href={`/team/${props.teamSlug}/${props.project.slug}/transactions?testTxWithWallet=${props.wallet.id}`}
className="flex items-center gap-2 h-9 rounded-lg"
>
<SendIcon className="size-4 text-muted-foreground" />
Send test transaction
</Link>
</DropdownMenuItem>
{props.fundWalletAddress && (
<DropdownMenuItem
onClick={() => setShowFundModal(true)}
className="flex items-center gap-2 h-9 rounded-lg"
>
<WalletProductIcon className="size-4 text-muted-foreground" />
Fund wallet
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setAsDefaultMutation.mutate()}
disabled={setAsDefaultMutation.isPending}
className="flex items-center gap-2 h-9 rounded-lg"
>
<CheckIcon className="size-4 text-muted-foreground" />
Set as default
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{props.fundWalletAddress && (
<FundWalletModal
open={showFundModal}
onOpenChange={setShowFundModal}
title="Fund server wallet"
description="Send funds to the server wallet"
recipientAddress={props.fundWalletAddress}
client={props.client}
defaultChainId={props.chainId}
checkoutWidgetTitle={
props.wallet.metadata.label
? `Fund ${props.wallet.metadata.label}`
: "Fund server wallet"
}
/>
)}
</>
);
}
function WalletBalanceCell(props: {
address: string;
chainId: number;
client: ThirdwebClient;
}) {
const chain = useV5DashboardChain(props.chainId);
const balance = useWalletBalance({
address: props.address,
chain: chain,
client: props.client,
});
if (balance.isFetching) {
return <Skeleton className="h-5 w-16" />;
}
if (!balance.data) {
return <span className="text-muted-foreground">N/A</span>;
}
return (
<span className="text-sm text-foreground">
{balance.data.displayValue} {balance.data.symbol}
</span>
);
}
@@ -1,37 +0,0 @@
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import type { Wallet } from "./types";
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
export function ServerWalletsTable({
wallets,
project,
teamSlug,
currentPage,
totalPages,
totalRecords,
managementAccessToken,
client,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
client: ThirdwebClient;
}) {
return (
<ServerWalletsTableUI
client={client}
currentPage={currentPage}
managementAccessToken={managementAccessToken}
project={project}
teamSlug={teamSlug}
totalPages={totalPages}
totalRecords={totalRecords}
wallets={wallets}
/>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { createSolanaAccount } from "../lib/vault.client";
export function CreateSolanaWallet(props: {
managementAccessToken: string | undefined;
project: Project;
teamSlug: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [label, setLabel] = useState("");
const router = useDashboardRouter();
const createMutation = useMutation({
mutationFn: async () => {
if (!props.managementAccessToken) {
throw new Error("No management access token");
}
const result = await createSolanaAccount({
managementAccessToken: props.managementAccessToken,
label: label.trim(),
projectId: props.project.id,
teamId: props.project.teamId,
});
if (!result.success || !result.data) {
throw result.error || new Error("Failed to create Solana wallet");
}
return result.data;
},
onSuccess: () => {
toast.success("Solana wallet created successfully");
setOpen(false);
setLabel("");
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to create Solana wallet",
);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="default"
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
disabled={props.disabled}
>
<PlusIcon className="size-4" />
Create Solana Wallet
</Button>
</DialogTrigger>
<DialogContent className="rounded-2xl">
<DialogHeader>
<DialogTitle>Create Solana Wallet</DialogTitle>
<DialogDescription>
Create a new Solana server wallet for your project
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="label">Wallet Label</Label>
<Input
id="label"
placeholder="e.g., Production Wallet"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
/>
</div>
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
>
{createMutation.isPending ? "Creating..." : "Create Wallet"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,218 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { KeyRoundIcon, Loader2Icon, ShieldCheckIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { upgradeAccessTokensForSolana } from "../../lib/vault.client";
const formSchema = z.object({
secretKey: z.string().min(1, "Secret key is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function UpgradeSolanaPermissions(props: { project: Project }) {
const router = useDashboardRouter();
const [isComplete, setIsComplete] = useState(false);
// Check if this is an ejected vault
const engineCloudService = props.project.services.find(
(s) => s.name === "engineCloud",
);
const isEjectedVault = !engineCloudService?.encryptedAdminKey;
const form = useForm<FormValues>({
defaultValues: {
secretKey: "",
},
resolver: zodResolver(formSchema),
});
const upgradeMutation = useMutation({
mutationFn: async (data: FormValues) => {
return upgradeAccessTokensForSolana({
project: props.project,
projectSecretKey: data.secretKey,
});
},
onSuccess: (result) => {
if (!result.success) {
toast.error(result.error || "Failed to upgrade Solana permissions");
return;
}
setIsComplete(true);
toast.success("Solana permissions enabled successfully!");
// Refresh the page after a short delay to show updated state
setTimeout(() => {
router.refresh();
}, 1500);
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to upgrade Solana permissions",
);
},
});
if (isComplete) {
return (
<Card className="border-success/50 bg-success/5">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success/10">
<ShieldCheckIcon className="size-5 text-success" />
</div>
<div className="flex-1">
<CardTitle className="text-success">
Solana Access Enabled
</CardTitle>
<CardDescription>
Your project now has full Solana functionality
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-2 rounded-lg bg-background/50 p-3 border border-border/50">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Refreshing page to load Solana wallets...
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-purple-500/10">
<KeyRoundIcon className="size-5 text-purple-500" />
</div>
<div>
<CardTitle>Enable Solana Functionality</CardTitle>
<CardDescription>
Upgrade your project to support Solana wallets and transactions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertTitle>
{isEjectedVault
? "Why do you need my admin key?"
: "Why do you need my secret key?"}
</AlertTitle>
<AlertDescription className="mt-2 space-y-2 text-sm">
<p>
Your project was created before Solana support was added. To
enable Solana features, we need to update your access tokens with
new permissions.
</p>
{isEjectedVault ? (
<>
<p>
Since you're using an ejected vault, your admin key is used
to:
</p>
<ul className="ml-4 list-disc space-y-1">
<li>Create new access tokens with Solana permissions</li>
<li>Update your management token with the new permissions</li>
</ul>
<p className="font-medium">
Your admin key is never stored and is only used during this
one-time upgrade process.
</p>
</>
) : (
<>
<p>Your secret key is used to:</p>
<ul className="ml-4 list-disc space-y-1">
<li>Decrypt your existing admin credentials</li>
<li>Create new access tokens with Solana permissions</li>
<li>Re-encrypt and securely store the updated credentials</li>
</ul>
<p className="font-medium">
Your secret key is never stored and is only used during this
one-time upgrade process.
</p>
</>
)}
</AlertDescription>
</Alert>
<form
className="space-y-4"
onSubmit={form.handleSubmit((data) => upgradeMutation.mutate(data))}
>
<div className="space-y-2">
<Label htmlFor="secretKey">
{isEjectedVault ? "Vault Admin Key" : "Project Secret Key"}
</Label>
<Input
{...form.register("secretKey")}
id="secretKey"
placeholder={
isEjectedVault
? "Enter your vault admin key"
: "Enter your secret key"
}
type="password"
disabled={upgradeMutation.isPending}
className="font-mono"
/>
{form.formState.errors.secretKey && (
<p className="text-destructive text-sm">
{form.formState.errors.secretKey.message}
</p>
)}
<p className="text-muted-foreground text-xs">
{isEjectedVault
? "This is the admin key you received when you ejected your vault"
: "You can find your secret key in your project settings"}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="submit"
disabled={upgradeMutation.isPending || !form.formState.isValid}
className="min-w-[140px]"
>
{upgradeMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Upgrading...
</>
) : (
"Enable Solana"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,200 @@
"use server";
import {
createVaultClient,
createSolanaAccount as vaultCreateSolanaAccount,
listSolanaAccounts as vaultListSolanaAccounts,
} from "@thirdweb-dev/vault-sdk";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import type { SolanaWallet } from "../wallet-table/types";
interface VaultSolanaAccountListItem {
id: string;
pubkey: string;
createdAt: string;
updatedAt: string;
metadata: {
projectId?: string;
type?: string;
label?: string;
} | null;
}
interface SolanaAccountResponse {
pubkey: string;
createdAt: string;
updatedAt: string;
}
export async function listSolanaAccounts(params: {
managementAccessToken: string;
page?: number;
limit?: number;
projectId?: string;
}): Promise<{
data: {
items: SolanaWallet[];
totalRecords: number;
};
error: Error | null;
success: boolean;
}> {
const { managementAccessToken, page = 1, limit = 100, projectId } = params;
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
return {
data: {
items: [],
totalRecords: 0,
},
error: new Error("Missing managementAccessToken or vault URL"),
success: false,
};
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await vaultListSolanaAccounts({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: page - 1, // Vault SDK uses 0-based pagination
pageSize: limit,
},
},
});
if (!response.success || !response.data) {
return {
data: {
items: [],
totalRecords: 0,
},
error: response.error
? new Error(JSON.stringify(response.error))
: new Error("Failed to fetch Solana accounts"),
success: false,
};
}
const items = (response.data.items || []) as VaultSolanaAccountListItem[];
// Filter by projectId and type, transform to SolanaWallet type
const wallets: SolanaWallet[] = items
.filter((item) => {
// Filter by projectId
if (projectId && item.metadata?.projectId !== projectId) {
return false;
}
// Only include server-wallet type
return !item.metadata?.type || item.metadata.type === "server-wallet";
})
.map((item) => ({
id: item.id,
publicKey: item.pubkey,
metadata: {
type: "server-wallet",
projectId: item.metadata?.projectId || projectId || "",
label: item.metadata?.label,
},
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}));
return {
data: {
items: wallets,
totalRecords: wallets.length, // Use filtered count since we're filtering by projectId
},
error: null,
success: true,
};
} catch (error) {
console.error("Failed to list Solana accounts", error);
return {
data: {
items: [],
totalRecords: 0,
},
error: error instanceof Error ? error : new Error("Unknown error"),
success: false,
};
}
}
export async function createSolanaAccount(params: {
managementAccessToken: string;
label: string;
projectId: string;
teamId: string;
}): Promise<{
data: SolanaAccountResponse | null;
error: Error | null;
success: boolean;
}> {
const { managementAccessToken, label, projectId, teamId } = params;
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
return {
data: null,
error: new Error("Missing managementAccessToken or vault URL"),
success: false,
};
}
try {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const response = await vaultCreateSolanaAccount({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
metadata: {
label,
projectId,
teamId,
type: "server-wallet",
},
},
},
});
if (!response.success || !response.data) {
return {
data: null,
error: response.error
? new Error(JSON.stringify(response.error))
: new Error("Failed to create Solana account"),
success: false,
};
}
return {
data: {
pubkey: response.data.pubkey,
createdAt: response.data.createdAt,
updatedAt: response.data.updatedAt,
},
error: null,
success: true,
};
} catch (error) {
console.error("Failed to create Solana account", error);
return {
data: null,
error: error instanceof Error ? error : new Error("Unknown error"),
success: false,
};
}
}
@@ -0,0 +1,11 @@
export type SolanaWallet = {
id: string;
publicKey: string;
metadata: {
type: string;
projectId: string;
label?: string;
};
createdAt: string;
updatedAt: string;
};
@@ -1,25 +1,27 @@
import { ChevronLeftIcon } from "lucide-react";
import Link from "next/link";
export default function TransactionLayout({
export default async function TransactionLayout({
children,
params,
}: {
children: React.ReactNode;
params: { team_slug: string; project_slug: string };
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await params;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center gap-2">
<Link
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
href={`/team/${params.team_slug}/${params.project_slug}/transactions`}
href={`/team/${team_slug}/${project_slug}/transactions`}
>
<ChevronLeftIcon className="size-4" />
Back to Transactions
</Link>
</div>
<div className="flex-1">{children}</div>
<div className="flex-1 space-y-6">{children}</div>
</div>
);
}
@@ -15,9 +15,12 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { loginRedirect } from "@/utils/redirects";
import type { Transaction } from "../../analytics/tx-table/types";
import {
getSingleSolanaTransaction,
getSingleTransaction,
getSolanaTransactionActivityLogs,
getTransactionActivityLogs,
} from "../../lib/analytics";
import { SolanaTransactionDetailsUI } from "./solana-transaction-details-ui";
import { TransactionDetailsUI } from "./transaction-details-ui";
type AbiItem =
@@ -195,17 +198,18 @@ export default async function TransactionPage({
redirect(`/team/${team_slug}`);
}
const [transactionData, activityLogs] = await Promise.all([
// Try fetching both EVM and Solana transactions
const [evmTransactionData, solanaTransactionData] = await Promise.all([
getSingleTransaction({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
}),
getTransactionActivityLogs({
}).catch(() => null),
getSingleSolanaTransaction({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
}),
}).catch(() => null),
]);
const client = getClientThirdwebClient({
@@ -213,23 +217,50 @@ export default async function TransactionPage({
teamId: project.teamId,
});
if (!transactionData) {
notFound();
// Determine which transaction type we have and fetch appropriate activity logs
if (solanaTransactionData) {
const activityLogs = await getSolanaTransactionActivityLogs({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
});
// Render Solana transaction details
return (
<SolanaTransactionDetailsUI
activityLogs={activityLogs}
client={client}
project={project}
teamSlug={team_slug}
transaction={solanaTransactionData}
/>
);
}
// Decode transaction data on the server
const decodedTransactionData = await decodeTransactionData(transactionData);
if (evmTransactionData) {
const activityLogs = await getTransactionActivityLogs({
clientId: project.publishableKey,
teamId: project.teamId,
transactionId: id,
});
return (
<div className="space-y-6 p-2">
// Decode transaction data on the server for EVM
const decodedTransactionData =
await decodeTransactionData(evmTransactionData);
// Render EVM transaction details
return (
<TransactionDetailsUI
activityLogs={activityLogs}
client={client}
decodedTransactionData={decodedTransactionData}
project={project}
teamSlug={team_slug}
transaction={transactionData}
transaction={evmTransactionData}
/>
</div>
);
);
}
// No transaction found
notFound();
}
@@ -0,0 +1,459 @@
"use client";
import { format, formatDistanceToNowStrict } from "date-fns";
import {
ChevronDownIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { stringify } from "thirdweb/utils";
import type { Project } from "@/api/project/projects";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { Badge } from "@/components/ui/badge";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import type { SolanaTransaction } from "../../analytics/solana-tx-table/types";
import type { ActivityLogEntry } from "../../lib/analytics";
import { getSolanaNetworkName, getSolscanUrl } from "../../lib/solana-utils";
const solanaStatusDetails = {
CONFIRMED: { name: "Confirmed", variant: "success" as const },
FAILED: { name: "Failed", variant: "destructive" as const },
QUEUED: { name: "Queued", variant: "warning" as const },
SUBMITTED: { name: "Submitted", variant: "warning" as const },
};
export function SolanaTransactionDetailsUI({
transaction,
activityLogs,
}: {
transaction: SolanaTransaction;
teamSlug: string;
client: ThirdwebClient;
project: Project;
activityLogs: ActivityLogEntry[];
}) {
const [activeTab, setActiveTab] = useState<"overview" | "logs" | "raw">(
"overview",
);
// Extract relevant data from transaction
const {
id,
chainId,
signerAddress,
signature,
confirmedAt,
createdAt,
executionResult,
errorMessage,
} = transaction;
const status = (executionResult?.status ||
transaction.status ||
"QUEUED") as keyof typeof solanaStatusDetails;
// Parse network from chainId
const network = getSolanaNetworkName(chainId);
const networkDisplay = network.charAt(0).toUpperCase() + network.slice(1);
// Calculate time difference between creation and confirmation
const confirmationTime =
confirmedAt && createdAt
? new Date(confirmedAt).getTime() -
(createdAt instanceof Date ? createdAt : new Date(createdAt)).getTime()
: null;
// Get signature from executionResult if available
const txSignature =
(executionResult && "signature" in executionResult
? executionResult.signature
: signature) || null;
// Get slot and blockTime
const slot =
executionResult && "slot" in executionResult
? executionResult.slot
: transaction.confirmedAtSlot;
const blockTime =
executionResult && "blockTime" in executionResult
? executionResult.blockTime
: transaction.blockTime;
return (
<>
{/* Transaction ID Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">
Transaction Details
</h1>
<div className="mt-2 flex items-center gap-2">
<span className="text-muted-foreground text-sm">Queue ID:</span>
<CopyTextButton
className="font-mono"
copyIconPosition="left"
textToCopy={id}
textToShow={id}
tooltip="Copy Queue ID"
variant="ghost"
/>
</div>
</div>
<Badge
className="gap-2 text-base"
variant={solanaStatusDetails[status].variant}
>
{solanaStatusDetails[status].name}
</Badge>
</div>
{/* Tabs */}
<TabButtons
tabs={[
{
name: "Overview",
onClick: () => setActiveTab("overview"),
isActive: activeTab === "overview",
},
{
name: "Activity Logs",
onClick: () => setActiveTab("logs"),
isActive: activeTab === "logs",
},
{
name: "Raw Data",
onClick: () => setActiveTab("raw"),
isActive: activeTab === "raw",
},
]}
/>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="grid gap-6 md:grid-cols-2">
{/* Transaction Information */}
<Card>
<CardHeader>
<CardTitle>Transaction Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow label="Network" value={`Solana ${networkDisplay}`} />
<InfoRow
label="Status"
value={solanaStatusDetails[status].name}
/>
<InfoRow label="Signer Address">
<SolanaAddress address={signerAddress} shortenAddress={false} />
</InfoRow>
{txSignature && (
<InfoRow label="Signature">
<Link
href={getSolscanUrl(txSignature, chainId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-link-foreground hover:underline text-sm"
>
<code className="font-mono">
{txSignature.slice(0, 12)}...{txSignature.slice(-12)}
</code>
<ExternalLinkIcon className="size-3" />
</Link>
</InfoRow>
)}
{slot && <InfoRow label="Slot" value={slot.toString()} />}
{errorMessage && (
<InfoRow label="Error">
<span className="text-destructive text-sm">
{errorMessage}
</span>
</InfoRow>
)}
</CardContent>
</Card>
{/* Timing Information */}
<Card>
<CardHeader>
<CardTitle>Timing Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InfoRow label="Queued At">
<ToolTipLabel
label={format(
createdAt instanceof Date ? createdAt : new Date(createdAt),
"PP pp z",
)}
>
<span className="text-sm">
{formatDistanceToNowStrict(
createdAt instanceof Date
? createdAt
: new Date(createdAt),
{ addSuffix: true },
)}
</span>
</ToolTipLabel>
</InfoRow>
{confirmedAt && (
<>
<InfoRow label="Confirmed At">
<ToolTipLabel
label={format(new Date(confirmedAt), "PP pp z")}
>
<span className="text-sm">
{formatDistanceToNowStrict(new Date(confirmedAt), {
addSuffix: true,
})}
</span>
</ToolTipLabel>
</InfoRow>
{confirmationTime && (
<InfoRow
label="Confirmation Time"
value={`${(confirmationTime / 1000).toFixed(2)}s`}
/>
)}
</>
)}
{blockTime && (
<InfoRow
label="Block Time"
value={format(new Date(blockTime * 1000), "PP pp z")}
/>
)}
</CardContent>
</Card>
{/* Transaction Instructions */}
{transaction.transactionParams &&
transaction.transactionParams.instructions.length > 0 && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Instructions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{transaction.transactionParams.instructions.map(
(instruction, index) => (
<InstructionCard
key={`instruction-${instruction.programId}-${index}`}
index={index}
instruction={instruction}
/>
),
)}
</div>
</CardContent>
</Card>
)}
{/* Execution Details */}
{executionResult && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Execution Details</CardTitle>
</CardHeader>
<CardContent>
<CodeClient
code={stringify(executionResult, null, 2)}
lang="json"
/>
</CardContent>
</Card>
)}
</div>
)}
{/* Activity Logs Tab */}
{activeTab === "logs" && (
<Card>
<CardHeader>
<CardTitle>Activity Logs</CardTitle>
</CardHeader>
<CardContent>
{activityLogs.length === 0 ? (
<p className="text-muted-foreground text-sm">
No activity logs available
</p>
) : (
<div className="space-y-2">
{activityLogs.map((log) => (
<ActivityLogItem key={log.id} log={log} />
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Raw Data Tab */}
{activeTab === "raw" && (
<Card>
<CardHeader>
<CardTitle>Raw Transaction Data</CardTitle>
</CardHeader>
<CardContent>
<CodeClient code={stringify(transaction, null, 2)} lang="json" />
</CardContent>
</Card>
)}
</>
);
}
function InfoRow({
label,
value,
children,
}: {
label: string;
value?: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<dt className="text-muted-foreground text-sm font-medium">{label}</dt>
<dd className="text-sm">
{children || <span className="break-all">{value || "N/A"}</span>}
</dd>
</div>
);
}
function InstructionCard({
instruction,
index,
}: {
instruction: {
programId: string;
keys: Array<{
pubkey: string;
isSigner: boolean;
isWritable: boolean;
}>;
data: string;
};
index: number;
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<button
type="button"
className="flex w-full items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="font-medium text-sm">Instruction {index + 1}</span>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</button>
{isExpanded && (
<div className="mt-4 space-y-3">
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Program ID
</p>
<code className="text-xs font-mono break-all">
{instruction.programId}
</code>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Accounts ({instruction.keys.length})
</p>
<div className="space-y-2">
{instruction.keys.map((key, idx) => (
<div
key={`${key.pubkey}-${idx}`}
className="rounded border border-border bg-background p-2"
>
<code className="text-xs font-mono break-all">
{key.pubkey}
</code>
<div className="mt-1 flex gap-2">
{key.isSigner && (
<Badge variant="outline" className="text-xs">
Signer
</Badge>
)}
{key.isWritable && (
<Badge variant="outline" className="text-xs">
Writable
</Badge>
)}
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium mb-1">
Data
</p>
<code className="text-xs font-mono break-all block bg-background p-2 rounded border border-border">
{instruction.data || "No data"}
</code>
</div>
</div>
)}
</div>
);
}
function ActivityLogItem({ log }: { log: ActivityLogEntry }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="rounded-lg border border-border p-3">
<button
type="button"
className="flex w-full items-center justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{log.eventType}
</Badge>
<span className="text-sm">{log.stageName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{format(new Date(log.timestamp), "PP pp")}
</span>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</div>
</button>
{isExpanded && log.payload && (
<div className="mt-3">
<CodeClient
code={
typeof log.payload === "string"
? log.payload
: stringify(log.payload, null, 2)
}
lang="json"
/>
</div>
)}
</div>
);
}
@@ -22,8 +22,8 @@ import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
import type { Transaction } from "../../analytics/tx-table/types";
import { statusDetails } from "../../analytics/tx-table/types";
import type { ActivityLogEntry } from "../../lib/analytics";
import type { DecodedTransactionData, DecodedTransactionResult } from "./page";
@@ -12,11 +12,15 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
import { ServerWalletsTable } from "../transactions/server-wallets/wallet-table/wallet-table";
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
@@ -25,6 +29,7 @@ export default async function Page(props: {
type?: string;
interval?: string;
page?: string;
solana_page?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
@@ -66,7 +71,9 @@ export default async function Page(props: {
// Fetch server wallets with pagination (5 per page)
const pageSize = 5;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
// Fetch EVM wallets
const eoas =
vaultClient && managementAccessToken
? await listEoas({
@@ -84,7 +91,32 @@ export default async function Page(props: {
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const serverWallets = eoas.data?.items as Wallet[] | undefined;
// Fetch Solana wallets
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check for Solana permission errors
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
"AUTH_INSUFFICIENT_SCOPE",
);
const client = getClientThirdwebClient({
jwt: authToken,
@@ -143,18 +175,34 @@ export default async function Page(props: {
authToken={authToken}
/>
{/* Server Wallets Section */}
{/* Server Wallets Section (EVM + Solana) */}
<div className="flex flex-col gap-4">
{eoas.error ? null : (
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message || "Failed to load EVM wallets"}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
currentPage={currentPage}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
managementAccessToken={managementAccessToken ?? undefined}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
totalRecords={eoas.data.totalRecords}
wallets={serverWallets ?? []}
solanaPermissionError={isSolanaPermissionError || false}
/>
)}
</div>
@@ -0,0 +1,113 @@
import { cn } from "@workspace/ui/lib/utils";
import type { BuyAndSwapEmbedProps } from "@/components/blocks/BuyAndSwapEmbed";
import { FaqAccordion } from "@/components/blocks/faq-section";
import { UniversalBridgeEmbed } from "./client/UniversalBridgeEmbed";
import { BridgePageHeader } from "./header";
export function BridgePageUI(props: {
title: React.ReactNode;
buyTab: BuyAndSwapEmbedProps["buyTab"];
swapTab: BuyAndSwapEmbedProps["swapTab"];
}) {
return (
<div className="grow flex flex-col">
<BridgePageHeader />
<div className="flex grow items-center justify-center px-4 relative pt-12 pb-20 lg:py-28 min-h-[calc(100dvh-60px)]">
<DotsBackgroundPattern />
<UniversalBridgeEmbed buyTab={props.buyTab} swapTab={props.swapTab} />
</div>
<HeadingSection title={props.title} />
<div className="h-20 lg:h-40" />
<BridgeFaqSection />
<div className="h-32" />
</div>
);
}
function HeadingSection(props: { title: React.ReactNode }) {
return (
<div className="container">
<div className="mb-3 lg:mb-6">{props.title}</div>
<p className="text-muted-foreground text-sm text-pretty text-center lg:text-lg mb-6 lg:mb-8">
Seamlessly move your assets across 85+ chains with the best rates and
fastest execution
</p>
<div className="flex flex-col lg:flex-row gap-3 lg:gap-2 items-center justify-center">
<DataPill>85+ Chains Supported</DataPill>
<DataPill>4500+ Tokens Supported</DataPill>
<DataPill>9+ Million Routes Available</DataPill>
</div>
</div>
);
}
function DataPill(props: { children: React.ReactNode }) {
return (
<p className="bg-card flex items-center text-xs lg:text-sm gap-1.5 text-foreground border rounded-full px-8 lg:px-3 py-1.5 hover:text-foreground transition-colors duration-300">
{props.children}
</p>
);
}
function DotsBackgroundPattern(props: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
props.className,
)}
style={{
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
backgroundSize: "24px 24px",
maskImage:
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
}}
/>
);
}
const bridgeFaqs: Array<{ title: string; description: string }> = [
{
title: "What is bridging in crypto?",
description:
"Crypto bridging (cross-chain bridging) moves tokens between blockchains so you can use assets across networks. In thirdweb Bridge, connect your wallet, choose the source token/network and destination token/network, review the route and price, then confirm. Assets arrive after finality, often under ~10 seconds on fast routes, though timing depends on networks and congestion.",
},
{
title: "How does crypto bridging work?",
description:
"Bridge smart contracts lock or burn tokens on the source chain and mint or release equivalents on the destination via verified cross-chain providers. thirdweb Bridge automatically finds the fastest, lowest-cost route and may use different mechanisms based on networks and liquidity. Arrival can range from seconds to minutes depending on finality; many routes complete in ~10 seconds",
},
{
title: "What is a crypto asset swap?",
description:
"A crypto swap exchanges one token for another via a DEX or aggregator. thirdweb Bridge lets you bridge + swap in one step. For example, ETH on Ethereum to USDC on Base, by selecting your start and end tokens/networks and confirming.",
},
{
title: "How can I get stablecoins like USDC or USDT?",
description:
"Use thirdweb Bridge to convert assets you hold into USDC or USDT on your chosen network: select your current token/network, pick the stablecoin (USDC, USDT, etc) on the destination, and confirm. You can also buy stablecoins with fiat in the Buy flow and bridge if needed. Always verify official token contract addresses.",
},
{
title: "What is the cost of bridging and swapping?",
description:
"Costs include gas on each chain, bridge/liquidity provider fees, and any DEX swap fees or price impact. thirdweb Bridge compares routes and selects the best price route. Save by using lower-gas times or combining bridge + swap in one flow.",
},
];
function BridgeFaqSection() {
return (
<section className="container max-w-2xl">
<h2 className="text-2xl md:text-3xl font-semibold mb-4 lg:mb-8 tracking-tight text-center">
Frequently Asked Questions
</h2>
<FaqAccordion faqs={bridgeFaqs} />
</section>
);
}
@@ -2,10 +2,15 @@
import { ThemeProvider } from "next-themes";
import { ThirdwebProvider } from "thirdweb/react";
import { Toaster } from "@/components/ui/sonner";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TWAutoConnect } from "../../../(app)/components/autoconnect";
const thirdwebClient = getClientThirdwebClient();
export function BridgeProviders({ children }: { children: React.ReactNode }) {
return (
<ThirdwebProvider>
<TWAutoConnect client={thirdwebClient} />
<ThemeProvider
attribute="class"
defaultTheme="dark"
@@ -1,27 +1,32 @@
"use client";
import type { TokenInfo } from "thirdweb/react";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { createWallet } from "thirdweb/wallets";
import {
BuyAndSwapEmbed,
type BuyAndSwapEmbedProps,
} from "@/components/blocks/BuyAndSwapEmbed";
import { appMetadata } from "@/constants/connect";
export function UniversalBridgeEmbed({
chainId,
token,
amount,
}: {
chainId?: number;
token: TokenInfo | undefined;
amount?: string;
export const bridgeWallets = [
createWallet("io.metamask"),
createWallet("com.coinbase.wallet", {
appMetadata,
}),
createWallet("me.rainbow"),
createWallet("io.rabby"),
createWallet("io.zerion.wallet"),
];
export function UniversalBridgeEmbed(props: {
buyTab: BuyAndSwapEmbedProps["buyTab"];
swapTab: BuyAndSwapEmbedProps["swapTab"];
}) {
const chain = useV5DashboardChain(chainId || 1);
return (
<BuyAndSwapEmbed
chain={chain}
buyAmount={amount}
tokenAddress={token?.address}
buyTab={props.buyTab}
swapTab={props.swapTab}
pageType="bridge"
isTestnet={chain.testnet}
wallets={bridgeWallets}
/>
);
}
@@ -1,33 +0,0 @@
"use client";
import { ChevronRightIcon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
import Link from "next/link";
import { reportBridgePageLinkClick } from "@/analytics/report";
import { cn } from "@/lib/utils";
export function PillLink(props: {
children: React.ReactNode;
href: string;
linkType: "integrate-bridge" | "trending-tokens";
}) {
const Icon =
props.linkType === "integrate-bridge" ? DollarSignIcon : TrendingUpIcon;
return (
<Link
href={props.href}
target="_blank"
onClick={() => reportBridgePageLinkClick({ linkType: props.linkType })}
className={cn(
"shadow-sm text-center justify-center inline-flex items-center gap-4 text-foreground group",
"text-sm bg-card/50 backdrop-blur-lg border border-border/70",
"rounded-full px-4 py-2.5 transition-colors duration-300 text-pretty leading-5",
"hover:bg-pink-300/20 dark:hover:bg-pink-950/30 hover:text-foreground hover:border-pink-400 dark:hover:border-pink-800",
)}
>
<div className="flex items-center gap-2.5">
<Icon className="size-4 shrink-0 text-pink-600" />
{props.children}
</div>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground/50 group-hover:translate-x-1 transition-all duration-300 group-hover:text-foreground" />
</Link>
);
}
@@ -1,20 +1,27 @@
"use client";
import { BookOpenIcon, CoinsIcon, MoonIcon, SunIcon } from "lucide-react";
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { useTheme } from "next-themes";
import { reportBridgePageLinkClick } from "@/analytics/report";
import { ClientOnly } from "@/components/blocks/client-only";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
import { bridgeWallets } from "./client/UniversalBridgeEmbed";
export function PageHeader(props: { containerClassName?: string }) {
export function BridgePageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-dashed">
<div className="border-b border-border/70">
<header
className={cn(
"container flex max-w-7xl justify-between py-4",
"container flex max-w-7xl justify-between py-3 lg:py-4",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link className="flex items-center gap-2" href="/team">
<div className="flex items-center gap-6">
<Link className="flex items-center gap-2" href="/home">
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
@@ -22,18 +29,68 @@ export function PageHeader(props: { containerClassName?: string }) {
</Link>
</div>
<div className="flex items-center gap-5">
<Link
href="https://portal.thirdweb.com/bridge"
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<ToggleThemeButton className="bg-transparent" />
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
<div className="flex items-center gap-3 lg:gap-5">
<div className="flex items-center gap-1 lg:gap-2">
<Link
onClick={() => {
reportBridgePageLinkClick({ linkType: "trending-tokens" });
}}
target="_blank"
href="/tokens"
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
>
<span className="text-sm sr-only md:not-sr-only">
Trending Tokens
</span>
<CoinsIcon className="size-5 md:hidden" />
</Link>
<Link
onClick={() => {
reportBridgePageLinkClick({ linkType: "bridge-docs" });
}}
href="https://portal.thirdweb.com/bridge"
target="_blank"
aria-label="View Documentation"
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
>
<BookOpenIcon className="size-5" />
</Link>
<ToggleThemeButton />
</div>
<PublicPageConnectButton
connectButtonClassName="!rounded-full"
wallets={bridgeWallets}
/>
</div>
</header>
</div>
);
}
function ToggleThemeButton(props: { className?: string }) {
const { setTheme, theme } = useTheme();
return (
<ClientOnly
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
>
<Button
aria-label="Toggle theme"
className={cn(
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
props.className,
)}
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
variant="ghost"
>
{theme === "light" ? (
<SunIcon className="size-5 " />
) : (
<MoonIcon className="size-5 " />
)}
</Button>
</ClientOnly>
);
}
@@ -1,59 +0,0 @@
import { createThirdwebClient } from "thirdweb";
import { setThirdwebDomains } from "thirdweb/utils";
import {
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
NEXT_PUBLIC_IPFS_GATEWAY_URL,
} from "@/constants/public-envs";
import {
THIRDWEB_BRIDGE_URL,
THIRDWEB_BUNDLER_DOMAIN,
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_INSIGHT_API_DOMAIN,
THIRDWEB_PAY_DOMAIN,
THIRDWEB_RPC_DOMAIN,
THIRDWEB_SOCIAL_API_DOMAIN,
THIRDWEB_STORAGE_DOMAIN,
} from "@/constants/urls";
import { getVercelEnv } from "@/utils/vercel";
function getBridgeThirdwebClient() {
if (getVercelEnv() !== "production") {
// if not on production: run this when creating a client to set the domains
setThirdwebDomains({
bridge: THIRDWEB_BRIDGE_URL,
bundler: THIRDWEB_BUNDLER_DOMAIN,
inAppWallet: THIRDWEB_INAPP_WALLET_DOMAIN,
insight: THIRDWEB_INSIGHT_API_DOMAIN,
pay: THIRDWEB_PAY_DOMAIN,
rpc: THIRDWEB_RPC_DOMAIN,
social: THIRDWEB_SOCIAL_API_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
});
}
// During build time, client ID might not be available
if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
// Return a minimal client that will fail gracefully at runtime if needed
return createThirdwebClient({
clientId: "dummy-build-time-client",
config: {
storage: {
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
},
},
secretKey: undefined,
});
}
return createThirdwebClient({
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
config: {
storage: {
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
},
},
secretKey: undefined,
});
}
export const bridgeAppThirdwebClient = getBridgeThirdwebClient();
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,181 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { fetchChain } from "@/utils/fetchChain";
import { BridgePageUI } from "../../components/bridge-page";
import { generateTokenPairSlugs, getTokenPairData } from "./slug-map";
type Params = {
"token-pair": string;
};
type PageProps = {
params: Promise<Params>;
};
export async function generateStaticParams(): Promise<Params[]> {
return generateTokenPairSlugs().map((slug) => ({
"token-pair": slug,
}));
}
export async function generateMetadata(
props: PageProps,
): Promise<Metadata | undefined> {
const params = await props.params;
const tokenPair = params["token-pair"];
const tokenMetadata = await getTokenMetadata(tokenPair).catch(
() => undefined,
);
if (!tokenMetadata) {
return undefined;
}
const title = getTitle(tokenMetadata);
const description =
"Bridge and swap 4500+ tokens across 85+ chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality";
const result: Metadata = {
title,
description,
openGraph: {
title,
description,
},
};
return result;
}
export default async function Page(props: { params: Promise<Params> }) {
const params = await props.params;
const tokenMetadata = await getTokenMetadata(params["token-pair"]);
if (!tokenMetadata) {
redirect("/bridge");
}
const { fromToken, toToken, fromChain, toChain } = tokenMetadata;
return (
<BridgePageUI
buyTab={{
buyToken: {
tokenAddress: toToken.address,
chainId: toToken.chainId,
},
}}
swapTab={{
sellToken: {
tokenAddress: fromToken.address,
chainId: fromToken.chainId,
},
buyToken: {
tokenAddress: toToken.address,
chainId: toToken.chainId,
},
}}
title={
<h1 className="text-center text-2xl md:text-5xl font-semibold tracking-tighter text-balance max-w-5xl block mx-auto">
{getTitle({
fromToken,
toToken,
fromChain,
toChain,
})}
</h1>
}
/>
);
}
function cleanedChainName(chainName: string) {
return chainName.replaceAll("mainnet", "").replaceAll("Mainnet", "").trim();
}
function cleanTokenName(tokenName: string) {
return tokenName.replaceAll("Token", "").replaceAll("token", "").trim();
}
async function getTokenMetadata(slug: string) {
const tokenPairData = getTokenPairData(slug);
if (!tokenPairData) {
redirect("/bridge");
}
const [fromTokenTokens, toTokenTokens, fromChain, toChain] =
await Promise.all([
Bridge.tokens({
client: serverThirdwebClient,
chainId: tokenPairData.from.chainId,
tokenAddress: tokenPairData.from.tokenAddress,
}).catch(() => undefined),
Bridge.tokens({
client: serverThirdwebClient,
chainId: tokenPairData.to.chainId,
tokenAddress: tokenPairData.to.tokenAddress,
}).catch(() => undefined),
fetchChain(tokenPairData.from.chainId),
fetchChain(tokenPairData.to.chainId),
]);
const fromToken = fromTokenTokens?.[0];
const toToken = toTokenTokens?.[0];
if (!fromToken || !toToken || !fromChain || !toChain) {
return undefined;
}
return {
fromToken,
toToken,
fromChain,
toChain,
};
}
function getTitle(data: {
fromToken: {
name: string;
symbol: string;
address: string;
};
toToken: {
name: string;
symbol: string;
address: string;
};
fromChain: ChainMetadata;
toChain: ChainMetadata;
}) {
const isSameChain = data.fromChain.chainId === data.toChain.chainId;
const fromTokenName = cleanTokenName(data.fromToken.name);
const toTokenName = cleanTokenName(data.toToken.name);
const fromTokenFullName =
fromTokenName === data.fromToken.symbol ||
data.fromToken.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
? data.fromToken.symbol
: `${fromTokenName} (${data.fromToken.symbol})`;
const toTokenFullName =
toTokenName === data.toToken.symbol ||
data.toToken.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
? data.toToken.symbol
: `${toTokenName} (${data.toToken.symbol})`;
// if both symbol are same: Swap {symbol} from {fromChain} to {toChain}
if (fromTokenFullName === toTokenFullName) {
return `Swap ${fromTokenFullName} from ${cleanedChainName(data.fromChain.name)} to ${cleanedChainName(data.toChain.name)}`;
}
const line1 = `Swap ${fromTokenFullName} to ${toTokenFullName}`;
const line2 = isSameChain
? `on ${cleanedChainName(data.fromChain.name)}`
: `cross-chain from ${cleanedChainName(data.fromChain.name)} to ${cleanedChainName(data.toChain.name)}`;
return `${line1} ${line2}`;
}
@@ -0,0 +1,149 @@
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
type Tokens = Record<
string,
| {
chainId: number;
tokens: Record<string, string | undefined>;
}
| undefined
>;
const tokens: Tokens = {
eth: {
chainId: 1,
tokens: {
usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
shib: "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE",
},
},
bnb: {
chainId: 56,
tokens: {
ada: "0x3EE2200Efb3400fAbB9AacF31297cBdD1d435D47",
},
},
avax: {
chainId: 43114,
tokens: {},
},
doge: {
chainId: 2000,
tokens: {},
},
base: {
chainId: 8453,
tokens: {
usdc: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
weth: "0x4200000000000000000000000000000000000006",
},
},
pol: {
chainId: 137,
tokens: {
usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
bnb: "0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3",
shib: "0x6f8a06447ff6fcf75d803135a7de15ce88c1d4ec",
weth: "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619",
avax: "0x2C89bbc92BD86F8075d1DEcc58C7F4E0107f286b",
},
},
arb: {
chainId: 42161,
tokens: {
usdc: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
},
},
};
type TokenPairData = {
from: {
chainId: number;
tokenAddress: string;
};
to: {
chainId: number;
tokenAddress: string;
};
};
// examples: eth-to-eth-usdc, eth-usdc-to-pol, base-to-eth
export function getTokenPairData(slug: string): TokenPairData | undefined {
const [fromSlug, toSlug] = slug.split("-to-");
if (!fromSlug || !toSlug) {
return undefined;
}
const fromTokenInfo = getTokenInfo(fromSlug);
const toTokenInfo = getTokenInfo(toSlug);
if (!fromTokenInfo || !toTokenInfo) {
return undefined;
}
return {
from: fromTokenInfo,
to: toTokenInfo,
};
}
function getTokenInfo(slug: string):
| {
chainId: number;
tokenAddress: string;
}
| undefined {
const [chainSlug, tokenSlug] = slug.split("-");
if (!chainSlug) {
return undefined;
}
const chainObject = tokens[chainSlug];
if (!chainObject) {
return undefined;
}
const tokenAddress = tokenSlug
? chainObject.tokens[tokenSlug]
: NATIVE_TOKEN_ADDRESS;
if (!tokenAddress) {
return undefined;
}
return {
chainId: chainObject.chainId,
tokenAddress,
};
}
export function generateTokenPairSlugs() {
const tokenIds = Object.keys(tokens);
// generate all possible token pair slugs
const tokenSlugs: string[] = [];
for (const chainSlug of tokenIds) {
// erc20s and native token
if (tokens[chainSlug]) {
tokenSlugs.push(chainSlug); // native token
for (const tokenSlug in tokens[chainSlug].tokens) {
tokenSlugs.push(`${chainSlug}-${tokenSlug}`);
}
}
}
const routeSlugs: string[] = [];
for (const fromSlug of tokenSlugs) {
for (const toSlug of tokenSlugs) {
if (fromSlug === toSlug) {
continue;
}
routeSlugs.push(`${fromSlug}-to-${toSlug}`);
}
}
return routeSlugs;
}
+11 -168
View File
@@ -1,16 +1,5 @@
import { cn } from "@workspace/ui/lib/utils";
import { DotIcon } from "lucide-react";
import type { Metadata } from "next";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { getContract } from "thirdweb/contract";
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
import { FaqSection } from "@/components/blocks/faq-section";
import { AppFooter } from "@/components/footers/app-footer";
import { PillLink } from "./components/client/pill-link";
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
import { PageHeader } from "./components/header";
import { bridgeAppThirdwebClient } from "./constants";
import { BridgePageUI } from "./components/bridge-page";
const title = "thirdweb Bridge: Buy, Bridge & Swap Crypto on 85+ Chains";
const description =
@@ -25,163 +14,17 @@ export const metadata: Metadata = {
title,
};
export default async function BridgePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const { chainId, tokenAddress, amount } = await searchParams;
let symbol: string | undefined;
let decimals: number | undefined;
let tokenName: string | undefined;
if (chainId && tokenAddress) {
try {
const metadata = await getCurrencyMetadata({
contract: getContract({
address: tokenAddress as Address,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(chainId)),
client: bridgeAppThirdwebClient,
}),
});
({ symbol, decimals, name: tokenName } = metadata);
} catch (error) {
console.warn("Failed to fetch token metadata:", error);
// Continue with undefined values; the component should handle gracefully
}
}
export default function Page() {
return (
<div className="grow flex flex-col relative overflow-hidden">
<div className="relative z-10">
<PageHeader />
<div className="h-12 lg:h-20" />
<div className="relative z-10">
<div className="container">
<h1 className="text-3xl md:text-6xl font-bold mb-6 tracking-tighter text-center leading-8 lg:leading-none text-balance">
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
chain, instantly
</h1>
<div className="flex flex-wrap gap-2 items-center justify-center">
<DataPill>85+ Chains Supported</DataPill>
<DotIcon className="size-3 text-muted-foreground lg:hidden" />
<DataPill>4500+ Tokens Supported</DataPill>
<DataPill>9+ Million Routes Available</DataPill>
</div>
</div>
<div className="h-12 lg:h-16" />
<div className="flex grow items-center justify-center px-4 relative">
<DotsBackgroundPattern />
<UniversalBridgeEmbed
amount={amount as string}
chainId={chainId ? Number(chainId) : undefined}
token={
symbol && decimals && tokenName
? {
address: tokenAddress as Address,
name: tokenName,
symbol,
}
: undefined
}
/>
</div>
</div>
<div className="h-32" />
<div className="flex flex-col gap-4 items-center container">
<PillLink
href="https://portal.thirdweb.com/bridge"
linkType="integrate-bridge"
>
Accept Payments in Any Token Right Inside Your App
</PillLink>
<PillLink
href="https://thirdweb.com/tokens"
linkType="trending-tokens"
>
Discover Trending Tokens
</PillLink>
</div>
<div className="h-32" />
<div className="container max-w-2xl">
<BridgeFaqSection />
</div>
<div className="h-32" />
<div className="relative">
<AppFooter />
</div>
</div>
</div>
);
}
function DataPill(props: { children: React.ReactNode }) {
return (
<p className="lg:bg-card flex items-center gap-1.5 text-muted-foreground text-xs lg:backdrop-blur-lg lg:border lg:border-border/70 rounded-full lg:px-3 lg:py-1.5 hover:text-foreground transition-colors duration-300">
{props.children}
</p>
);
}
function DotsBackgroundPattern(props: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
props.className,
)}
style={{
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
backgroundSize: "24px 24px",
maskImage:
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
}}
<BridgePageUI
buyTab={undefined}
swapTab={undefined}
title={
<h1 className="text-3xl md:text-6xl font-semibold tracking-tighter text-balance text-center">
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
chain, instantly
</h1>
}
/>
);
}
const bridgeFaqs: Array<{ title: string; description: string }> = [
{
title: "What is bridging in crypto?",
description:
"Crypto bridging (cross-chain bridging) moves tokens between blockchains so you can use assets across networks. In thirdweb Bridge, connect your wallet, choose the source token/network and destination token/network, review the route and price, then confirm. Assets arrive after finality, often under ~10 seconds on fast routes, though timing depends on networks and congestion.",
},
{
title: "How does crypto bridging work?",
description:
"Bridge smart contracts lock or burn tokens on the source chain and mint or release equivalents on the destination via verified cross-chain providers. thirdweb Bridge automatically finds the fastest, lowest-cost route and may use different mechanisms based on networks and liquidity. Arrival can range from seconds to minutes depending on finality; many routes complete in ~10 seconds",
},
{
title: "What is a crypto asset swap?",
description:
"A crypto swap exchanges one token for another via a DEX or aggregator. thirdweb Bridge lets you bridge + swap in one step. For example, ETH on Ethereum to USDC on Base, by selecting your start and end tokens/networks and confirming.",
},
{
title: "How can I get stablecoins like USDC or USDT?",
description:
"Use thirdweb Bridge to convert assets you hold into USDC or USDT on your chosen network: select your current token/network, pick the stablecoin (USDC, USDT, etc) on the destination, and confirm. You can also buy stablecoins with fiat in the Buy flow and bridge if needed. Always verify official token contract addresses.",
},
{
title: "What is the cost of bridging and swapping?",
description:
"Costs include gas on each chain, bridge/liquidity provider fees, and any DEX swap fees or price impact. thirdweb Bridge compares routes and selects the best price route. Save by using lower-gas times or combining bridge + swap in one flow.",
},
];
function BridgeFaqSection() {
return <FaqSection faqs={bridgeFaqs} />;
}
-28
View File
@@ -1,28 +0,0 @@
# E2E testing setup
This directory contains the end-to-end (E2E) testing setup for the project.
## Adding a test
1. create a new file in this directory with the `.spec.ts` extension
2. copy the paterns from the existing tests
## Running the tests locally
To run the tests locally, you can use the following command:
```bash
pnpm playwright
```
## CI (vercel + checkly)
These tests are run automatically on every pull request as part of the vercel build (after the build completes we check if the tests pass and then we deploy the changes to the preview environment).
Your PR will be _blocked_ from deploying if these tests fail.
### Updating the tests in checkly
1. Create the tests
2. make sure they run locally
3. push the changes to the repository and run `pnpm update-checkly` to update the checkly tests. (Requires checkly cli being signed in, meaning you need to have checkly access.)
-14
View File
@@ -1,14 +0,0 @@
import { expect, test } from "@playwright/test";
import { getBaseURL } from "./setup";
test.beforeEach(async ({ page, baseURL }) => {
await page.goto(`${getBaseURL(baseURL)}/1`);
});
test.describe("Chain Page", () => {
test("redirects chain-id to correct slug", async ({ page }) => {
// Expect the page to have the correct title.
const pageUrl = page.url();
expect(pageUrl).toContain("/ethereum");
});
});
@@ -1,28 +0,0 @@
import { expect, test } from "@playwright/test";
import { getBaseURL, waitForPageLoad } from "./setup";
test.beforeEach(async ({ page, baseURL }) => {
await page.goto(
`${getBaseURL(baseURL)}/1/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D`,
);
});
test.describe("Contract Page", () => {
test("BAYC", async ({ page }) => {
// give it some time to load
await waitForPageLoad(page, {
loadTimeout: 10000,
waitAfterLoad: 15000,
});
// Expect the page to have the correct title.
expect(await page.title()).toBe(
"BoredApeYachtClub (BAYC) | Ethereum Smart Contract | thirdweb",
);
// expect the page to have the correct h1 tag.
expect(await page.locator("h1").textContent()).toBe("BoredApeYachtClub");
// find the extensions header
const nftTabLinkEl = page.locator("a", { hasText: "NFTs" }).first();
expect(await nftTabLinkEl.textContent()).toBe("NFTs");
});
});
-15
View File
@@ -1,15 +0,0 @@
import { expect, test } from "@playwright/test";
import { getBaseURL } from "./setup";
test.beforeEach(async ({ page, baseURL }) => {
await page.goto(getBaseURL(baseURL));
});
test.describe("Homepage", () => {
test("has correct title", async ({ page }) => {
// Expect a title to be correct.
await expect(page).toHaveTitle(
"thirdweb: The complete web3 development platform",
);
});
});
@@ -1,30 +0,0 @@
import { expect, test } from "@playwright/test";
import { getBaseURL } from "./setup";
test.beforeEach(async ({ page, baseURL }) => {
await page.goto(`${getBaseURL(baseURL)}/thirdweb.eth`);
});
test.describe("Publisher Page", () => {
test("thirdweb.eth", async ({ page }) => {
// Expect the page to have the correct title.
expect(await page.locator("h1").textContent()).toBe("thirdweb.eth");
expect(await page.locator("h2").nth(1).textContent()).toBe(
"Published contracts",
);
// first h2 header, then 10 elements in first table -> should be 12th
expect(await page.locator("h2").nth(12).textContent()).toBe(
"Deployed contracts",
);
// 13 h2 headers to start
expect((await page.locator("h2").all()).length).toBe(13);
const publishedContractsShowMoreButton = page
.locator("text=Show more")
.first();
await publishedContractsShowMoreButton.click();
// should load 10 more elements
expect((await page.locator("h2").all()).length).toBe(23);
});
});
-29
View File
@@ -1,29 +0,0 @@
import type { Page } from "@playwright/test";
export function getBaseURL(url: string | undefined) {
if (process.env.ENVIRONMENT_URL) {
url = process.env.ENVIRONMENT_URL;
}
if (!url) {
url = "https://thirdweb.com/";
}
return url.endsWith("/") ? url.slice(0, -1) : url;
}
async function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForPageLoad(
page: Page,
options: { loadTimeout: number; waitAfterLoad?: number },
) {
await Promise.race([
page.waitForLoadState("load"),
wait(options.loadTimeout),
]);
if (options.waitAfterLoad) {
await wait(options.waitAfterLoad);
}
}
@@ -48,7 +48,6 @@ function App() {
>
<TokenIcon
className="h-auto w-20 rounded-full"
iconResolver="/usdc.svg"
/>
</TokenProvider>
)
@@ -31,10 +31,7 @@ export function TokenImageOverridePreview() {
chain={ethereum}
client={THIRDWEB_CLIENT}
>
<TokenIcon
className="h-auto w-20 rounded-full"
iconResolver="/usdc.svg"
/>
<TokenIcon className="h-auto w-20 rounded-full" />
</TokenProvider>
);
}
+1
View File
@@ -245,6 +245,7 @@ Currently, the following tools are available:
- `paymentsPurchase`
- `getPaymentHistory`
- `fetchWithPayment`
- `listPayableServices`
- `createToken`
- `listTokens`
- `getTokenOwners`
@@ -104,7 +104,10 @@ type Options = {
swap?: {
className?: string;
style?: React.CSSProperties;
onSuccess?: (quote: SwapPreparedQuote) => void;
onSuccess?: (data: {
quote: SwapPreparedQuote;
statuses: CompletedStatusResult[];
}) => void;
onError?: (error: Error, quote: SwapPreparedQuote) => void;
onCancel?: (quote: SwapPreparedQuote) => void;
onDisconnect?: () => void;
@@ -132,7 +135,10 @@ type Options = {
error: Error,
quote: BuyOrOnrampPrepareResult | undefined,
) => void;
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
onSuccess?: (data: {
quote: BuyOrOnrampPrepareResult;
statuses: CompletedStatusResult[];
}) => void;
className?: string;
country?: string;
presetOptions?: [number, number, number];
@@ -20,7 +20,7 @@ The easiest way to equip your agents with the ability to pay for API calls is to
The MCP server comes with all the tools by default, but you can filter the tools available by passing a comma-separated list of tools as a query parameter.
In this example, we create a ReAct agent using LangChain and filter the MCP tools to `fetchWithPayment` and `getWalletBalance` as the only 2 tools we give our agent. You can view the full list of available tools [here](/ai/mcp#available-tools).
In this example, we create a ReAct agent using LangChain and filter the MCP tools to `fetchWithPayment` and `listPayableServices` as the only 2 tools we give our agent. You can view the full list of available tools [here](/ai/mcp#available-tools).
<Tabs defaultValue="typescript">
@@ -42,7 +42,7 @@ const model = new ChatOpenAI({
const mcpServers = {
thirdweb: {
url: `https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,getWalletBalance`
url: `https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,listPayableServices`
}
};
@@ -53,7 +53,7 @@ const tools = await client.getTools();
const agent = createReactAgent({
llm: model,
tools: tools,
prompt: "Use the fetchWithPayment tool to fetch any endpoint. Always pay in {{tokenAddress}}. Your wallet address is {{walletAddress}}."
prompt: "Use the fetchWithPayment tool to fetch any endpoint. Discover the available endpoints with the listPayableServices tool."
});
```
@@ -68,12 +68,12 @@ from langgraph.prebuilt import create_react_agent
client = MultiServerMCPClient(
{
"thirdweb": {
"url": "https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,getWalletBalance",
"url": "https://api.thirdweb.com/mcp?secretKey=<your-project-secret-key>&tools=fetchWithPayment,listPayableServices",
}
}
)
tools = await client.get_tools()
agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPayment tool to fetch any endpoint. Always pay in {{tokenAddress}}. Your wallet address is {{walletAddress}}.")
agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPayment tool to fetch any endpoint. Discover the available endpoints with the listPayableServices tool.")
```
</TabsContent>
@@ -83,10 +83,10 @@ agent = create_react_agent("openai:gpt-4.1", tools, prompt="Use the fetchWithPay
You can also create your own MCP tool or wrap all external calls with the `/v1/x402/fetchWithPayment` endpoint to automatically handle payment flows when APIs return a `402 Payment Required` response.
Pass the target `url`, `method` and optional `from` wallet address to the endpoint that will complete the payment. The address should be one of your [user wallets](/wallets/users) or [server wallets](/wallets/server).
Pass the target `url`, `method`. By default, it will use your default project wallet for payments, but you can also pass an optional `from` wallet address to the endpoint that will complete the payment. The address should be one of your [user wallets](/wallets/users) or [server wallets](/wallets/server).
```bash
curl -X POST https://api.thirdweb.com/v1/payments/x402/fetch?url=https://api.example.com/premium&method=GET&from=0x1234... \
curl -X POST https://api.thirdweb.com/v1/payments/x402/fetch?url=https://api.example.com/premium&method=GET \
-H "Content-Type: application/json" \
-H "x-secret-key: <your-project-secret-key>" \
-d '{ ... }' # optional request body passed through to the url called.
+24
View File
@@ -1,5 +1,29 @@
# wagmi-inapp
## 0.0.13
### Patch Changes
- Updated dependencies [[`ceba683`](https://github.com/thirdweb-dev/js/commit/ceba6835dd60896efc34fe1495a1812c0cc39db7), [`b30369f`](https://github.com/thirdweb-dev/js/commit/b30369f3cc0bbceed3470b6c905551a5c930f08f)]:
- thirdweb@5.110.0
- @thirdweb-dev/wagmi-adapter@0.2.168
## 0.0.12
### Patch Changes
- Updated dependencies [[`5ff6063`](https://github.com/thirdweb-dev/js/commit/5ff6063961c5b58e6a49cae77c803155072f30fc), [`72fbb15`](https://github.com/thirdweb-dev/js/commit/72fbb1550f957c93cfde4e63d7bb6458fb192c47)]:
- thirdweb@5.109.1
- @thirdweb-dev/wagmi-adapter@0.2.167
## 0.0.11
### Patch Changes
- Updated dependencies [[`51c8318`](https://github.com/thirdweb-dev/js/commit/51c8318df7b51126496ee94b1fd79a338734fced), [`d68da22`](https://github.com/thirdweb-dev/js/commit/d68da2239b628f7835b0545a143b69e09cdf4141)]:
- thirdweb@5.109.0
- @thirdweb-dev/wagmi-adapter@0.2.166
## 0.0.10
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wagmi-inapp",
"private": true,
"version": "0.0.10",
"version": "0.0.13",
"type": "module",
"scripts": {
"dev": "vite",
+12
View File
@@ -1,5 +1,17 @@
# @thirdweb-dev/api
## 0.1.1
### Patch Changes
- [#8290](https://github.com/thirdweb-dev/js/pull/8290) [`f2121e9`](https://github.com/thirdweb-dev/js/commit/f2121e9519719ef809986c919acc5069b5b7ce74) Thanks [@0xFirekeeper](https://github.com/0xFirekeeper)! - added support for userId across wallet apis
## 0.1.0
### Minor Changes
- [#8279](https://github.com/thirdweb-dev/js/pull/8279) [`72fbb15`](https://github.com/thirdweb-dev/js/commit/72fbb1550f957c93cfde4e63d7bb6458fb192c47) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update to latest API
## 0.0.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/api",
"version": "0.0.3",
"version": "0.1.1",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
+531 -9
View File
@@ -15,12 +15,18 @@ import type {
CompleteAuthenticationData,
CompleteAuthenticationErrors,
CompleteAuthenticationResponses,
ConvertFiatToCryptoData,
ConvertFiatToCryptoErrors,
ConvertFiatToCryptoResponses,
CreatePaymentData,
CreatePaymentErrors,
CreatePaymentResponses,
CreateServerWalletData,
CreateServerWalletErrors,
CreateServerWalletResponses,
CreateSolanaWalletData,
CreateSolanaWalletErrors,
CreateSolanaWalletResponses,
CreateTokenData,
CreateTokenErrors,
CreateTokenResponses,
@@ -30,6 +36,15 @@ import type {
DeployContractData,
DeployContractErrors,
DeployContractResponses,
FetchWithPaymentData,
FetchWithPaymentErrors,
FetchWithPaymentResponses,
GetBridgeChainsData,
GetBridgeChainsErrors,
GetBridgeChainsResponses,
GetBridgeSupportedRoutesData,
GetBridgeSupportedRoutesErrors,
GetBridgeSupportedRoutesResponses,
GetContractEventsData,
GetContractEventsErrors,
GetContractEventsResponses,
@@ -48,6 +63,9 @@ import type {
GetPaymentHistoryData,
GetPaymentHistoryErrors,
GetPaymentHistoryResponses,
GetSolanaTransactionData,
GetSolanaTransactionErrors,
GetSolanaTransactionResponses,
GetTokenOwnersData,
GetTokenOwnersErrors,
GetTokenOwnersResponses,
@@ -69,12 +87,21 @@ import type {
InitiateAuthenticationData,
InitiateAuthenticationErrors,
InitiateAuthenticationResponses,
LinkAuthenticationData,
LinkAuthenticationErrors,
LinkAuthenticationResponses,
ListContractsData,
ListContractsErrors,
ListContractsResponses,
ListPayableServicesData,
ListPayableServicesErrors,
ListPayableServicesResponses,
ListServerWalletsData,
ListServerWalletsErrors,
ListServerWalletsResponses,
ListSolanaWalletsData,
ListSolanaWalletsErrors,
ListSolanaWalletsResponses,
ListTokensData,
ListTokensErrors,
ListTokensResponses,
@@ -94,20 +121,41 @@ import type {
ReadContractData,
ReadContractErrors,
ReadContractResponses,
SendSolanaTokensData,
SendSolanaTokensErrors,
SendSolanaTokensResponses,
SendSolanaTransactionData,
SendSolanaTransactionErrors,
SendSolanaTransactionResponses,
SendTokensData,
SendTokensErrors,
SendTokensResponses,
SendTransactionsData,
SendTransactionsErrors,
SendTransactionsResponses,
SettleX402PaymentData,
SettleX402PaymentErrors,
SettleX402PaymentResponses,
SignMessageData,
SignMessageErrors,
SignMessageResponses,
SignSolanaMessageData,
SignSolanaMessageErrors,
SignSolanaMessageResponses,
SignTypedDataData,
SignTypedDataErrors,
SignTypedDataResponses,
SocialAuthenticationData,
SocialAuthenticationErrors,
SupportedX402PaymentsData,
SupportedX402PaymentsErrors,
SupportedX402PaymentsResponses,
UnlinkAuthenticationData,
UnlinkAuthenticationErrors,
UnlinkAuthenticationResponses,
VerifyX402PaymentData,
VerifyX402PaymentErrors,
VerifyX402PaymentResponses,
WriteContractData,
WriteContractErrors,
WriteContractResponses,
@@ -137,22 +185,24 @@ export type Options<
* **Supported Methods:**
* - **SMS** - Send verification code to phone number
* - **Email** - Send verification code to email address
* - **OAuth** - ⚠️ **DEPRECATED**: Use `/auth/social` instead for OAuth flows
* - **Passkey** - Generate WebAuthn challenge for biometric authentication
* - **SIWE** - Generate Sign-In with Ethereum payload
*
* **OAuth Migration:**
* The OAuth method in this endpoint is deprecated. Please use the new `/auth/social` endpoint instead:
* - **Old**: `POST /auth/initiate` with `{"method": "oauth", "provider": "google", "redirectUrl": "..."}`
* - **New**: `GET /auth/social?provider=google&redirectUrl=...`
*
* **Flow:**
* 1. Choose your authentication method
* 2. Provide method-specific parameters
* 3. Receive challenge data to complete authentication
* 4. Use the /complete endpoint to finish the process
*
* NOTE: for custom authentication (JWT, auth-payload) and for guest authentication, you can skip this step and use the `/complete` endpoint directly.
* **OAuth:**
*
* The OAuth method uses a dedicated `/auth/social` endpoint instead of this one:
*
* `GET /auth/social?provider=google&redirectUrl=...`
*
* **Custom (JWT, auth-payload) and Guest:**
*
* For custom authentication (JWT, auth-payload) and for guest authentication, you can skip this step and use the `/auth/complete` endpoint directly.
*
* **Authentication:** Requires `x-client-id` header for frontend usage or `x-secret-key` for backend usage.
*/
@@ -194,6 +244,7 @@ export const initiateAuthentication = <ThrowOnError extends boolean = false>(
* - `isNewUser` - Whether this is a new wallet creation
* - `token` - JWT token for authenticated API requests
* - `type` - The authentication method used
* - `userId` - Unique identifier for the authenticated user
* - `walletAddress` - Your new or existing wallet address
*
* **Next step - Verify your token:**
@@ -237,6 +288,82 @@ export const completeAuthentication = <ThrowOnError extends boolean = false>(
});
};
/**
* Link Auth
* Link an additional authentication method or external wallet to the currently authenticated user. Provide the authentication token from another completed login (for example, a SIWE wallet or OAuth account) and this endpoint will associate it with the user's existing wallet.
*
* **Usage:**
* 1. Complete an authentication flow using `/auth/complete` to obtain the new authentication token
* 2. Call this endpoint with the token you want to link
* 3. Receive the full list of linked authentication profiles for the wallet
*
* **Authentication:** Requires both client authentication (`x-client-id` or `x-secret-key`) and a wallet access token via `Authorization: Bearer <jwt>`.
*/
export const linkAuthentication = <ThrowOnError extends boolean = false>(
options?: Options<LinkAuthenticationData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
LinkAuthenticationResponses,
LinkAuthenticationErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/auth/link",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Unlink Auth
* Disconnect an authentication method or external wallet from the currently authenticated user. Provide the identifiers for the provider you want to remove (for example, an email address or wallet address) and this endpoint will detach it from the user's account.
*
* **Usage:**
* 1. Choose the provider type you want to disconnect (email, phone, siwe, oauth, etc.)
* 2. Supply the provider-specific identifiers in the request body
* 3. Receive the updated list of linked authentication profiles
*
* **Authentication:** Requires both client authentication (`x-client-id` or `x-secret-key`) and a wallet access token via `Authorization: Bearer <jwt>`.
*/
export const unlinkAuthentication = <ThrowOnError extends boolean = false>(
options?: Options<UnlinkAuthenticationData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
UnlinkAuthenticationResponses,
UnlinkAuthenticationErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
{
scheme: "bearer",
type: "http",
},
],
url: "/v1/auth/unlink",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Social Auth
* Complete OAuth authentication with social providers in a single step. Unlike other auth methods that require separate initiate/complete calls, OAuth is handled entirely through redirects.
@@ -316,6 +443,7 @@ export const socialAuthentication = <ThrowOnError extends boolean = false>(
* Retrieve the authenticated user's wallet information including wallet addresses and linked authentication wallets. This endpoint provides comprehensive user data for the currently authenticated session.
*
* **Returns:**
* - userId - Unique identifier for this wallet in thirdweb auth
* - Primary wallet address
* - Smart wallet address (if available)
* - Wallet creation timestamp
@@ -507,7 +635,7 @@ export const getWalletTransactions = <ThrowOnError extends boolean = false>(
/**
* Get Tokens
* Retrieves token balances for a specific wallet address across one or more blockchain networks. This endpoint provides comprehensive token data including ERC-20 tokens with their balances, metadata, and price information. Results can be filtered by chain and paginated to meet specific requirements.
* Retrieves token balances for a specific wallet address across one or more blockchain networks. This endpoint provides comprehensive token data including ERC-20 tokens with their balances, metadata, and price information. Results can be filtered by chain, sorted by balance or USD value, and customized to include/exclude spam tokens, native tokens, and tokens without price data. Supports pagination and metadata resolution options.
*
* **Authentication**: Pass `x-client-id` header for frontend usage from allowlisted origins or `x-secret-key` for backend usage.
*/
@@ -1058,6 +1186,151 @@ export const paymentsPurchase = <ThrowOnError extends boolean = false>(
});
};
/**
* x402 - Verify payment
* Verify an x402 payment payload against the provided payment requirements. Compatible with any standard x402 middleware.
*/
export const verifyX402Payment = <ThrowOnError extends boolean = false>(
options?: Options<VerifyX402PaymentData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
VerifyX402PaymentResponses,
VerifyX402PaymentErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/payments/x402/verify",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* x402 - Settle payment
* Settle an x402 payment. Compatible with any standard x402 middleware.
*/
export const settleX402Payment = <ThrowOnError extends boolean = false>(
options?: Options<SettleX402PaymentData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
SettleX402PaymentResponses,
SettleX402PaymentErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/payments/x402/settle",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* x402 - Supported payment methods
* List supported x402 payment methods, optionally filtered by token address and chainId. Compatible with any standard x402 middleware.
*/
export const supportedX402Payments = <ThrowOnError extends boolean = false>(
options?: Options<SupportedX402PaymentsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
SupportedX402PaymentsResponses,
SupportedX402PaymentsErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/payments/x402/supported",
...options,
});
};
/**
* x402 - Fetch with payment
* Fetch any given url. If the url returns HTTP 402 payment required, this endpoint handles payment with the authenticated wallet.
*
* Optionally pass a 'from' query parameter with the authenticated wallet address (server or user wallet) to complete the payment.
*
* If no 'from' parameter is passed, the default project wallet address will be used.
*
* - Works with any x402 compatible endpoint.
* - Automatically selects a compatible payment method.
* - Signs the appropriate payment payload.
* - Sends the payment to the url.
* - Returns the final result from the url called.
*
* Request body and headers are always passed through to the url called.
*
* **Authentication**: This endpoint requires wallet authentication for the payment. For frontend usage, include `x-client-id` and `Authorization: Bearer <jwt>` headers. For backend usage, include `x-secret-key` header.
*/
export const fetchWithPayment = <ThrowOnError extends boolean = false>(
options: Options<FetchWithPaymentData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<
FetchWithPaymentResponses,
FetchWithPaymentErrors,
ThrowOnError
>({
security: [
{
scheme: "bearer",
type: "http",
},
{
name: "x-client-id",
type: "apiKey",
},
],
url: "/v1/payments/x402/fetch",
...options,
headers: {
"Content-Type": "*/*",
...options.headers,
},
});
};
/**
* x402 - Discover resources
* Discover payable x402 compatible services and HTTP endpoints that can be paid for using the fetchWithPayment tool. Use this tool to browse services, APIs and endpoints to find what you need for your tasks. Each item has a resource url that you can call with the fetchWithPayment tool.Price is in the base units of the asset. For example, if the price is 1000000 and the asset is USDC (which is the default and has 6 decimals), the price is 1 USDC.Examples: if network is eip155:8453, asset is 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, max amount required is 10000, resource is https://api.example.com/paid-api, then you should interpret that as "the api.example.com/paid-api service costs 0.01 USDC per call".
*/
export const listPayableServices = <ThrowOnError extends boolean = false>(
options?: Options<ListPayableServicesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
ListPayableServicesResponses,
ListPayableServicesErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
],
url: "/v1/payments/x402/discovery/resources",
...options,
});
};
/**
* List Tokens
* Lists or search existing tokens based on the provided filters. Supports querying by chain ID, token address, symbol, and/or name.
@@ -1124,7 +1397,13 @@ export const createToken = <ThrowOnError extends boolean = false>(
/**
* Get Owners
* Retrieves a paginated list of owners for a given ERC-20 token contract on a specific chain.
* Retrieves a paginated list of owners for a given token contract on a specific chain. Supports ERC-20 tokens, ERC-721 NFTs, and ERC-1155 tokens:
*
* - **ERC-20**: No `tokenId` provided - returns token holders with balances
* - **NFT Collection**: No `tokenId` provided - returns all owners of any token in the collection
* - **Specific NFT**: `tokenId` provided - returns owner(s) of that specific token ID
*
* The token standard is automatically detected using ERC165 interface detection when needed.
*
* **Authentication**: Pass `x-client-id` header for frontend usage from allowlisted origins or `x-secret-key` for backend usage.
*/
@@ -1147,6 +1426,83 @@ export const getTokenOwners = <ThrowOnError extends boolean = false>(
});
};
/**
* List Supported Chains
* List all blockchain networks available for cross-chain bridging. Each chain includes metadata and native currency details.
*
* **Authentication**: Pass `x-client-id` header for frontend usage from allowlisted origins or `x-secret-key` for backend usage.
*/
export const getBridgeChains = <ThrowOnError extends boolean = false>(
options?: Options<GetBridgeChainsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
GetBridgeChainsResponses,
GetBridgeChainsErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
],
url: "/v1/bridge/chains",
...options,
});
};
/**
* List Supported Routes
* List supported bridge routes with simple pagination and optional chain or token filters.
*
* **Authentication**: Pass `x-client-id` header for frontend usage from allowlisted origins or `x-secret-key` for backend usage.
*/
export const getBridgeSupportedRoutes = <ThrowOnError extends boolean = false>(
options?: Options<GetBridgeSupportedRoutesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
GetBridgeSupportedRoutesResponses,
GetBridgeSupportedRoutesErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
],
url: "/v1/bridge/routes",
...options,
});
};
/**
* Convert Fiat to Crypto
* Convert fiat currency amount to cryptocurrency token amount. Supports multiple fiat currencies based on available price data for the specific token. Returns the equivalent amount of crypto tokens for the specified fiat amount based on current market prices. If price data is not available for the requested currency, the API will return a 404 error.
*
* **Native Tokens**: To get the price of native tokens (like ETH on Ethereum), use the address `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`. For example, to get the price of ETH on Ethereum Mainnet (chainId: 1), pass `to=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`.
*
* **Authentication**: Pass `x-client-id` header for frontend usage from allowlisted origins or `x-secret-key` for backend usage.
*/
export const convertFiatToCrypto = <ThrowOnError extends boolean = false>(
options: Options<ConvertFiatToCryptoData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
ConvertFiatToCryptoResponses,
ConvertFiatToCryptoErrors,
ThrowOnError
>({
security: [
{
name: "x-client-id",
type: "apiKey",
},
],
url: "/v1/bridge/convert",
...options,
});
};
/**
* Swap or Bridge Tokens
* Swap one token for another using the optimal route available. You can specify a tokenIn amount (if exact='input') or tokenOut amount (if exact='output'), but not both. The corresponding output or input amount will be returned as the quote.
@@ -1180,6 +1536,172 @@ export const bridgeSwap = <ThrowOnError extends boolean = false>(
});
};
/**
* List Solana Wallets
* List all Solana wallets created for your project. Supports pagination with page and limit parameters.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const listSolanaWallets = <ThrowOnError extends boolean = false>(
options?: Options<ListSolanaWalletsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
ListSolanaWalletsResponses,
ListSolanaWalletsErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/wallets",
...options,
});
};
/**
* Create Solana Wallet
* Create a new Solana wallet or return the existing wallet for a given label. Labels must be unique within your project.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const createSolanaWallet = <ThrowOnError extends boolean = false>(
options?: Options<CreateSolanaWalletData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
CreateSolanaWalletResponses,
CreateSolanaWalletErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/wallets",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Sign Solana Message
* Sign an arbitrary message with a Solana wallet. Supports both text and hexadecimal message formats with automatic format detection.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const signSolanaMessage = <ThrowOnError extends boolean = false>(
options?: Options<SignSolanaMessageData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
SignSolanaMessageResponses,
SignSolanaMessageErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/sign-message",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Send Solana Tokens
* Transfer native SOL or SPL tokens on Solana. Automatically handles token account creation for SPL tokens if needed.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const sendSolanaTokens = <ThrowOnError extends boolean = false>(
options?: Options<SendSolanaTokensData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
SendSolanaTokensResponses,
SendSolanaTokensErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/send",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Send Solana Transaction
* Submit a Solana transaction composed of one or more instructions. Transactions are queued and processed asynchronously.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const sendSolanaTransaction = <ThrowOnError extends boolean = false>(
options?: Options<SendSolanaTransactionData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).post<
SendSolanaTransactionResponses,
SendSolanaTransactionErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/transactions",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Get Solana Transaction
* Retrieve the status and details of a queued Solana transaction using the identifier returned when the transaction was submitted.
*
* **Authentication**: This endpoint requires backend authentication using the x-secret-key header. The secret key should never be exposed publicly.
*/
export const getSolanaTransaction = <ThrowOnError extends boolean = false>(
options: Options<GetSolanaTransactionData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<
GetSolanaTransactionResponses,
GetSolanaTransactionErrors,
ThrowOnError
>({
security: [
{
name: "x-secret-key",
type: "apiKey",
},
],
url: "/v1/solana/transactions/:transactionId",
...options,
});
};
/**
* Chat
* Thirdweb AI chat completion API (BETA).
File diff suppressed because it is too large Load Diff
+21
View File
@@ -1,5 +1,26 @@
# @thirdweb-dev/nebula
## 0.2.71
### Patch Changes
- Updated dependencies [[`ceba683`](https://github.com/thirdweb-dev/js/commit/ceba6835dd60896efc34fe1495a1812c0cc39db7), [`b30369f`](https://github.com/thirdweb-dev/js/commit/b30369f3cc0bbceed3470b6c905551a5c930f08f)]:
- thirdweb@5.110.0
## 0.2.70
### Patch Changes
- Updated dependencies [[`5ff6063`](https://github.com/thirdweb-dev/js/commit/5ff6063961c5b58e6a49cae77c803155072f30fc), [`72fbb15`](https://github.com/thirdweb-dev/js/commit/72fbb1550f957c93cfde4e63d7bb6458fb192c47)]:
- thirdweb@5.109.1
## 0.2.69
### Patch Changes
- Updated dependencies [[`51c8318`](https://github.com/thirdweb-dev/js/commit/51c8318df7b51126496ee94b1fd79a338734fced), [`d68da22`](https://github.com/thirdweb-dev/js/commit/d68da2239b628f7835b0545a143b69e09cdf4141)]:
- thirdweb@5.109.0
## 0.2.68
### Patch Changes

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