Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
db3838032a Version Packages (#8102)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-23 03:42:43 -07:00
Joaquim VergesandGitHub 60021b3a44 [SDK] added settlePayment() utility for x402 payments (#8105) 2025-09-23 03:23:31 -07:00
a81899d60a [Dashboard] Add ai usage billing banner to dashboard (#8104)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-22 19:19:09 -07:00
MananTank b366d56039 [MNY-195] Dashboard: Remove Dexscreener on erc20 public page, add token info card (#8103)
<!--

## 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 UI components and functionality in the `ERC20` public page, improving styles, and refactoring the token info fetching logic.

### Detailed summary
- Removed `dex-screener.tsx` and `dex-screener-chains.ts`.
- Adjusted padding in `ContractHeader.tsx`.
- Added shadow to a div in `BuyAndSwapEmbed.tsx`.
- Updated border styles in `ERC20PublicPage.tsx`.
- Introduced `TokenInfoSection` for displaying token info.
- Refactored `fetchTokenInfoFromBridge` to use `ThirdwebClient`.
- Added icons in `ContractAnalyticsOverview` and `RecentTransfers`.
- Improved layout and styles for various components.

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

<!-- end pr-codex -->

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

* **New Features**
  * Added token summary section (Price, Market Cap, 24h Volume) on ERC20 public pages.

* **Style**
  * Visual polish: added shadow, larger rounded corners, adjusted headers, spacing, and rounded chart containers.
  * Chart and analytics headers now support/display icons; some header background styling simplified.

* **Changes**
  * Removed embedded DexScreener integration and related chain-slug mapping.

* **Refactor**
  * Token info fetching switched to a client-based SDK call; formatting helpers added for USD display.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 00:10:29 +00:00
MananTank e568bcea60 [MNY-193] Dashboard: Update Client Ids for Buy and Swap widgets in chain, bridge and asset pages (#8101)
<!--

## 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 and updating environment variable handling for the `BuyAndSwapEmbed` and `BuyFundsSection` components in a dashboard application. It enhances the usage of client IDs and improves the structure of props.

### Detailed summary
- Removed `client` prop from `BuyAndSwapEmbed` and `BuyFundsSection`.
- Introduced `clientId` handling in `getConfiguredThirdwebClient`.
- Updated `BuyAndSwapEmbed` to use `useMemo` for client configuration based on `pageType`.
- Added multiple `NEXT_PUBLIC_*_CLIENT_ID` constants in `public-envs.ts`.
- Cleaned up imports and types in various files for better clarity.

> ✨ 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
  - Auto-configured client selection per page type (asset/bridge/chain) for Buy, Swap, and Bridge embeds, improving reliability without manual setup.
- Refactor
  - Simplified components by removing the need to pass a client prop; widgets now use an internal, memoized client.
- Chores
  - Added new public environment variables for page-specific client IDs to enable tailored configuration:
    - NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
    - NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
    - NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 23:41:35 +00:00
MananTank a94f22928a [MNY-204] Dashboard: Add tracking for BuyWidget, SDK: update callback types in BuyWidget (#8100)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `BuyWidget` and related components by updating the callback props to include a `quote` object, allowing for better tracking of token buy events and improving error handling.

### Detailed summary
- Updated `onSuccess`, `onError`, and `onCancel` props in `BuyWidget` to accept a `quote` object.
- Enhanced error reporting with detailed information in `reportTokenBuyFailed`, `reportTokenBuyCancelled`, and `reportTokenBuySuccessful`.
- Modified `BuyAndSwapEmbed` component to handle the new callback signatures.
- Adjusted `BridgeOrchestrator` to utilize the `quote` object in its flow.

> ✨ 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
  - Buy and Bridge flows now provide quote details to success, error, and cancel callbacks.
  - Token-level analytics added for buy flows (success, failure, cancel).
- Breaking Changes
  - Callback signatures changed to include quote parameters; client implementations must be updated.
- Documentation
  - Examples updated to show new callback parameters and usage.
- Chores
  - Standardized analytics for asset purchases where contract type may be unspecified.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 22:53:37 +00:00
Joaquim VergesandGitHub 5249cb7409 [SDK] Add verifyPayment() backend utility for arbitrary chain x402 payments (#8091) 2025-09-22 15:35:05 -07:00
MananTank b37ed57365 Dashboard: Fix broken opengraph-image.tsx (#8094)
<!--

## 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 use of `useId` in several components by replacing it with static string values for IDs, improving consistency and potentially performance in the components related to Open Graph images.

### Detailed summary
- Removed `useId` from `contract-wallet-analytics.ts`, replacing it with a static variable.
- Updated `TWLogo` component in `opengraph-image.tsx` to use static IDs instead of `useId`.
- Changed `OgBrandIcon` component in `opengraph-image.tsx` to use a static gradient ID.
- Updated `TWLogo` component in another `opengraph-image.tsx` file to use static IDs instead of `useId`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- New Features
  - None.
- Bug Fixes
  - Improved reliability of OpenGraph images by using stable SVG IDs for gradients and clip paths, ensuring consistent rendering across sharing platforms on chain, contract, and drop pages.
- Chores
  - Removed unnecessary debug logging from wallet analytics to reduce console noise.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-22 18:59:27 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c328aa13a6 Version Packages (#8081)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 15:19:02 +07:00
FirekeeperandGitHub a85ef0b222 Add Zephyr testnet to pre-1559 chains (#8092) 2025-09-22 15:07:13 +07:00
sashaodessaandGitHub 51177fbe17 Fix typos in comments and docs across thirdweb packages (#8033)
Signed-off-by: sashaodessa <[email protected]>
2025-09-19 18:59:29 -07:00
Jonas DanielsandGitHub 1338a0cf2c Remove taint from GROWTH_PLAN_SKU and PAYMENT_METHOD_CONFIGURATION (#8089) 2025-09-19 18:58:47 -07:00
MananTank 96ebf378ff Playground: Redirect corrections (#8087)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the routing configuration in the `next.config.mjs` file to allow for dynamic path segments in specific routes.

### Detailed summary
- Changed `source` for `/insight` to `/insight/:path*`, allowing for dynamic paths.
- Changed `source` for `/payments/backend` to `/payments/backend/:path*`, enabling dynamic path handling.
- Both routes maintain their respective `destination` and `permanent` settings.

> ✨ 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**
  * Fixed redirects for /insight and /payments/backend to handle nested paths, ensuring deep links (e.g., subpages) correctly route to their destinations without 404s.
  * Maintains existing base redirects and leaves other routes untouched, improving navigation reliability without altering overall site behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 01:10:38 +00:00
MananTank b7732d3a75 Dashboard: Add search in tokens page (#8088)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Token` and `TokenPage` components by adding search functionality for tokens and improving the bridge network selection process.

### Detailed summary
- Added `query` parameter in `tokens` function for searching tokens by name or symbol.
- Updated `Page` component to fetch supported chains asynchronously.
- Modified `TokenPage` to accept `chains` as a prop and integrated search functionality.
- Enhanced `BridgeNetworkSelector` to utilize passed `chains` prop instead of querying.
- Added search input for token filtering in the `TokenPage`.

> ✨ 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 token search on the Tokens page; search by name or symbol with a new inline search field beside sort controls.
  * Search intelligently interacts with sorting: choosing Popular or Trending clears the search.

* **Improvements**
  * Faster, more reliable page load by fetching supported networks server‑side and passing them to the page.
  * Refined network selector UI with a consistent placeholder and smoother option rendering.

* **Refactor**
  * Streamlined components to receive pre-fetched chain data, reducing in-component data fetching and simplifying state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 00:54:37 +00:00
MananTank 6f88d38ad2 [BLD-325] Dashboard: Rename floating chat button label (#8086)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `CustomChatButton` component and its usage in various layouts by simplifying the button structure, changing icons, and removing unnecessary wrapper elements.

### Detailed summary
- Removed the wrapper `<div>` around the `CustomChatButton` in `layout.tsx` files.
- Changed the icon in `CustomChatButton` from `MessageCircleIcon` to `CircleQuestionMarkIcon`.
- Updated button label from dynamic to static "Need help?".
- Adjusted button class names for styling consistency.

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

<!-- end pr-codex -->
2025-09-20 00:03:01 +00:00
MananTank d53f8a25b4 [MNY-191] Dashboard: Add header in /bridge (#8085)
<!--

## 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 layout and components of the `Bridge` application by improving styling, adding new components, and refactoring existing structures for better usability and aesthetics.

### Detailed summary
- Updated `<body>` class in `layout.tsx` for improved layout.
- Changed width class in `UniversalBridgeEmbed.tsx` for full responsiveness.
- Added `relative z-10` class to the main `<div>` in `BuyAndSwapEmbed.tsx`.
- Introduced a new `PageHeader` component in `header.tsx` with navigation links.
- Refactored `BridgePage` in `page.tsx` to include `PageHeader` and remove unnecessary elements.
- Added `DotsBackgroundPattern` function for a decorative background effect.
- Integrated `AppFooter` in `BridgePage` for consistent footer display.

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

<!-- end pr-codex -->
2025-09-19 23:35:04 +00:00
MananTank dd693169bc Dashboard: Remove /routes (#8084)
<!--

## 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 the removal of several files related to the `bridge` feature in the dashboard and the addition of a new `tokens` page with metadata for improved SEO.

### Detailed summary
- Deleted files related to the `bridge` feature:
  - `utils.ts`
  - `layout.tsx`
  - `route.ts`
  - `page.tsx`
  - `opengraph-image.png`
  - Various components under `routes/components/client` and `routes/components/server`
- Added a new `tokens` page in `page.tsx` with:
  - Metadata for SEO, including title and description
  - Import of `PageHeader` and `TokenPage` components

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- New Features
  - Redirects: Visiting /routes now automatically navigates to /tokens (temporary redirect).
  - Tokens page now includes enriched metadata for improved previews and sharing.

- Refactor
  - Removed the legacy Routes dashboard and associated UI controls, streamlining navigation to the Tokens experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 23:35:04 +00:00
MananTank 93f913c614 [MNY-189] SDK: SwapWidget UI improvements (#8080)
<!--

## 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 UI improvements for the `SwapWidget`, enhancing responsiveness, styling, and functionality, particularly for mobile users. It also refines token selection and formatting for better user experience.

### Detailed summary
- Removed a changeset file related to `SwapWidget` UI improvements.
- Adjusted breakpoints and font sizes in `design-system/index.ts`.
- Enhanced `SearchInput` styling in `SearchInput.tsx`.
- Added `tokenAmountFormatter` utility in `utils.ts`.
- Made `children` prop optional in `basic.tsx`.
- Updated color values in `sdk-component-theme.ts`.
- Enhanced `Input` component styles in `formElements.tsx`.
- Improved `BuyAndSwapEmbed.tsx` with better token handling.
- Introduced `useIsMobile` hook for mobile detection.
- Updated `ArrowUpDownIcon.tsx` SVG for better rendering.
- Added hover background support in `buttons.tsx`.
- Renamed `WithData` to `WithDataDesktop` in `SelectChain.stories.tsx`.
- Added mobile versions of `WithData` and `Loading` functions in `SelectChain.stories.tsx`.
- Updated `SwapWidget` stories to manage token selections and themes.
- Refined `select-chain.tsx` for better mobile handling.
- Improved `swap-ui.tsx` with mobile responsiveness and token selection logic.
- Enhanced `select-token-ui.tsx` with mobile UI improvements and token display logic.
- Added `ActiveWalletDetails` for displaying wallet information.
- Updated various button and token display styles for consistency and better UX.

> ✨ 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**
  * Swap widget: onDisconnect callback, details modal, max-fill, improved wallet/account display, clearer loading/insufficient-balance states; stories now disable token-selection persistence.
  * Mobile detection hook enabling separate mobile/desktop flows.

* **UI/Style**
  * Enhanced token/chain visuals (gradients, smaller icon size), adjusted spacing and input sizing, customizable button hover backgrounds.

* **Refactor**
  * Unified token amount formatting and simplified numeric displays.

* **Chores**
  * Added changeset entry for UI improvements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 02:22:43 +05:30
MananTank 02502159b6 Dashboard: Add Tokens page (#8073)
<!--

## 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 enhancements to the token management system, focusing on adding price-related fields, sorting options, and improving the user interface for token display and selection.

### Detailed summary
- Added `marketCapUsd` and `volume24hUsd` fields to the `Token` type.
- Introduced `sortBy` option in token fetching.
- Updated `PublicPageConnectButton` to include a `detailsButton`.
- Created a new `Page` component for displaying token information.
- Implemented a `PageHeader` component for navigation.
- Added `TokenPage` component for token management with sorting capabilities.
- Developed `TokensTable` component for listing tokens with price, market cap, and volume.
- Introduced `BridgeNetworkSelector` for selecting blockchain networks.
- Enhanced UI with loading states and better token presentation.

> ✨ 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
  - Introduced a Tokens dashboard page with header and hero section.
  - Added network selector for choosing chains.
  - Implemented token list with columns for Token, Price, Market Cap, and 24h Volume.
  - Enabled sorting by Popular (market cap) and Trending (volume), plus pagination.
  - Improved Connect button with an additional details button style.

- Enhancements
  - Loading states and empty-state messaging for token lists.
  - Backend now supports token sorting and exposes market cap and 24h volume for richer displays.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 20:02:12 +00:00
103 changed files with 3404 additions and 2245 deletions
+5
View File
@@ -474,6 +474,11 @@ async function redirects() {
destination: "/team/~/~/support",
permanent: false,
},
{
source: "/routes",
destination: "/tokens",
permanent: false,
},
];
}
+49 -7
View File
@@ -225,7 +225,7 @@ type AssetContractType =
*/
export function reportAssetBuySuccessful(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy successful", {
@@ -243,9 +243,51 @@ type TokenSwapParams = {
pageType: "asset" | "bridge" | "chain";
};
type TokenBuyParams = {
buyTokenChainId: number | undefined;
buyTokenAddress: string | undefined;
pageType: "asset" | "bridge" | "chain";
};
/**
* ### Why do we need to report this event?
* - To track number of successful token swaps from the token page
* - To track number of successful token buys
* - To track which tokens are being bought the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuySuccessful(properties: TokenBuyParams) {
posthog.capture("token buy successful", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed token buys
* - To track which token buys are failing
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuyFailed(properties: TokenBuyParams) {
posthog.capture("token buy failed", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled token buys
* - To track which token buys are being cancelled
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenBuyCancelled(properties: TokenBuyParams) {
posthog.capture("token buy cancelled", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of successful token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
@@ -271,7 +313,7 @@ export function reportSwapWidgetShown(properties: {
/**
* ### Why do we need to report this event?
* - To track number of failed token swaps from the token page
* - To track number of failed token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
@@ -287,7 +329,7 @@ export function reportTokenSwapFailed(
/**
* ### Why do we need to report this event?
* - To track number of cancelled token swaps from the token page
* - To track number of cancelled token swaps
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
@@ -299,7 +341,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases from the token page
* - To track number of failed asset purchases
* - To track the errors that users encounter when trying to purchase an asset
*
* ### Who is responsible for this event?
@@ -307,7 +349,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
*/
export function reportAssetBuyFailed(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
error: string;
}) {
@@ -329,7 +371,7 @@ export function reportAssetBuyFailed(properties: {
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
contractType: AssetContractType;
contractType: AssetContractType | undefined;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
@@ -1,29 +1,39 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import type { Chain, ThirdwebClient } from "thirdweb";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Chain } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
reportAssetBuySuccessful,
reportSwapWidgetShown,
reportTokenBuyCancelled,
reportTokenBuyFailed,
reportTokenBuySuccessful,
reportTokenSwapCancelled,
reportTokenSwapFailed,
reportTokenSwapSuccessful,
} from "@/analytics/report";
import { Button } from "@/components/ui/button";
import {
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
} from "@/constants/public-envs";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
type PageType = "asset" | "bridge" | "chain";
export function BuyAndSwapEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: "asset" | "bridge" | "chain";
pageType: PageType;
}) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
@@ -41,8 +51,23 @@ export function BuyAndSwapEmbed(props: {
});
}, [props.pageType]);
const client = useMemo(() => {
return getConfiguredThirdwebClient({
clientId:
props.pageType === "asset"
? NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
: props.pageType === "bridge"
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
: props.pageType === "chain"
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
: undefined,
secretKey: undefined,
teamId: undefined,
});
}, [props.pageType]);
return (
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col">
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl">
<div className="flex gap-2.5 p-4 border-b border-dashed">
<TabButton
label="Swap"
@@ -62,36 +87,85 @@ export function BuyAndSwapEmbed(props: {
chain={props.chain}
className="!rounded-2xl !w-full !border-none"
title=""
client={props.client}
client={client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
onError={(e, quote) => {
const errorMessage = parseError(e);
reportTokenBuyFailed({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
contractType: undefined,
});
}
}}
onCancel={() => {
onCancel={(quote) => {
reportTokenBuyCancelled({
buyTokenChainId:
quote?.type === "buy"
? quote.intent.destinationChainId
: quote?.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenAddress:
quote?.type === "buy"
? quote.intent.destinationTokenAddress
: quote?.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
contractType: undefined,
});
}
}}
onSuccess={() => {
onSuccess={(quote) => {
reportTokenBuySuccessful({
buyTokenChainId:
quote.type === "buy"
? quote.intent.destinationChainId
: quote.type === "onramp"
? quote.intent.chainId
: undefined,
buyTokenAddress:
quote.type === "buy"
? quote.intent.destinationTokenAddress
: quote.type === "onramp"
? quote.intent.tokenAddress
: undefined,
pageType: props.pageType,
});
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
contractType: undefined,
});
}
}}
@@ -103,16 +177,17 @@ export function BuyAndSwapEmbed(props: {
{tab === "swap" && (
<SwapWidget
client={props.client}
client={client}
theme={themeObj}
className="!rounded-2xl !border-none !w-full"
prefill={{
sellToken: {
// buy this token by default
buyToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
buyToken: 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,
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Bridge, ThirdwebClient } from "thirdweb";
import { MultiSelect } from "@/components/blocks/multi-select";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
@@ -296,3 +296,89 @@ export function SingleNetworkSelector(props: {
/>
);
}
type BridgeNetworkSelectorProps = {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
side?: "left" | "right" | "top" | "bottom";
align?: "center" | "start" | "end";
placeholder?: string;
client: ThirdwebClient;
chains: Bridge.chains.Result;
};
export function BridgeNetworkSelector(props: BridgeNetworkSelectorProps) {
const options = useMemo(() => {
return props.chains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [props.chains]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = props.chains.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[props.chains],
);
const renderOption = useCallback(
(option: Option) => {
const chain = props.chains.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return option.label;
}
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIconClient
className="size-5"
client={props.client}
src={chain.icon}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
</div>
);
},
[props.chains, props.client],
);
return (
<SelectWithSearch
align={props.align}
className={props.className}
closeOnSelect={true}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={props.placeholder || "Select Chain"}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain ID"
showCheck={false}
side={props.side}
value={props.chainId?.toString()}
/>
);
}
@@ -30,6 +30,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
description?: string;
titleClassName?: string;
headerClassName?: string;
icon?: React.ReactNode;
};
customHeader?: React.ReactNode;
// chart config
@@ -70,6 +71,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
<Card className={props.className}>
{props.header && (
<CardHeader className={props.header.headerClassName}>
{props.header.icon}
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
{props.header.title}
</CardTitle>
@@ -4,7 +4,7 @@ export function GridPatternEmbedContainer(props: {
children: React.ReactNode;
}) {
return (
<div className=" sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-xl overflow-hidden relative">
<GridPattern
width={30}
height={30}
@@ -1,6 +1,6 @@
"use client";
import { MessageCircleIcon, XIcon } from "lucide-react";
import { CircleQuestionMarkIcon, XIcon } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { createThirdwebClient } from "thirdweb";
@@ -16,7 +16,6 @@ const client = createThirdwebClient({
});
export function CustomChatButton(props: {
label: string;
examplePrompts: string[];
authToken: string;
team: Team;
@@ -39,21 +38,25 @@ export function CustomChatButton(props: {
<>
{/* Inline Button (not floating) */}
<Button
className="gap-2 rounded-full shadow-lg"
className={cn(
"gap-2 rounded-full shadow-lg fixed right-6 bottom-6 z-50 bg-card",
isOpen && "opacity-0",
)}
size="sm"
onClick={() => {
setIsOpen(true);
setHasBeenOpened(true);
}}
variant="default"
variant="outline"
>
<MessageCircleIcon className="size-4" />
{props.label}
<CircleQuestionMarkIcon className="size-3.5 text-muted-foreground" />
Need help?
</Button>
{/* Popup/Modal */}
<div
className={cn(
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-2xl",
!isOpen && "hidden",
)}
ref={ref}
@@ -61,8 +64,7 @@ export function CustomChatButton(props: {
{/* Header with close button */}
<div className="flex items-center justify-between border-b px-4 py-4">
<div className="flex items-center gap-2 font-semibold text-lg">
<MessageCircleIcon className="size-5 text-muted-foreground" />
{props.label}
Need help?
</div>
<Button
aria-label="Close chat"
@@ -44,11 +44,12 @@ function AnnouncementBannerUI(props: {
}
export function AnnouncementBanner() {
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
trackingLabel="product-rebrand"
/>
);
// return (
// <AnnouncementBannerUI
// href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
// label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
// trackingLabel="product-rebrand"
// />
// );
return null;
}
@@ -32,3 +32,12 @@ export const NEXT_PUBLIC_DEMO_ENGINE_URL =
export const NEXT_PUBLIC_THIRDWEB_AI_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com";
export const NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID;
export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID;
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
+1 -17
View File
@@ -92,23 +92,7 @@ if (REDIS_URL) {
);
}
// DO NOT TAINT THESE VALUES (for now)
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
if (GROWTH_PLAN_SKU) {
experimental_taintUniqueValue(
"Do not pass GROWTH_PLAN_SKU to the client",
process,
GROWTH_PLAN_SKU,
);
}
export const PAYMENT_METHOD_CONFIGURATION =
process.env.PAYMENT_METHOD_CONFIGURATION || "";
if (PAYMENT_METHOD_CONFIGURATION) {
experimental_taintUniqueValue(
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
process,
PAYMENT_METHOD_CONFIGURATION,
);
}
@@ -25,6 +25,7 @@ import { getVercelEnv } from "@/utils/vercel";
export function getConfiguredThirdwebClient(options: {
secretKey: string | undefined;
teamId: string | undefined;
clientId?: string;
}): ThirdwebClient {
if (getVercelEnv() !== "production") {
// if not on production: run this when creating a client to set the domains
@@ -77,7 +78,8 @@ export function getConfiguredThirdwebClient(options: {
}
// During build time, provide fallbacks if credentials are missing
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
const clientId =
options.clientId || NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
const secretKey = options.secretKey || undefined;
return createThirdwebClient({
@@ -31,9 +31,9 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
selectedTextBg: "hsl(var(--inverted))",
selectedTextColor: "hsl(var(--inverted-foreground))",
separatorLine: "hsl(var(--border))",
skeletonBg: "hsl(var(--muted))",
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
success: "hsl(var(--success-text))",
tertiaryBg: "hsl(var(--muted)/50%)",
tertiaryBg: "hsl(var(--muted)/30%)",
tooltipBg: "hsl(var(--popover))",
tooltipText: "hsl(var(--popover-foreground))",
},
@@ -1,12 +0,0 @@
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
{children}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

@@ -1,37 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type ChainlistPaginationProps = {
totalPages: number;
activePage: number;
};
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
activePage,
totalPages,
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams || undefined);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<PaginationButtons
activePage={activePage}
onPageClick={(page) => router.push(createPageURL(page))}
totalPages={totalPages}
/>
);
};
@@ -1,74 +0,0 @@
"use client";
import { SearchIcon, XCircleIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
function cleanUrl(url: string) {
if (url.endsWith("?")) {
return url.slice(0, -1);
}
return url;
}
export const SearchInput: React.FC = () => {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// reset the input if the query param is removed
if (inputRef.current?.value && !searchParams?.get("query")) {
inputRef.current.value = "";
}
}, [searchParams]);
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams ?? undefined);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
// always delete the page number when searching
params.delete("page");
const url = cleanUrl(`${pathname}?${params.toString()}`);
router.replace(url);
}, 300);
return (
<div className="group relative w-full">
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
<Input
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
defaultValue={searchParams?.get("query") || ""}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search by token name or symbol"
ref={inputRef}
/>
{searchParams?.has("query") && (
<Button
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
onClick={() => {
const params = new URLSearchParams(searchParams ?? undefined);
params.delete("query");
params.delete("page");
const url = cleanUrl(`${pathname}?${params.toString()}`);
router.replace(url);
}}
size="icon"
variant="outline"
>
<XCircleIcon className="size-5" />
</Button>
)}
</div>
);
};
@@ -1,55 +0,0 @@
"use client";
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type QueryTypeProps = {
activeType: "origin" | "destination";
};
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(type: "origin" | "destination") => {
const params = new URLSearchParams(searchParams || undefined);
params.set("type", type);
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<div className="flex flex-row">
<ToolTipLabel contentClassName="w-full" label="Origin">
<Button
className="rounded-r-none"
onClick={() => {
router.replace(createPageURL("origin"));
}}
size="icon"
variant={activeType === "origin" ? "default" : "outline"}
>
<ArrowUpRightIcon strokeWidth={1} />
</Button>
</ToolTipLabel>
<ToolTipLabel contentClassName="w-full" label="Destination">
<Button
className="rounded-l-none"
onClick={() => {
router.replace(createPageURL("destination"));
}}
size="icon"
variant={activeType === "destination" ? "default" : "outline"}
>
<ArrowDownLeftIcon strokeWidth={1} />
</Button>
</ToolTipLabel>
</div>
);
};
@@ -1,50 +0,0 @@
"use client";
import { Grid2X2Icon, ListIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
type RouteListViewProps = {
activeView: "grid" | "table";
};
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const createPageURL = useCallback(
(view: "grid" | "table") => {
const params = new URLSearchParams(searchParams || undefined);
params.set("view", view);
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return (
<div className="flex flex-row">
<Button
className="rounded-r-none"
onClick={() => {
router.replace(createPageURL("table"));
}}
size="icon"
variant={activeView === "table" ? "default" : "outline"}
>
<ListIcon strokeWidth={1} />
</Button>
<Button
className="rounded-l-none"
onClick={() => {
router.replace(createPageURL("grid"));
}}
size="icon"
variant={activeView === "grid" ? "default" : "outline"}
>
<Grid2X2Icon strokeWidth={1} />
</Button>
</div>
);
};
@@ -1,141 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
type RouteListCardProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri?: string | null;
originTokenSymbol: string;
originTokenName: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri?: string | null;
destinationTokenSymbol: string;
destinationTokenName: string;
};
export async function RouteListCard({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenName,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenName,
}: RouteListCardProps) {
const [
originChain,
destinationChain,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
originTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: originTokenIconUri,
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: destinationTokenIconUri,
})
: undefined,
]);
return (
<div className="relative h-full">
<Card className="h-full w-full">
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex flex-row items-center gap-2">
{resolvedOriginTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={originTokenAddress}
className="size-8 rounded-full border border-muted-foreground"
src={resolvedOriginTokenIconUri}
/>
) : (
<div className="size-8 rounded-full bg-muted-foreground" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={destinationTokenAddress}
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
src={resolvedDestinationTokenIconUri}
/>
) : (
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
)}
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between gap-2">
<TokenName
chainMetadata={originChain}
tokenAddress={originTokenAddress}
tokenName={originTokenName}
/>
<div className="text-right text-base text-muted-foreground">
{originChain.name}
</div>
</div>
<div className="flex justify-between gap-2">
<TokenName
chainMetadata={destinationChain}
tokenAddress={destinationTokenAddress}
tokenName={destinationTokenName}
/>
<div className="text-right text-base text-muted-foreground">
{destinationChain.name}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
function TokenName(props: {
tokenAddress: string;
tokenName: string;
chainMetadata: ChainMetadata;
}) {
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
if (isERC20) {
return (
<Link
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
target="_blank"
>
{props.tokenName}
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
</Link>
);
}
return (
<div className="text-left font-normal text-base">
{props.chainMetadata.nativeCurrency.name}
</div>
);
}
@@ -1,136 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import {
type ChainMetadata,
defineChain,
getChainMetadata,
} from "thirdweb/chains";
import { shortenAddress } from "thirdweb/utils";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
import { TableCell, TableRow } from "@/components/ui/table";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
type RouteListRowProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri?: string | null;
originTokenSymbol?: string;
originTokenName?: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri?: string | null;
destinationTokenSymbol?: string;
destinationTokenName?: string;
};
export async function RouteListRow({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenSymbol,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenSymbol,
}: RouteListRowProps) {
const [
originChain,
destinationChain,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
originTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: originTokenIconUri,
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
client: serverThirdwebClient,
uri: destinationTokenIconUri,
})
: undefined,
]);
return (
<TableRow className="group">
<TableCell>
<TokenInfo
chainMetadata={originChain}
tokenAddress={originTokenAddress}
tokenIconUri={resolvedOriginTokenIconUri}
tokenSymbol={originTokenSymbol}
/>
</TableCell>
<TableCell className="text-muted-foreground/90">
<span className="font-medium">{originChain.name}</span>
</TableCell>
<TableCell>
<TokenInfo
chainMetadata={destinationChain}
tokenAddress={destinationTokenAddress}
tokenIconUri={resolvedDestinationTokenIconUri}
tokenSymbol={destinationTokenSymbol}
/>
</TableCell>
<TableCell className="text-muted-foreground/90">
<span className="font-medium">{destinationChain.name}</span>
</TableCell>
</TableRow>
);
}
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
function TokenInfo(props: {
tokenAddress: string;
tokenSymbol: string | undefined;
chainMetadata: ChainMetadata;
tokenIconUri: string | undefined;
}) {
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
return (
<div className="flex items-center gap-1.5">
{props.tokenIconUri ? (
<Img
alt={props.tokenAddress}
className="size-7 rounded-full border border-border/50"
src={props.tokenIconUri}
/>
) : (
<div className="size-7 rounded-full bg-muted-foreground/20" />
)}
{isERC20 ? (
<Button
asChild
className="h-auto w-auto py-1 px-1.5 gap-2"
variant="ghost"
>
<Link
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
target="_blank"
>
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
) : (
<span className="font-medium text-sm px-1.5 py-1">
{props.chainMetadata.nativeCurrency.symbol}
</span>
)}
</div>
);
}
@@ -1,176 +0,0 @@
import type { Address } from "thirdweb";
import {
Table,
TableBody,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getRoutes } from "../../../utils";
import { ChainlistPagination } from "../client/pagination";
import { RouteListCard } from "./routelist-card";
import { RouteListRow } from "./routelist-row";
export type SearchParams = Partial<{
includeDeprecated: boolean;
query: string;
page: number;
type: "origin" | "destination";
// maybe later we'll have a page size param?
// pageSize: number;
view: "table" | "grid";
}>;
// 120 is divisible by 2, 3, and 4 so card layout looks nice
const DEFAULT_PAGE_SIZE = 120;
async function getRoutesToRender(params: SearchParams) {
const filters: Partial<{
originQuery?: string;
destinationQuery?: string;
originChainId?: number;
originTokenAddress?: Address;
destinationChainId?: number;
destinationTokenAddress?: Address;
}> = {};
if (params.type === "origin" || typeof params.type === "undefined") {
if (params.query?.startsWith("0x")) {
filters.originTokenAddress = params.query as Address;
} else if (Number.isInteger(Number(params.query))) {
filters.originChainId = Number(params.query);
} else if (params.query) {
filters.originQuery = params.query;
}
} else if (params.type === "destination") {
if (params.query?.startsWith("0x")) {
filters.destinationTokenAddress = params.query as Address;
} else if (Number.isInteger(Number(params.query))) {
filters.destinationChainId = Number(params.query);
} else if (params.query) {
filters.destinationQuery = params.query;
}
}
const routes = await getRoutes({
destinationQuery: filters.destinationQuery,
limit: DEFAULT_PAGE_SIZE,
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
originQuery: filters.originQuery,
});
return {
filteredCount: routes.meta.filteredCount,
routesToRender: routes.data,
totalCount: routes.meta.totalCount,
};
}
export async function RoutesData(props: {
searchParams: SearchParams;
activeView: "table" | "grid";
isLoggedIn: boolean;
}) {
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
props.searchParams,
);
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
return (
<>
<main>
{/* empty state */}
{routesToRender.length === 0 ? (
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
<p className="text-2xl">No Results found</p>
</div>
) : props.activeView === "table" ? (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Origin Token</TableHead>
<TableHead>Origin Chain</TableHead>
<TableHead>Destination Token</TableHead>
<TableHead>Destination Chain</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{routesToRender.map((route) => (
<RouteListRow
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenName={route.destinationToken.name}
destinationTokenSymbol={route.destinationToken.symbol}
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenName={route.originToken.name}
originTokenSymbol={route.originToken.symbol}
/>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{routesToRender.map((route) => (
<li
className="h-full"
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
>
<RouteListCard
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenName={route.destinationToken.name}
destinationTokenSymbol={route.destinationToken.symbol}
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenName={route.originToken.name}
originTokenSymbol={route.originToken.symbol}
/>
</li>
))}
</ul>
)}
</main>
<div className="h-10" />
{totalPages > 1 && (
<ChainlistPagination
activePage={Number(props.searchParams.page) || 1}
totalPages={totalPages}
/>
)}
<div className="h-4" />
<p className="text-balance text-center text-muted-foreground text-sm">
Showing{" "}
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
out of{" "}
{filteredCount !== totalCount ? (
<>
<span className="text-accent-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
routes that match filters. (Total:{" "}
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>
)
</>
) : (
<>
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>{" "}
routes.
</>
)}
</p>
</>
);
}
@@ -1,94 +0,0 @@
import { ArrowUpRightIcon } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getAuthToken } from "@/api/auth-token";
import { SearchInput } from "./components/client/search";
import { QueryType } from "./components/client/type";
import { RouteListView } from "./components/client/view";
import {
RoutesData,
type SearchParams,
} from "./components/server/routes-table";
const title = "Payments Routes: Swap, Bridge, and Onramp";
const description =
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
export default async function RoutesPage(props: {
searchParams: Promise<SearchParams>;
}) {
const authToken = await getAuthToken();
const headersList = await headers();
const viewportWithHint = Number(
headersList.get("Sec-Ch-Viewport-Width") || 0,
);
const searchParams = await props.searchParams;
const activeType = searchParams.type ?? "origin";
// default is driven by viewport hint
const activeView = searchParams.view
? searchParams.view
: viewportWithHint > 1000
? "table"
: "grid";
return (
<section className="container mx-auto flex h-full flex-col px-4 py-10">
<header className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-2">
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
Routes
</h1>
</div>
<div className="flex flex-row items-end gap-4 lg:flex-col">
<div className="flex w-full flex-row items-center gap-4">
<SearchInput />
<QueryType activeType={activeType} />
<RouteListView activeView={activeView} />
</div>
</div>
</div>
</header>
<div className="h-10" />
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with thirdweb Payments
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/payments"
rel="noopener noreferrer"
target="_blank"
>
Learn More
<ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
<div className="h-10" />
<RoutesData
activeView={activeView}
isLoggedIn={!!authToken}
searchParams={searchParams}
/>
</section>
);
}
@@ -1,18 +0,0 @@
import type { Address } from "thirdweb";
export type Route = {
originToken: {
address: Address;
chainId: number;
iconUri?: string;
name: string;
symbol: string;
};
destinationToken: {
address: Address;
chainId: number;
iconUri?: string;
name: string;
symbol: string;
};
};
@@ -1,48 +0,0 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { Route } from "./types/route";
export async function getRoutes({
limit,
offset,
originQuery,
destinationQuery,
}: {
limit?: number;
offset?: number;
originQuery?: string;
destinationQuery?: string;
} = {}) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
if (limit) {
url.searchParams.set("limit", limit.toString());
}
if (offset) {
url.searchParams.set("offset", offset.toString());
}
if (originQuery) {
url.searchParams.set("originQuery", originQuery);
}
if (destinationQuery) {
url.searchParams.set("destinationQuery", destinationQuery);
}
url.searchParams.set("sortBy", "popularity");
const routesResponse = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
next: { revalidate: 60 * 60 },
});
if (!routesResponse.ok) {
throw new Error("Failed to fetch routes");
}
const routes: {
data: Route[];
meta: { totalCount: number; filteredCount: number };
} = await routesResponse.json();
return routes;
}
@@ -1,18 +1,13 @@
"use client";
import type { ThirdwebClient } 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;
client: ThirdwebClient;
}) {
export function BuyFundsSection(props: { chain: ChainMetadata }) {
return (
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
client={props.client}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
@@ -1,5 +1,4 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
@@ -18,8 +17,8 @@ export const size = {
export const contentType = "image/png";
const TWLogo: React.FC = () => {
const clipPathId = useId();
const linearGradientId = useId();
const clipPathId = "og-tw-logo-clip-path";
const linearGradientId = "og-tw-logo-linear-gradient";
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
<svg
@@ -44,7 +44,7 @@ export default async function Page(props: {
{chain.testnet ? (
<FaucetSection chain={chain} client={client} isLoggedIn={!!account} />
) : chain.services.find((c) => c.service === "pay" && c.enabled) ? (
<BuyFundsSection chain={chain} client={client} />
<BuyFundsSection chain={chain} />
) : null}
{/* Chain Overview */}
@@ -67,7 +67,6 @@ export async function getContractUniqueWalletAnalytics(params: {
}
const json = (await res.json()) as InsightResponse;
console.log("wallet analytics json", json);
const aggregations = Object.values(json.aggregations[0]);
const returnValue: TransactionAnalyticsEntry[] = [];
@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from "@vercel/og";
import { useId } from "react";
import { getContractMetadata } from "thirdweb/extensions/common";
import { isProd } from "@/constants/env-utils";
import { API_ROUTES_CLIENT_ID } from "@/constants/server-envs";
@@ -62,8 +61,9 @@ function shortenString(str: string) {
return `${str.substring(0, 7)}...${str.substring(str.length - 5)}`;
}
const gradientId = "og-brand-icon-gradient";
const OgBrandIcon: React.FC = () => {
const gradientId = useId();
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
<svg
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b bg-card">
<div className="border-b">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
connectButton={{
className: props.connectButtonClassName,
}}
detailsButton={{
className: props.connectButtonClassName,
}}
connectModal={{
privacyPolicyUrl: "/privacy-policy",
showThirdwebBranding: false,
@@ -76,7 +76,7 @@ export function ContractHeaderUI(props: {
return (
<div
className={cn(
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
"flex flex-col lg:flex-row lg:items-center gap-5 py-8 relative",
props.className,
)}
>
@@ -1,6 +1,7 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeftRightIcon,
ArrowRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
@@ -73,8 +74,13 @@ function RecentTransfersUI(props: {
return (
<div>
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-xl">
<div className="flex mb-3">
<div className="rounded-full border p-2 bg-background">
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight mb-1">
Recent Transfers
</h2>
<p className="text-muted-foreground text-sm">
@@ -194,7 +200,7 @@ function RecentTransfersUI(props: {
)}
</TableContainer>
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
<div className="flex items-center justify-end gap-3 rounded-b-xl border-x border-b bg-card px-6 py-5">
<Button
className="gap-1.5 bg-background rounded-full"
disabled={props.page === 0 || props.isPending}
@@ -1,5 +1,6 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import {
@@ -35,10 +36,18 @@ export function ContractAnalyticsOverview(props: {
margin={{
bottom: 20,
}}
className="rounded-xl"
header={{
headerClassName: "p-4 lg:p-6",
title: "Analytics",
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
icon: (
<div className="flex mb-3">
<div className="rounded-full border p-2 bg-background">
<BarChart3Icon className="size-4 text-muted-foreground" />
</div>
</div>
),
description:
"View trends of transactions, events and unique wallets interacting with this contract over time",
}}
@@ -1,77 +0,0 @@
export const mapChainIdToDexScreenerChainSlug = {
8453: "base",
1: "ethereum",
56: "bsc",
369: "pulsechain",
137: "polygon",
2741: "abstract",
43114: "avalanche",
999: "hyperliquid",
480: "worldchain",
42161: "arbitrum",
388: "cronos",
59144: "linea",
1514: "story",
397: "near",
295: "hedera",
146: "sonic",
10: "optimism",
80094: "berachain",
57073: "ink",
130: "unichain",
5000: "mantle",
324: "zksync",
466: "apechain",
1116: "core",
250: "fantom",
1868: "soneium",
81457: "blast",
2000: "dogechain",
14: "flare",
2040: "vana",
4337: "beam",
109: "shibarium",
747: "flowevm",
1088: "metis",
1030: "conflux",
43113: "avalanchedfk",
534352: "scroll",
747474: "katana",
42220: "celo",
1284: "moonbeam",
4200: "merlinchain",
2222: "kava",
39797: "energi",
34443: "mode",
252: "fraxtal",
48900: "zircuit",
20: "elastos",
100: "gnosischain",
204: "opbnb",
169: "manta",
1313161554: "aurora",
3073: "movement",
4689: "iotex",
23294: "oasissapphire",
6001: "bouncebit",
42170: "arbitrumnova",
1101: "polygonzkevm",
40: "telos",
592: "astar",
42262: "oasisemerald",
1285: "moonriver",
245022934: "neonevm",
7777777: "zora",
122: "fuse",
321: "kcc",
1234: "stepnetwork",
106: "velas",
167000: "taiko",
288: "boba",
42766: "zkfair",
32520: "bitgert",
82: "meter",
} as const;
export type DexScreenerChainSlug =
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
@@ -1,58 +0,0 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { ClientOnly } from "@/components/blocks/client-only";
import type { DexScreenerChainSlug } from "./dex-screener-chains";
function DexScreenerIframe(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
const { theme } = useTheme();
const iframeUrl = useMemo(() => {
const resolvedTheme = theme === "light" ? "light" : "dark";
const url = new URL("https://dexscreener.com");
url.pathname = `${props.chain}/${props.contractAddress}`;
url.searchParams.set("embed", "1");
url.searchParams.set("loadChartSettings", "0");
url.searchParams.set("chartTheme", resolvedTheme);
url.searchParams.set("theme", resolvedTheme);
url.searchParams.set("trades", "1");
url.searchParams.set("chartStyle", "1");
url.searchParams.set("chartLeftToolbar", "0");
url.searchParams.set("chartType", "usd");
url.searchParams.set("interval", "15");
url.searchParams.set("chartDefaultOnMobile", "1");
return url.toString();
}, [theme, props.chain, props.contractAddress]);
return (
<iframe
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
src={iframeUrl}
title="DexScreener"
></iframe>
);
}
export function DexScreener(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
return (
<ClientOnly
ssr={
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
<Spinner className="size-10" />
</div>
}
>
<DexScreenerIframe
chain={props.chain}
contractAddress={props.contractAddress}
/>
</ClientOnly>
);
}
@@ -1,42 +1,21 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { Bridge, type ThirdwebClient } from "thirdweb";
export async function fetchTokenInfoFromBridge(params: {
chainId: number;
tokenAddress: string;
clientId: string;
client: ThirdwebClient;
}) {
try {
const res = await fetch(
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
{
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
},
);
if (!res.ok) {
console.error(
`Failed to fetch token info from bridge: ${await res.text()}`,
);
return null;
}
const data = (await res.json()) as {
data: Array<{
iconUri: string;
address: string;
decimals: number;
name: string;
symbol: string;
priceUsd: number;
}>;
};
return data.data[0];
const res = await Bridge.tokens({
client: params.client,
chainId: params.chainId,
tokenAddress: params.tokenAddress,
includePrices: true,
limit: 1,
});
return res[0];
} catch {
return null;
return undefined;
}
}
@@ -1,3 +1,4 @@
import { BarChart3Icon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
import { cookies } from "next/headers";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
@@ -13,8 +14,6 @@ import { PageHeader } from "../_components/PageHeader";
import { ContractHeaderUI } from "./_components/ContractHeader";
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
import { DexScreener } from "./_components/dex-screener";
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
import { RecentTransfers } from "./_components/RecentTransfers";
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
@@ -40,7 +39,7 @@ export async function ERC20PublicPage(props: {
}),
fetchTokenInfoFromBridge({
chainId: props.serverContract.chain.id,
clientId: props.clientContract.client.clientId,
client: props.serverContract.client,
tokenAddress: props.serverContract.address,
}),
resolveFunctionSelectors(props.serverContract),
@@ -80,7 +79,7 @@ export async function ERC20PublicPage(props: {
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
<PageHeader containerClassName="max-w-7xl" />
<div className="border-b">
<div className="border-b border-dashed">
<div className="container max-w-7xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
@@ -102,6 +101,44 @@ export async function ERC20PublicPage(props: {
<div className="h-8" />
{tokenInfoFromUB && (
<div className="container max-w-7xl mb-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 p-6 border rounded-xl bg-card relative">
<TokenInfoSection
icon={DollarSignIcon}
label="Price"
value={
tokenInfoFromUB.prices.USD
? formatPrice(tokenInfoFromUB.prices.USD)
: "N/A"
}
/>
<TokenInfoSection
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
icon={TrendingUpIcon}
label="Market Cap"
value={
tokenInfoFromUB.marketCapUsd
? formatCompactUSD(tokenInfoFromUB.marketCapUsd)
: "N/A"
}
/>
<TokenInfoSection
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
icon={BarChart3Icon}
label="Volume (24h)"
value={
tokenInfoFromUB.volume24hUsd
? formatCompactUSD(tokenInfoFromUB.volume24hUsd)
: "N/A"
}
/>
</div>
</div>
)}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
@@ -118,20 +155,6 @@ export async function ERC20PublicPage(props: {
</div>
)}
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
<div className="container max-w-7xl pb-10">
<DexScreener
chain={
mapChainIdToDexScreenerChainSlug[
props.chainMetadata
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
]
}
contractAddress={props.clientContract.address}
/>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
<ContractAnalyticsOverview
@@ -173,7 +196,6 @@ function BuyEmbed(props: {
return (
<BuyAndSwapEmbed
chain={props.clientContract.chain}
client={props.clientContract.client}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
@@ -206,3 +228,61 @@ async function getActiveClaimConditionWithErrorHandler(
}
type ActiveClaimCondition = Awaited<ReturnType<typeof getActiveClaimCondition>>;
function TokenInfoSection(props: {
label: string;
value: string;
icon: React.FC<{ className?: string }>;
className?: string;
}) {
return (
<div className={props.className}>
<div className="flex mb-5">
<div className="border rounded-full bg-background p-2">
<props.icon className="size-4 text-muted-foreground" />
</div>
</div>
<dl className="space-y-1.5">
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
{props.label}
</dt>
<dd className="text-3xl font-bold text-foreground tracking-tight">
{props.value}
</dd>
</dl>
</div>
);
}
function formatPrice(value: number): string {
if (value < 100) {
return smallValueUSDFormatter.format(value);
}
return largeValueUSDFormatter.format(value);
}
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 6,
roundingMode: "halfEven",
});
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
function formatCompactUSD(value: number): string {
return compactValueUSDFormatter.format(value);
}
@@ -0,0 +1,39 @@
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { cn } from "@/lib/utils";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link className="flex items-center gap-2" href="/team">
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
</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 />
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
import { useState } from "react";
import { Bridge } from "thirdweb";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokensTable } from "./tokens-table";
const client = getClientThirdwebClient();
const pageSize = 20;
export function TokenPage(props: { chains: Bridge.chains.Result }) {
const [page, setPage] = useState(1);
const [chainId, setChainId] = useState(1);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
const tokensQuery = useQuery({
queryKey: [
"tokens",
{
page,
chainId,
sortBy,
search,
},
],
queryFn: () => {
return Bridge.tokens({
client: client,
chainId: chainId,
limit: pageSize,
offset: (page - 1) * pageSize,
sortBy: search ? undefined : sortBy,
query: search ? search : undefined,
});
},
refetchOnMount: false,
refetchOnWindowFocus: false,
});
return (
<div className="pb-20 pt-8">
<div className="container max-w-7xl">
<div className="mb-4 flex gap-4 lg:gap-3 flex-col lg:flex-row">
<BridgeNetworkSelector
client={client}
chainId={chainId}
onChange={setChainId}
className="rounded-xl bg-card lg:w-fit min-w-[280px]"
popoverContentClassName="!w-[300px] rounded-xl overflow-hidden"
chains={props.chains}
/>
<div className="flex gap-3">
<SortButton
label="Popular"
onClick={() => {
setSortBy("market_cap");
setSearch("");
}}
isSelected={sortBy === "market_cap" && !search}
icon={ActivityIcon}
/>
<SortButton
label="Trending"
onClick={() => {
setSortBy("volume");
setSearch("");
}}
isSelected={sortBy === "volume" && !search}
icon={TrendingUpIcon}
/>
</div>
<div className="relative w-full lg:w-[420px] ml-auto">
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search Tokens"
value={search}
className="rounded-xl bg-card flex-1 pl-9"
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<TokensTable
pageSize={pageSize}
tokens={tokensQuery.data ?? []}
isFetching={tokensQuery.isFetching}
pagination={{
onNext: () => setPage(page + 1),
onPrevious: () => setPage(page - 1),
nextDisabled: !!(
tokensQuery.data && tokensQuery.data.length < pageSize
),
previousDisabled: page === 1,
}}
/>
</div>
</div>
);
}
function SortButton(props: {
label: string;
onClick: () => void;
isSelected: boolean;
icon: React.FC<{ className?: string }>;
}) {
return (
<Button
variant="outline"
onClick={props.onClick}
className={cn(
"rounded-xl gap-2 bg-card",
props.isSelected && "bg-accent border-active-border",
)}
>
<props.icon className="size-3.5" />
{props.label}
</Button>
);
}
@@ -0,0 +1,195 @@
"use client";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function TokensTable(props: {
tokens: Bridge.TokenWithPrices[];
pageSize: number;
isFetching: boolean;
pagination: {
onNext: () => void;
onPrevious: () => void;
nextDisabled: boolean;
previousDisabled: boolean;
};
}) {
const { tokens, isFetching } = props;
return (
<div className="border rounded-xl overflow-hidden">
<TableContainer className="border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Token</TableHead>
<TableHead>Price</TableHead>
<TableHead>Market cap</TableHead>
<TableHead>Volume (24h)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isFetching
? new Array(props.pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TableRow key={i}>
<TableCell>
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-3 w-36" />
</div>
</div>
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
</TableRow>
))
: tokens.length > 0
? tokens.map((token) => {
const price = token.prices.USD;
return (
<TableRow
key={`${token.chainId}-${token.address}`}
className="hover:bg-accent/50"
linkBox
>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage
src={token.iconUri ?? undefined}
alt={token.symbol}
/>
<AvatarFallback>
{token.symbol?.slice(0, 2)?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1.5">
<span className="font-medium leading-none text-base">
{token.symbol}
</span>
<Link
href={
token.address.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLowerCase()
? `/${token.chainId}`
: `/${token.chainId}/${token.address}`
}
prefetch={false}
className="before:absolute before:inset-0"
>
<span className="text-sm text-muted-foreground leading-none">
{token.name}
</span>
</Link>
</div>
</div>
</TableCell>
<TableCell>
{price ? formatPrice(price) : "N/A"}
</TableCell>
<TableCell>
{token.marketCapUsd
? formatUsdCompact(token.marketCapUsd)
: "N/A"}
</TableCell>
<TableCell>
{token.volume24hUsd
? formatUsdCompact(token.volume24hUsd)
: "N/A"}
</TableCell>
</TableRow>
);
})
: null}
</TableBody>
</Table>
</TableContainer>
{tokens.length === 0 && !isFetching && (
<div className="flex items-center justify-center py-20 text-muted-foreground w-full text-sm ">
No tokens found
</div>
)}
<div className="flex justify-end p-4 gap-3 border-t bg-card">
<Button
variant="outline"
className="gap-2 rounded-full"
size="sm"
onClick={() => props.pagination.onPrevious()}
disabled={props.pagination.previousDisabled || isFetching}
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
variant="outline"
className="gap-2 rounded-full"
size="sm"
onClick={() => props.pagination.onNext()}
disabled={props.pagination.nextDisabled || isFetching}
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
);
}
function formatPrice(value: number): string {
if (value < 100) {
return smallValueUSDFormatter.format(value);
}
return largeValueUSDFormatter.format(value);
}
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 6,
roundingMode: "halfEven",
});
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 2,
roundingMode: "halfEven",
});
function formatUsdCompact(value: number): string {
return compactValueUSDFormatter.format(value);
}
@@ -0,0 +1,61 @@
import { BringToFrontIcon } from "lucide-react";
import type { Metadata } from "next";
import { unstable_cache } from "next/cache";
import Link from "next/link";
import { Bridge } from "thirdweb";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { PageHeader } from "./components/header";
import { TokenPage } from "./components/token-page";
const title = "Tokens | thirdweb";
const description = "Discover and swap any tokens on any chain, instantly";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
export default async function Page() {
const chains = await getBridgeSupportedChains();
return (
<div>
<PageHeader />
<div className="border-b py-10 ">
<div className="container max-w-7xl">
<div className="flex mb-4">
<div className="rounded-full bg-card p-2.5 border">
<BringToFrontIcon className="size-6 text-muted-foreground" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tighter mb-1">
Discover and swap any tokens on any chain, instantly
</h1>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://thirdweb.com/monetize/bridge"
target="_blank"
>
Powered by thirdweb bridge
</Link>
</div>
</div>
<TokenPage chains={chains} />
</div>
);
}
const getBridgeSupportedChains = unstable_cache(
async () => {
const chains = await Bridge.chains({ client: serverThirdwebClient });
return chains;
},
["bridge-supported-chains"],
{
revalidate: 60 * 60, // 1 hour
},
);
@@ -1,5 +1,4 @@
import { ImageResponse } from "next/og";
import { useId } from "react";
import { download } from "thirdweb/storage";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
@@ -19,8 +18,8 @@ export const size = {
export const contentType = "image/png";
const TWLogo: React.FC = () => {
const cipId = useId();
const linearGradientId = useId();
const cipId = "og-tw-logo-clip-path";
const linearGradientId = "og-tw-logo-linear-gradient";
return (
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
@@ -103,15 +103,12 @@ export default async function TeamLayout(props: {
>
{props.children}
</TeamSidebarLayout>
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
label="Get Help"
team={team}
/>
</div>
<CustomChatButton
authToken={authToken}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
team={team}
/>
</div>
</SidebarProvider>
);
@@ -1,6 +1,6 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import { ArrowUpRightIcon, InfoIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { Button } from "@/components/ui/button";
import { NebulaIcon } from "@/icons/NebulaIcon";
@@ -24,6 +24,7 @@ export function EmptyStateChatPageContent(props: {
}) {
return (
<div className="overflow-hidden py-10 lg:py-16">
<AIUsageBanner />
{props.showAurora && (
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--primary-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--primary-foreground)/10%)]" />
)}
@@ -165,3 +166,35 @@ const Aurora: React.FC<AuroraProps> = ({ className }) => {
/>
);
};
function AIUsageBanner() {
return (
<div className="absolute top-0 left-0 right-0 z-10 flex justify-center px-4 pt-4">
<div className="max-w-4xl">
<div className="relative overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-50/50 p-3 shadow-sm dark:border-blue-800 dark:from-blue-950/50 dark:to-blue-950/20">
<div className="relative flex items-center gap-3 px-2">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<InfoIcon className="size-3.5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 text-sm">
<p className="text-blue-800 dark:text-blue-200">
thirdweb AI usage is billed based on number of tokens used. See
the{" "}
<a
href="https://thirdweb.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 underline decoration-blue-300 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-500 dark:text-blue-300 dark:decoration-blue-600 dark:hover:text-blue-200 dark:hover:decoration-blue-400"
>
pricing page
</a>
.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -77,15 +77,12 @@ export default async function ProjectLayout(props: {
{props.children}
</ProjectSidebarLayout>
</div>
<div className="fixed right-4 bottom-4 z-50">
<CustomChatButton
authToken={authToken}
clientId={project.publishableKey}
examplePrompts={siwaExamplePrompts}
label="Ask AI Assistant"
team={team}
/>
</div>
<CustomChatButton
authToken={authToken}
clientId={project.publishableKey}
examplePrompts={siwaExamplePrompts}
team={team}
/>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
</SidebarProvider>
);
@@ -3,7 +3,6 @@
import type { TokenInfo } from "thirdweb/react";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { bridgeAppThirdwebClient } from "../../constants";
export function UniversalBridgeEmbed({
chainId,
@@ -17,9 +16,8 @@ export function UniversalBridgeEmbed({
const chain = useV5DashboardChain(chainId || 1);
return (
<div className="lg:w-[400px]">
<div className="w-full lg:w-[400px]">
<BuyAndSwapEmbed
client={bridgeAppThirdwebClient}
chain={chain}
buyAmount={amount}
tokenAddress={token?.address}
@@ -0,0 +1,39 @@
import Link from "next/link";
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
import { cn } from "@/lib/utils";
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-dashed">
<header
className={cn(
"container flex max-w-7xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link className="flex items-center gap-2" href="/team">
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
</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 />
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
</div>
</header>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function BridgeLayout({
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"h-screen w-screen bg-background font-sans antialiased",
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
+28 -37
View File
@@ -1,10 +1,12 @@
import { ArrowUpRightIcon } from "lucide-react";
import { cn } from "@workspace/ui/lib/utils";
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 { AppFooter } from "@/components/footers/app-footer";
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
import { PageHeader } from "./components/header";
import { bridgeAppThirdwebClient } from "./constants";
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
@@ -49,8 +51,11 @@ export default async function BridgePage({
}
return (
<div className="relative mx-auto flex h-dvh w-full flex-col items-center justify-center overflow-hidden border py-10">
<main className="container z-10 flex justify-center">
<div className="grow flex flex-col relative overflow-hidden">
<PageHeader />
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
<DotsBackgroundPattern />
<UniversalBridgeEmbed
amount={amount as string}
chainId={chainId ? Number(chainId) : undefined}
@@ -64,42 +69,28 @@ export default async function BridgePage({
: undefined
}
/>
</main>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0 opacity-20"
src="/assets/login/background.svg"
/>
<div className="absolute inset-x-0 bottom-8 z-20">
<div className="container mx-auto px-4">
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-base tracking-tight">
Get Started with thirdweb Bridge
</h3>
<p className="text-muted-foreground text-sm">
thirdweb Bridge allows developers to easily bridge and swap
tokens between chains and wallets.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/bridge"
rel="noopener noreferrer"
target="_blank"
>
Learn More
<ArrowUpRightIcon className="size-4" />
</a>
</div>
</div>
</div>
<div className="bg-background relative z-10">
<AppFooter />
</div>
</div>
);
}
function DotsBackgroundPattern(props: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
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%)",
}}
/>
);
}
@@ -23,7 +23,7 @@ export function EmptyStateChatPageContent(props: {
onLoginClick: undefined | (() => void);
}) {
return (
<div className="overflow-hidden py-10 lg:py-16">
<div className="relative overflow-hidden py-10 lg:py-16">
{props.showAurora && (
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--nebula-pink-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--nebula-pink-foreground)/10%)]" />
)}
+2 -2
View File
@@ -148,12 +148,12 @@ const nextConfig = {
permanent: false,
},
{
source: "/insight",
source: "/insight/:path*",
destination: "https://insight.thirdweb.com/reference",
permanent: false,
},
{
source: "/payments/backend",
source: "/payments/backend/:path*",
destination: "/reference#tag/payments",
permanent: false,
},
-1
View File
@@ -48,7 +48,6 @@
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"x402-next": "^0.6.1",
"zod": "3.25.75"
},
"devDependencies": {
@@ -59,7 +59,9 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
prefill={props.options.prefill}
currency={props.options.currency}
showThirdwebBranding={props.options.showThirdwebBranding}
key={JSON.stringify(props.options)}
key={JSON.stringify({
prefill: props.options.prefill,
})}
persistTokenSelections={false}
/>
)}
@@ -3,7 +3,7 @@
import { useMutation } from "@tanstack/react-query";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { CodeIcon, LockIcon } from "lucide-react";
import { baseSepolia } from "thirdweb/chains";
import { arbitrumSepolia } from "thirdweb/chains";
import {
ConnectButton,
getDefaultToken,
@@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { THIRDWEB_CLIENT } from "../../../../lib/client";
const chain = baseSepolia;
const chain = arbitrumSepolia;
const token = getDefaultToken(chain, "USDC");
export function X402ClientPreview() {
@@ -46,8 +46,8 @@ function ServerCodeExample() {
Next.js Server Code Example
</h2>
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
Use any x402 middleware + the thirdweb facilitator to settle
transactions with our server wallet.
Create middleware with the thirdweb facilitator to settle transactions
with your server wallet.
</p>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
@@ -57,28 +57,41 @@ function ServerCodeExample() {
className="h-full rounded-none border-none"
code={`// src/middleware.ts
import { facilitator } from "thirdweb/x402";
import { facilitator, settlePayment } from "thirdweb/x402";
import { createThirdwebClient } from "thirdweb";
import { paymentMiddleware } from "x402-next";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "0xYourWalletAddress",
});
export const middleware = paymentMiddleware(
"0xYourWalletAddress",
{
"/api/paid-endpoint": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
},
},
facilitator({
client,
serverWalletAddress: "0xYourServerWalletAddress",
}),
);
export async function middleware(request: NextRequest) {
const method = request.method.toUpperCase();
const resourceUrl = request.nextUrl.toString();
const paymentData = request.headers.get("X-PAYMENT");
const result = await settlePayment({
resourceUrl,
method,
paymentData,
payTo: "0xYourWalletAddress",
network: "eip155:11155111", // or any other chain id
price: "$0.01", // can also be a ERC20 token amount
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// payment successful, execute the request
return NextResponse.next();
}
// otherwise, request payment
return NextResponse.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
// Configure which paths the middleware should run on
export const config = {
+44 -19
View File
@@ -1,34 +1,59 @@
import { type NextRequest, NextResponse } from "next/server";
import { createThirdwebClient } from "thirdweb";
import { facilitator } from "thirdweb/x402";
import { paymentMiddleware } from "x402-next";
import { arbitrumSepolia } from "thirdweb/chains";
import { facilitator, settlePayment } from "thirdweb/x402";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
});
const chain = arbitrumSepolia;
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
const ENGINE_VAULT_ACCESS_TOKEN = process.env
.ENGINE_VAULT_ACCESS_TOKEN as string;
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
export const middleware = paymentMiddleware(
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
{
"/api/paywall": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
const twFacilitator = facilitator({
baseUrl: `${API_URL}/v1/payments/x402`,
client,
serverWalletAddress: BACKEND_WALLET_ADDRESS,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const method = request.method.toUpperCase();
const resourceUrl = `${request.nextUrl.protocol}//${request.nextUrl.host}${pathname}`;
const paymentData = request.headers.get("X-PAYMENT");
const result = await settlePayment({
resourceUrl,
method,
paymentData,
payTo: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
network: `eip155:${chain.id}`,
price: "$0.01",
routeConfig: {
description: "Access to paid content",
},
},
facilitator({
baseUrl: `${API_URL}/v1/payments/x402`,
client,
serverWalletAddress: BACKEND_WALLET_ADDRESS,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
}),
);
facilitator: twFacilitator,
});
if (result.status === 200) {
// payment successful, execute the request
const response = NextResponse.next();
for (const [key, value] of Object.entries(result.responseHeaders)) {
response.headers.set(key, value);
}
return response;
}
// otherwise, request payment
return NextResponse.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
// Configure which paths the middleware should run on
export const config = {
+6
View File
@@ -13,6 +13,7 @@ import {
TypeScriptIcon,
} from "@/icons";
import SwapWidgetImage from "./swap-dark.png";
import SwapWidgetImageLight from "./swap-light.png";
export const metadata = createMetadata({
image: {
@@ -55,7 +56,12 @@ function Example() {
}
```
<div className='dark-only'>
<DocImage src={SwapWidgetImage} />
</div>
<div className='light-only'>
<DocImage src={SwapWidgetImageLight} />
</div>
## Live Playground
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+43 -22
View File
@@ -41,40 +41,61 @@ const response = await fetchWithPay('https://api.example.com/paid-endpoint');
## Server Side
To make your API calls payable, you can use any x402 middleware library like x402-hono, x402-next, x402-express, etc.
To make your API calls payable, you can use the `settlePayment` function in a middleware or in your endpoint directly.
Then, use the `facilitator` configuratino function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
Use the `facilitator` configuration function settle transactions with your thirdweb server wallet gaslessly and pass it to the `settlePayment` function.
Here's an example with Next.js:
Here's an example with a Next.js middleware:
```typescript
import { createThirdwebClient } from "thirdweb";
import { facilitator } from "thirdweb/x402";
import { paymentMiddleware } from "x402-next";
import { facilitator, settlePayment } from "thirdweb/x402";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "0xYourWalletAddress",
});
export const middleware = paymentMiddleware(
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
{
"/api/paid-endpoint": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
export async function middleware(request: NextRequest) {
const method = request.method.toUpperCase();
const resourceUrl = request.nextUrl.toString();
const paymentData = request.headers.get("X-PAYMENT");
const result = await settlePayment({
resourceUrl,
method,
paymentData,
payTo: "0xYourWalletAddress",
network: "eip155:1", // or any other chain id in CAIP2 format: "eip155:<chain_id>"
price: "$0.01", // can also be a ERC20 token amount
routeConfig: {
description: "Access to paid content",
},
},
facilitator({
client,
serverWalletAddress: "0x1234567890123456789012345678901234567890",
}),
);
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// payment successful, execute the request
const response = NextResponse.next();
// optionally set the response headers back to the client
for (const [key, value] of Object.entries(result.responseHeaders)) {
response.headers.set(key, value);
}
return response;
}
// otherwise, request payment
return NextResponse.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
// Configure which paths the middleware should run on
export const config = {
matcher: ["/api/paid-endpoint"],
};
```
You can also use the `verifyPayment` function to verify the payment before settling it. This lets you do the work that requires payment first and then settle the payment.
+14
View File
@@ -1,5 +1,19 @@
# @thirdweb-dev/nebula
## 0.2.53
### Patch Changes
- Updated dependencies [[`a94f229`](https://github.com/thirdweb-dev/js/commit/a94f22928a662a5aff7a203fc2d383d9fa0907ec), [`5249cb7`](https://github.com/thirdweb-dev/js/commit/5249cb7409a8486346fe428f824c81dd90845555)]:
- thirdweb@5.108.0
## 0.2.52
### Patch Changes
- Updated dependencies [[`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2), [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa)]:
- thirdweb@5.107.1
## 0.2.51
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.51"
"version": "0.2.53"
}
+26
View File
@@ -1,5 +1,31 @@
# thirdweb
## 5.108.0
### Minor Changes
- [#8091](https://github.com/thirdweb-dev/js/pull/8091) [`5249cb7`](https://github.com/thirdweb-dev/js/commit/5249cb7409a8486346fe428f824c81dd90845555) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Accept arbitrary chain ids for x402 payments with new settlePayment() and verifyPayment() backend utility functions
### Patch Changes
- [#8100](https://github.com/thirdweb-dev/js/pull/8100) [`a94f229`](https://github.com/thirdweb-dev/js/commit/a94f22928a662a5aff7a203fc2d383d9fa0907ec) Thanks [@MananTank](https://github.com/MananTank)! - Update the `onSuccess`, `onError`, and `onCancel` callback props of the `BuyWidget` to be called with the `quote` object
```tsx
<BuyWidget
onSuccess={(quote) => console.log("Swap completed:", quote)}
onError={(error, quote) => console.error("Swap failed:", error, quote)}
onCancel={(quote) => console.log("Swap cancelled:", quote)}
/>
```
## 5.107.1
### Patch Changes
- [#8080](https://github.com/thirdweb-dev/js/pull/8080) [`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2) Thanks [@MananTank](https://github.com/MananTank)! - SwapWidget UI improvements
- [#8092](https://github.com/thirdweb-dev/js/pull/8092) [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa) Thanks [@0xFirekeeper](https://github.com/0xFirekeeper)! - add zephyr testnet to pre-1559 chains
## 5.107.0
### Minor Changes
+1 -1
View File
@@ -427,5 +427,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.107.0"
"version": "5.108.0"
}
+13
View File
@@ -141,6 +141,8 @@ export async function tokens<
limit,
offset,
includePrices,
sortBy,
query,
} = options;
const clientFetch = getClientFetch(client);
@@ -167,6 +169,13 @@ export async function tokens<
if (includePrices !== undefined) {
url.searchParams.set("includePrices", includePrices.toString());
}
if (sortBy !== undefined) {
url.searchParams.set("sortBy", sortBy);
}
if (query !== undefined) {
url.searchParams.set("query", query);
}
const response = await clientFetch(url.toString());
if (!response.ok) {
@@ -204,6 +213,10 @@ export declare namespace tokens {
offset?: number | null;
/** Whether or not to include prices for the tokens. Setting this to false will speed up the request. */
includePrices?: IncludePrices;
/** Sort by a specific field. */
sortBy?: "newest" | "oldest" | "volume" | "market_cap";
/** search for tokens by token name or symbol */
query?: string;
};
/**
@@ -7,6 +7,8 @@ export type Token = {
symbol: string;
name: string;
iconUri?: string;
marketCapUsd?: number;
volume24hUsd?: number;
};
export type TokenWithPrices = Token & {
+8
View File
@@ -1,5 +1,13 @@
export { decodePayment, encodePayment } from "../x402/encode.js";
export {
facilitator,
type ThirdwebX402FacilitatorConfig,
} from "../x402/facilitator.js";
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
export { settlePayment } from "../x402/settle-payment.js";
export type {
PaymentArgs,
SettlePaymentResult,
VerifyPaymentResult,
} from "../x402/types.js";
export { verifyPayment } from "../x402/verify-payment.js";
+1
View File
@@ -47,6 +47,7 @@ const FORCE_GAS_PRICE_CHAIN_IDS = [
2020, // Ronin Mainnet
2021, // Ronin Testnet (Saigon)
98866, // Plume mainnet
1417429182, // Wilderworld Zephyr Testnet
];
/**
@@ -200,6 +200,7 @@ export const iconSize = {
"4xl": "128",
lg: "32",
md: "24",
"sm+": "20",
sm: "16",
xl: "48",
xs: "12",
@@ -37,7 +37,7 @@ type UseContractEventsOptions<
*
* ### Using event extensions
*
* The `thirdweb/extesions` export contains event definitions for many popular contracts.
* The `thirdweb/extensions` export contains event definitions for many popular contracts.
* You can use these event definitions to watch for specific events with a type-safe API.
*
* ```jsx
@@ -32,7 +32,7 @@ export function useWaitForReceipt(
},
queryKey: [
"waitForReceipt",
// TODO: here chain can be undfined so we go to a `-1` chain but this feels wrong
// TODO: here chain can be undefined so we go to a `-1` chain but this feels wrong
options?.chain.id || -1,
options?.transactionHash,
] as const,
@@ -289,15 +289,9 @@ const DEFAULT_TOKENS = {
symbol: "USDC",
},
],
"421613": [
"421614": [
{
address: "0xe39Ab88f8A4777030A534146A9Ca3B52bd5D43A3",
icon: wrappedEthIcon,
name: "Wrapped Ether",
symbol: "WETH",
},
{
address: "0xfd064A18f3BF249cf1f87FC203E90D8f650f2d63",
address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
icon: usdcIcon,
name: "USD Coin",
symbol: "USDC",
@@ -82,17 +82,17 @@ export interface BridgeOrchestratorProps {
/**
* Called when the flow is completed successfully
*/
onComplete: () => void;
onComplete: (quote: BridgePrepareResult) => void;
/**
* Called when the flow encounters an error
*/
onError: (error: Error) => void;
onError: (error: Error, quote: BridgePrepareResult | undefined) => void;
/**
* Called when the user cancels the flow
*/
onCancel: () => void;
onCancel: (quote: BridgePrepareResult | undefined) => void;
/**
* Connect options for wallet connection
@@ -189,19 +189,22 @@ export function BridgeOrchestrator({
}, [send, uiOptions.mode]);
// Handle post-buy transaction completion
const handlePostBuyTransactionComplete = useCallback(() => {
onComplete?.();
send({ type: "RESET" });
}, [onComplete, send]);
const handlePostBuyTransactionComplete = useCallback(
(quote: BridgePrepareResult) => {
onComplete?.(quote);
send({ type: "RESET" });
},
[onComplete, send],
);
// Handle errors
const handleError = useCallback(
(error: Error) => {
console.error(error);
onError?.(error);
onError?.(error, state.context.quote);
send({ error, type: "ERROR_OCCURRED" });
},
[onError, send],
[onError, send, state.context.quote],
);
// Handle payment method selection
@@ -227,10 +230,13 @@ export function BridgeOrchestrator({
// Handle execution complete
const handleExecutionComplete = useCallback(
(completedStatuses: CompletedStatusResult[]) => {
(
completedStatuses: CompletedStatusResult[],
quote: BridgePrepareResult,
) => {
send({ completedStatuses, type: "EXECUTION_COMPLETE" });
if (uiOptions.mode !== "transaction") {
onComplete?.();
onComplete?.(quote);
}
},
[send, onComplete, uiOptions.mode],
@@ -241,6 +247,8 @@ export function BridgeOrchestrator({
send({ type: "RETRY" });
}, [send]);
const quote = state.context.quote;
// Handle requirements resolved from FundWallet and DirectPayment
const handleRequirementsResolved = useCallback(
(amount: string, token: TokenWithPrices, receiverAddress: Address) => {
@@ -263,7 +271,7 @@ export function BridgeOrchestrator({
error={state.context.currentError}
onCancel={() => {
send({ type: "RESET" });
onCancel?.();
onCancel?.(quote);
}}
onRetry={handleRetry}
/>
@@ -369,31 +377,33 @@ export function BridgeOrchestrator({
/>
)}
{state.value === "execute" &&
state.context.quote &&
state.context.request && (
<StepRunner
autoStart={true}
client={client}
onBack={() => {
send({ type: "BACK" });
}}
onCancel={onCancel}
onComplete={handleExecutionComplete}
request={state.context.request}
wallet={state.context.selectedPaymentMethod?.payerWallet}
windowAdapter={webWindowAdapter}
/>
)}
{state.value === "execute" && quote && state.context.request && (
<StepRunner
autoStart={true}
client={client}
onBack={() => {
send({ type: "BACK" });
}}
onCancel={() => {
onCancel(quote);
}}
onComplete={(completedStatuses) => {
handleExecutionComplete(completedStatuses, quote);
}}
request={state.context.request}
wallet={state.context.selectedPaymentMethod?.payerWallet}
windowAdapter={webWindowAdapter}
/>
)}
{state.value === "success" &&
state.context.quote &&
quote &&
state.context.completedStatuses && (
<SuccessScreen
client={client}
completedStatuses={state.context.completedStatuses}
onDone={handleDoneOrContinueClick}
preparedQuote={state.context.quote}
preparedQuote={quote}
uiOptions={uiOptions}
windowAdapter={webWindowAdapter}
hasPaymentId={!!paymentLinkId}
@@ -402,9 +412,10 @@ export function BridgeOrchestrator({
{state.value === "post-buy-transaction" &&
uiOptions.mode === "transaction" &&
quote &&
uiOptions.transaction && (
<ExecutingTxScreen
closeModal={handlePostBuyTransactionComplete}
closeModal={() => handlePostBuyTransactionComplete(quote)}
onTxSent={() => {
// Do nothing
}}
@@ -23,6 +23,7 @@ import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProv
import type { Theme } from "../../../core/design-system/index.js";
import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js";
import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js";
import type { BridgePrepareResult } from "../../../core/hooks/useBridgePrepare.js";
import type { SupportedTokens } from "../../../core/utils/defaultTokens.js";
import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js";
import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js";
@@ -32,6 +33,11 @@ import type { LocaleId } from "../types.js";
import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";
type BuyOrOnrampPrepareResult = Extract<
BridgePrepareResult,
{ type: "buy" | "onramp" }
>;
export type BuyWidgetProps = {
/**
* Customize the supported tokens that users can pay with.
@@ -155,17 +161,17 @@ export type BuyWidgetProps = {
/**
* Callback triggered when the purchase is successful.
*/
onSuccess?: () => void;
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
/**
* Callback triggered when the purchase encounters an error.
*/
onError?: (error: Error) => void;
onError?: (error: Error, quote: BuyOrOnrampPrepareResult | undefined) => void;
/**
* Callback triggered when the user cancels the purchase.
*/
onCancel?: () => void;
onCancel?: (quote: BuyOrOnrampPrepareResult | undefined) => void;
/**
* @hidden
@@ -447,14 +453,23 @@ export function BuyWidget(props: BuyWidgetProps) {
client={props.client}
connectLocale={localeQuery.data}
connectOptions={props.connectOptions}
onCancel={() => {
props.onCancel?.();
onCancel={(quote) => {
// type guard
if (quote?.type === "buy" || quote?.type === "onramp") {
props.onCancel?.(quote);
}
}}
onComplete={() => {
props.onSuccess?.();
onComplete={(quote) => {
// type guard
if (quote?.type === "buy" || quote?.type === "onramp") {
props.onSuccess?.(quote);
}
}}
onError={(err: Error) => {
props.onError?.(err);
onError={(err: Error, quote) => {
// type guard
if (quote?.type === "buy" || quote?.type === "onramp") {
props.onError?.(err, quote);
}
}}
paymentLinkId={props.paymentLinkId}
paymentMethods={props.paymentMethods}
@@ -36,6 +36,7 @@ export function SearchInput(props: {
variant="outline"
placeholder={props.placeholder}
value={props.value}
sm
style={{
paddingLeft: "44px",
}}
@@ -162,6 +162,10 @@ export type SwapWidgetProps = {
* @default true
*/
persistTokenSelections?: boolean;
/**
* Called when the user disconnects the active wallet
*/
onDisconnect?: () => void;
};
/**
@@ -325,46 +329,11 @@ function SwapWidgetContent(props: SwapWidgetProps) {
});
const [buyToken, setBuyToken] = useState<TokenSelection | undefined>(() => {
if (props.prefill?.buyToken) {
return {
tokenAddress:
props.prefill.buyToken.tokenAddress ||
getAddress(NATIVE_TOKEN_ADDRESS),
chainId: props.prefill.buyToken.chainId,
};
}
if (!isPersistEnabled) {
return undefined;
}
const lastUsedBuyToken = getLastUsedTokens()?.buyToken;
// the token that will be set as initial value of sell token
const sellToken = getInitialSellToken(
props.prefill,
getLastUsedTokens()?.sellToken,
);
// if both tokens are same, ignore "buyToken", keep "sellToken"
if (
lastUsedBuyToken &&
sellToken &&
lastUsedBuyToken.tokenAddress.toLowerCase() ===
sellToken.tokenAddress.toLowerCase() &&
lastUsedBuyToken.chainId === sellToken.chainId
) {
return undefined;
}
return lastUsedBuyToken;
return getInitialTokens(props.prefill, isPersistEnabled).buyToken;
});
const [sellToken, setSellToken] = useState<TokenSelection | undefined>(() => {
return getInitialSellToken(
props.prefill,
isPersistEnabled ? getLastUsedTokens()?.sellToken : undefined,
);
return getInitialTokens(props.prefill, isPersistEnabled).sellToken;
});
// persist selections to localStorage whenever they change
@@ -394,6 +363,7 @@ function SwapWidgetContent(props: SwapWidgetProps) {
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
return (
<SwapUI
onDisconnect={props.onDisconnect}
showThirdwebBranding={
props.showThirdwebBranding === undefined
? true
@@ -533,17 +503,63 @@ function SwapWidgetContent(props: SwapWidgetProps) {
return null;
}
function getInitialSellToken(
function getInitialTokens(
prefill: SwapWidgetProps["prefill"],
lastUsedSellToken: TokenSelection | undefined,
) {
if (prefill?.sellToken) {
isPersistEnabled: boolean,
): {
buyToken: TokenSelection | undefined;
sellToken: TokenSelection | undefined;
} {
const lastUsedTokens = isPersistEnabled ? getLastUsedTokens() : undefined;
const buyToken = prefill?.buyToken
? {
tokenAddress:
prefill.buyToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
chainId: prefill.buyToken.chainId,
}
: lastUsedTokens?.buyToken;
const sellToken = prefill?.sellToken
? {
tokenAddress:
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
chainId: prefill.sellToken.chainId,
}
: lastUsedTokens?.sellToken;
// if both tokens are same
if (
buyToken &&
sellToken &&
buyToken.tokenAddress?.toLowerCase() ===
sellToken.tokenAddress?.toLowerCase() &&
buyToken.chainId === sellToken.chainId
) {
// if sell token prefill is specified, ignore buy token
if (prefill?.sellToken) {
return {
buyToken: undefined,
sellToken: sellToken,
};
}
// if buy token prefill is specified, ignore sell token
if (prefill?.buyToken) {
return {
buyToken: buyToken,
sellToken: undefined,
};
}
// if none of the two are specified via prefill, keep buy token
return {
tokenAddress:
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
chainId: prefill.sellToken.chainId,
buyToken: buyToken,
sellToken: undefined,
};
}
return lastUsedSellToken;
return {
buyToken: buyToken,
sellToken: sellToken,
};
}
@@ -1,35 +0,0 @@
import type { Theme } from "../../../../core/design-system/index.js";
import { Text } from "../../components/text.js";
export function DecimalRenderer(props: {
value: string;
color: keyof Theme["colors"];
weight: 400 | 500 | 600 | 700;
integerSize: "md" | "sm";
fractionSize: "sm" | "xs";
}) {
if (Number(props.value) > 1000) {
return (
<Text size={props.integerSize} color={props.color} weight={props.weight}>
{compactFormatter.format(Number(props.value))}
</Text>
);
}
const [integerPart, fractionPart] = props.value.split(".");
return (
<div style={{ display: "flex", alignItems: "baseline" }}>
<Text size={props.integerSize} color={props.color} weight={props.weight}>
{integerPart}
</Text>
<Text size={props.fractionSize} color={props.color} weight={props.weight}>
.{fractionPart || "00"}
</Text>
</div>
);
}
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 2,
});
@@ -4,6 +4,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
import {
fontSize,
iconSize,
radius,
spacing,
} from "../../../../core/design-system/index.js";
import { Container, Line, ModalHeader } from "../../components/basic.js";
@@ -21,6 +22,7 @@ type SelectBuyTokenProps = {
client: ThirdwebClient;
onSelectChain: (chain: BridgeChain) => void;
selectedChain: BridgeChain | undefined;
isMobile: boolean;
};
/**
@@ -56,11 +58,15 @@ export function SelectBridgeChainUI(
});
return (
<div>
<Container px="md" py="md+">
<ModalHeader onBack={props.onBack} title="Select Chain" />
</Container>
<Line />
<Container fullHeight flex="column">
{props.isMobile && (
<>
<Container px="md" py="md+">
<ModalHeader onBack={props.onBack} title="Select Chain" />
</Container>
<Line />
</>
)}
<Spacer y="md" />
@@ -79,10 +85,12 @@ export function SelectBridgeChainUI(
<Spacer y="sm" />
<Container
expand
px="md"
gap={props.isMobile ? undefined : "xxs"}
flex="column"
style={{
height: "400px",
maxHeight: props.isMobile ? "400px" : "none",
overflowY: "auto",
scrollbarWidth: "none",
paddingBottom: spacing.md,
@@ -95,6 +103,7 @@ export function SelectBridgeChainUI(
client={props.client}
onClick={() => props.onSelectChain(chain)}
isSelected={chain.chainId === props.selectedChain?.chainId}
isMobile={props.isMobile}
/>
))}
@@ -119,7 +128,7 @@ export function SelectBridgeChainUI(
</div>
)}
</Container>
</div>
</Container>
);
}
@@ -144,6 +153,7 @@ function ChainButton(props: {
client: ThirdwebClient;
onClick: () => void;
isSelected: boolean;
isMobile: boolean;
}) {
return (
<Button
@@ -152,18 +162,21 @@ function ChainButton(props: {
style={{
justifyContent: "flex-start",
fontWeight: 500,
fontSize: fontSize.md,
fontSize: props.isMobile ? fontSize.md : fontSize.sm,
border: "1px solid transparent",
padding: `${spacing.sm} ${spacing.sm}`,
padding: !props.isMobile ? `${spacing.xs} ${spacing.xs}` : undefined,
}}
gap="sm"
onClick={props.onClick}
gap="sm"
>
<Img
src={props.chain.icon}
client={props.client}
width={iconSize.lg}
height={iconSize.lg}
width={props.isMobile ? iconSize.lg : iconSize.md}
height={props.isMobile ? iconSize.lg : iconSize.md}
style={{
borderRadius: radius.full,
}}
/>
{cleanedChainName(props.chain.name)}
</Button>
@@ -1,9 +1,9 @@
import { DiscIcon } from "@radix-ui/react-icons";
import { useMemo, useState } from "react";
import type { Token } from "../../../../../bridge/index.js";
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { toTokens } from "../../../../../utils/units.js";
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
import {
fontSize,
iconSize,
@@ -12,15 +12,15 @@ import {
} from "../../../../core/design-system/index.js";
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
import { Container, Line, ModalHeader } from "../../components/basic.js";
import { Container, noScrollBar } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { Img } from "../../components/Img.js";
import { Skeleton } from "../../components/Skeleton.js";
import { Spacer } from "../../components/Spacer.js";
import { Spinner } from "../../components/Spinner.js";
import { Text } from "../../components/text.js";
import { DecimalRenderer } from "./common.js";
import { StyledDiv } from "../../design-system/elements.js";
import { useIsMobile } from "../../hooks/useisMobile.js";
import { SearchInput } from "./SearchInput.js";
import { SelectChainButton } from "./SelectChainButton.js";
import { SelectBridgeChain } from "./select-chain.js";
@@ -31,6 +31,7 @@ import {
useTokenBalances,
useTokens,
} from "./use-tokens.js";
import { tokenAmountFormatter } from "./utils.js";
/**
* @internal
@@ -138,6 +139,7 @@ function SelectTokenUI(
showMore: (() => void) | undefined;
},
) {
const isMobile = useIsMobile();
const [screen, setScreen] = useState<"select-chain" | "select-token">(
"select-token",
);
@@ -180,184 +182,70 @@ function SelectTokenUI(
});
}, [otherTokens]);
const noTokensFound =
!props.isFetching &&
sortedOtherTokens.length === 0 &&
props.ownedTokens.length === 0;
if (!isMobile) {
return (
<Container
style={{
display: "grid",
gridTemplateColumns: "300px 1fr",
height: "100%",
}}
>
<LeftContainer>
<SelectBridgeChain
onBack={() => setScreen("select-token")}
client={props.client}
isMobile={false}
onSelectChain={(chain) => {
props.setSelectedChain(chain);
setScreen("select-token");
}}
selectedChain={props.selectedChain}
/>
</LeftContainer>
<Container flex="column" relative scrollY>
<TokenSelectionScreen
onSelectToken={props.setSelectedToken}
isMobile={false}
selectedToken={props.selectedToken}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
onSelectChain={() => setScreen("select-chain")}
client={props.client}
search={props.search}
setSearch={props.setSearch}
/>
</Container>
</Container>
);
}
if (screen === "select-token") {
return (
<Container>
<Container px="md" py="md+">
<ModalHeader onBack={props.onBack} title="Select Token" />
</Container>
<Line />
{!props.selectedChain && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "400px",
}}
>
<Spinner color="secondaryText" size="xl" />
</div>
)}
{props.selectedChain && (
<>
<Container p="md">
<SelectChainButton
onClick={() => setScreen("select-chain")}
selectedChain={props.selectedChain}
client={props.client}
/>
</Container>
{/* search */}
<Container px="md">
<SearchInput
value={props.search}
onChange={props.setSearch}
placeholder="Search by Token or Address"
/>
</Container>
<Spacer y="sm" />
<Container px="md">
<Container
flex="column"
style={{
height: "400px",
overflowY: "auto",
scrollbarWidth: "none",
paddingBottom: spacing.md,
}}
>
{props.isFetching &&
new Array(20).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TokenButtonSkeleton key={i} />
))}
{!props.isFetching && sortedOwnedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
>
<WalletDotIcon size={iconSize.sm} />
<Text
size="sm"
color="secondaryText"
weight={500}
style={{
overflow: "unset",
}}
>
Your Tokens
</Text>
</Container>
)}
{!props.isFetching &&
sortedOwnedTokens.map((token) => (
<TokenButton
key={token.token_address}
token={token}
client={props.client}
onSelect={props.setSelectedToken}
isSelected={
!!props.selectedToken &&
props.selectedToken.tokenAddress.toLowerCase() ===
token.token_address.toLowerCase() &&
token.chain_id === props.selectedToken.chainId
}
/>
))}
{!props.isFetching && sortedOwnedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
style={{
marginTop: spacing.sm,
}}
>
<CoinsIcon size={iconSize.sm} />
<Text
size="sm"
color="secondaryText"
weight={500}
style={{
overflow: "unset",
}}
>
Other Tokens
</Text>
</Container>
)}
{!props.isFetching &&
sortedOtherTokens.map((token) => (
<TokenButton
key={token.address}
token={token}
client={props.client}
onSelect={props.setSelectedToken}
isSelected={
props.selectedToken?.tokenAddress.toLowerCase() ===
token.address.toLowerCase()
}
/>
))}
{props.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.showMore?.();
}}
>
Load More
</Button>
)}
{noTokensFound && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text size="md" color="secondaryText">
No Tokens Found
</Text>
</div>
)}
</Container>
</Container>
</>
)}
</Container>
<TokenSelectionScreen
onSelectToken={props.setSelectedToken}
selectedToken={props.selectedToken}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
isMobile={true}
onSelectChain={() => setScreen("select-chain")}
client={props.client}
search={props.search}
setSearch={props.setSearch}
/>
);
}
if (screen === "select-chain") {
return (
<SelectBridgeChain
isMobile={true}
onBack={() => setScreen("select-token")}
client={props.client}
onSelectChain={(chain) => {
@@ -379,7 +267,7 @@ function TokenButtonSkeleton() {
display: "flex",
alignItems: "center",
gap: spacing.sm,
padding: `${spacing.sm} ${spacing.sm}`,
padding: `${spacing.xs} ${spacing.xs}`,
height: "70px",
}}
>
@@ -398,6 +286,7 @@ function TokenButton(props: {
onSelect: (tokenWithPrices: TokenSelection) => void;
isSelected: boolean;
}) {
const theme = useCustomTheme();
const tokenBalanceInUnits =
"balance" in props.token
? toTokens(BigInt(props.token.balance), props.token.decimals)
@@ -416,7 +305,7 @@ function TokenButton(props: {
fontWeight: 500,
fontSize: fontSize.md,
border: "1px solid transparent",
padding: `${spacing.sm} ${spacing.xs}`,
padding: `${spacing.xs} ${spacing.xs}`,
textAlign: "left",
lineHeight: "1.5",
borderRadius: radius.lg,
@@ -451,7 +340,14 @@ function TokenButton(props: {
}}
fallback={
<Container color="secondaryText">
<DiscIcon width={iconSize.lg} height={iconSize.lg} />
<Container
style={{
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
borderRadius: radius.full,
width: `${iconSize.lg}px`,
height: `${iconSize.lg}px`,
}}
/>
</Container>
}
/>
@@ -473,18 +369,15 @@ function TokenButton(props: {
<Text size="md" color="primaryText" weight={500}>
{props.token.symbol}
</Text>
{"balance" in props.token && (
<DecimalRenderer
integerSize="md"
fractionSize="sm"
value={formatTokenAmount(
BigInt(props.token.balance),
props.token.decimals,
3,
<Text size="md" color="primaryText">
{tokenAmountFormatter.format(
Number(
toTokens(BigInt(props.token.balance), props.token.decimals),
),
)}
color="primaryText"
weight={500}
/>
</Text>
)}
</Container>
<Container
@@ -495,9 +388,8 @@ function TokenButton(props: {
}}
>
<Text
size="sm"
size="xs"
color="secondaryText"
weight={400}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
@@ -509,16 +401,9 @@ function TokenButton(props: {
</Text>
{usdValue && (
<Container flex="row">
<Text size="sm" color="secondaryText" weight={400}>
$
<Text size="xs" color="secondaryText" weight={400}>
${usdValue.toFixed(2)}
</Text>
<DecimalRenderer
value={usdValue.toFixed(2)}
color="secondaryText"
weight={500}
integerSize="sm"
fractionSize="xs"
/>
</Container>
)}
</Container>
@@ -526,3 +411,223 @@ function TokenButton(props: {
</Button>
);
}
function TokenSelectionScreen(props: {
selectedChain: BridgeChain | undefined;
isMobile: boolean;
onSelectChain: () => void;
client: ThirdwebClient;
search: string;
setSearch: (search: string) => void;
isFetching: boolean;
ownedTokens: TokenBalance[];
otherTokens: Token[];
showMore: (() => void) | undefined;
selectedToken: TokenSelection | undefined;
onSelectToken: (token: TokenSelection) => void;
}) {
const noTokensFound =
!props.isFetching &&
props.otherTokens.length === 0 &&
props.ownedTokens.length === 0;
return (
<Container fullHeight flex="column">
<Container px="md" pt="md+">
<Text size="lg" weight={600} color="primaryText" trackingTight>
Select Token
</Text>
<Spacer y="3xs" />
<Text
size="xs"
color="secondaryText"
multiline
style={{
textWrap: "pretty",
maxWidth: "70%",
}}
>
Select a token from the list or search for a token by symbol or
address
</Text>
</Container>
{!props.selectedChain && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "400px",
}}
>
<Spinner color="secondaryText" size="xl" />
</div>
)}
{props.selectedChain && (
<>
{props.isMobile ? (
<Container p="md">
<SelectChainButton
onClick={props.onSelectChain}
selectedChain={props.selectedChain}
client={props.client}
/>
</Container>
) : (
<Spacer y="md" />
)}
{/* search */}
<Container px="md">
<SearchInput
value={props.search}
onChange={props.setSearch}
placeholder="Search by token or address"
/>
</Container>
<Spacer y="xs" />
<Container
pb="md"
px="md"
expand
gap="xxs"
flex="column"
style={{
minHeight: "400px",
maxHeight: props.isMobile ? "450px" : "none",
overflowY: "auto",
scrollbarWidth: "none",
paddingBottom: spacing.md,
}}
>
{props.isFetching &&
new Array(20).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TokenButtonSkeleton key={i} />
))}
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
>
<WalletDotIcon size={iconSize.xs} />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Your Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.ownedTokens.map((token) => (
<TokenButton
key={token.token_address}
token={token}
client={props.client}
onSelect={props.onSelectToken}
isSelected={
!!props.selectedToken &&
props.selectedToken.tokenAddress.toLowerCase() ===
token.token_address.toLowerCase() &&
token.chain_id === props.selectedToken.chainId
}
/>
))}
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
style={{
marginTop: spacing.sm,
}}
>
<CoinsIcon size={iconSize.xs} />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Other Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.otherTokens.map((token) => (
<TokenButton
key={token.address}
token={token}
client={props.client}
onSelect={props.onSelectToken}
isSelected={
props.selectedToken?.tokenAddress.toLowerCase() ===
token.address.toLowerCase()
}
/>
))}
{props.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.showMore?.();
}}
>
Load More
</Button>
)}
{noTokensFound && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text size="md" color="secondaryText">
No Tokens Found
</Text>
</div>
)}
</Container>
</>
)}
</Container>
);
}
const LeftContainer = /* @__PURE__ */ StyledDiv((_) => {
const theme = useCustomTheme();
return {
display: "flex",
flexDirection: "column",
overflowY: "auto",
...noScrollBar,
borderRight: `1px solid ${theme.colors.separatorLine}`,
position: "relative",
};
});
@@ -1,9 +1,5 @@
import styled from "@emotion/styled";
import {
ChevronDownIcon,
ChevronRightIcon,
DiscIcon,
} from "@radix-ui/react-icons";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js";
@@ -15,12 +11,10 @@ import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import { getToken } from "../../../../../pay/convert/get-token.js";
import {
getFiatSymbol,
type SupportedFiatCurrency,
} from "../../../../../pay/convert/type.js";
import { getAddress } from "../../../../../utils/address.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { getAddress, shortenAddress } from "../../../../../utils/address.js";
import { toTokens, toUnits } from "../../../../../utils/units.js";
import { AccountProvider } from "../../../../core/account/provider.js";
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
import {
fontSize,
@@ -31,11 +25,16 @@ import {
} from "../../../../core/design-system/index.js";
import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js";
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
import { WalletProvider } from "../../../../core/wallet/provider.js";
import { ConnectButton } from "../../ConnectWallet/ConnectButton.js";
import { DetailsModal } from "../../ConnectWallet/Details.js";
import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js";
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
import connectLocaleEn from "../../ConnectWallet/locale/en.js";
import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js";
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
import {
formatCurrencyAmount,
formatTokenAmount,
} from "../../ConnectWallet/screens/formatTokenBalance.js";
import { Container } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { Input } from "../../components/formElements.js";
@@ -43,8 +42,13 @@ import { Img } from "../../components/Img.js";
import { Modal } from "../../components/Modal.js";
import { Skeleton } from "../../components/Skeleton.js";
import { Spacer } from "../../components/Spacer.js";
import { Spinner } from "../../components/Spinner.js";
import { Text } from "../../components/text.js";
import { DecimalRenderer } from "./common.js";
import { useIsMobile } from "../../hooks/useisMobile.js";
import { AccountAvatar } from "../../prebuilt/Account/avatar.js";
import { AccountBlobbie } from "../../prebuilt/Account/blobbie.js";
import { AccountName } from "../../prebuilt/Account/name.js";
import { WalletIcon } from "../../prebuilt/Wallet/icon.js";
import { SelectToken } from "./select-token-ui.js";
import type {
ActiveWalletInfo,
@@ -82,6 +86,7 @@ type SwapUIProps = {
type: "buy" | "sell";
amount: string;
}) => void;
onDisconnect: (() => void) | undefined;
};
function useTokenPrice(options: {
@@ -110,9 +115,16 @@ function useTokenPrice(options: {
* @internal
*/
export function SwapUI(props: SwapUIProps) {
const [modalState, setModalState] = useState<
"select-buy-token" | "select-sell-token" | undefined
>(undefined);
const [modalState, setModalState] = useState<{
screen: "select-buy-token" | "select-sell-token";
isOpen: boolean;
}>({
screen: "select-buy-token",
isOpen: false,
});
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const isMobile = useIsMobile();
// Token Prices ----------------------------------------------------------------------------
const buyTokenQuery = useTokenPrice({
@@ -193,14 +205,19 @@ export function SwapUI(props: SwapUIProps) {
);
// ----------------------------------------------------------------------------
const disableContinue =
!preparedResultQuery.data ||
preparedResultQuery.isFetching ||
notEnoughBalance;
return (
<Container p="md">
<Modal
hide={false}
className="tw-modal__swap-widget"
size="compact"
size={isMobile ? "compact" : "wide"}
title="Select Token"
open={!!modalState}
open={modalState.isOpen}
crossContainerStyles={{
right: spacing.md,
top: spacing["md+"],
@@ -208,19 +225,30 @@ export function SwapUI(props: SwapUIProps) {
}}
setOpen={(v) => {
if (!v) {
setModalState(undefined);
setModalState((v) => ({
...v,
isOpen: false,
}));
}
}}
>
{modalState === "select-buy-token" && (
{modalState.screen === "select-buy-token" && (
<SelectToken
activeWalletInfo={props.activeWalletInfo}
onBack={() => setModalState(undefined)}
onBack={() =>
setModalState((v) => ({
...v,
isOpen: false,
}))
}
client={props.client}
selectedToken={props.buyToken}
setSelectedToken={(token) => {
props.setBuyToken(token);
setModalState(undefined);
setModalState((v) => ({
...v,
isOpen: false,
}));
// if buy token is same as sell token, unset sell token
if (
props.sellToken &&
@@ -234,14 +262,22 @@ export function SwapUI(props: SwapUIProps) {
/>
)}
{modalState === "select-sell-token" && (
{modalState.screen === "select-sell-token" && (
<SelectToken
onBack={() => setModalState(undefined)}
onBack={() =>
setModalState((v) => ({
...v,
isOpen: false,
}))
}
client={props.client}
selectedToken={props.sellToken}
setSelectedToken={(token) => {
props.setSellToken(token);
setModalState(undefined);
setModalState((v) => ({
...v,
isOpen: false,
}));
// if sell token is same as buy token, unset buy token
if (
props.buyToken &&
@@ -257,10 +293,35 @@ export function SwapUI(props: SwapUIProps) {
)}
</Modal>
{detailsModalOpen && (
<DetailsModal
client={props.client}
locale={connectLocaleEn}
detailsModal={undefined}
theme={props.theme}
closeModal={() => {
setDetailsModalOpen(false);
}}
onDisconnect={() => {
props.onDisconnect?.();
}}
chains={[]}
connectOptions={props.connectOptions}
/>
)}
{/* Sell */}
<TokenSection
onMaxClick={() => {
if (sellTokenBalanceQuery.data) {
props.setAmountSelection({
type: "sell",
amount: sellTokenBalanceQuery.data.displayValue,
});
}
}}
activeWalletInfo={props.activeWalletInfo}
isConnected={!!props.activeWalletInfo}
notEnoughBalance={notEnoughBalance}
balance={{
data: sellTokenBalanceQuery.data?.value,
isFetching: sellTokenBalanceQuery.isFetching,
@@ -269,7 +330,7 @@ export function SwapUI(props: SwapUIProps) {
data: sellTokenAmount,
isFetching: isSellAmountFetching,
}}
label="Sell"
type="sell"
setAmount={(value) => {
props.setAmountSelection({ type: "sell", amount: value });
}}
@@ -283,7 +344,15 @@ export function SwapUI(props: SwapUIProps) {
}
client={props.client}
currency={props.currency}
onSelectToken={() => setModalState("select-sell-token")}
onSelectToken={() =>
setModalState({
screen: "select-sell-token",
isOpen: true,
})
}
onWalletClick={() => {
setDetailsModalOpen(true);
}}
/>
{/* Switch */}
@@ -302,8 +371,12 @@ export function SwapUI(props: SwapUIProps) {
{/* Buy */}
<TokenSection
onMaxClick={undefined}
onWalletClick={() => {
setDetailsModalOpen(true);
}}
activeWalletInfo={props.activeWalletInfo}
isConnected={!!props.activeWalletInfo}
notEnoughBalance={false}
balance={{
data: buyTokenBalanceQuery.data?.value,
isFetching: buyTokenBalanceQuery.isFetching,
@@ -312,7 +385,7 @@ export function SwapUI(props: SwapUIProps) {
data: buyTokenAmount,
isFetching: isBuyAmountFetching,
}}
label="Buy"
type="buy"
selectedToken={
props.buyToken
? {
@@ -326,7 +399,12 @@ export function SwapUI(props: SwapUIProps) {
}}
client={props.client}
currency={props.currency}
onSelectToken={() => setModalState("select-buy-token")}
onSelectToken={() =>
setModalState({
screen: "select-buy-token",
isOpen: true,
})
}
/>
{/* error message */}
@@ -342,7 +420,7 @@ export function SwapUI(props: SwapUIProps) {
Failed to get a quote
</Text>
) : (
<Spacer y="lg" />
<Spacer y="md" />
)}
{/* Button */}
@@ -361,11 +439,7 @@ export function SwapUI(props: SwapUIProps) {
/>
) : (
<Button
disabled={
!preparedResultQuery.data ||
preparedResultQuery.isFetching ||
notEnoughBalance
}
disabled={disableContinue}
fullWidth
onClick={() => {
if (
@@ -385,14 +459,20 @@ export function SwapUI(props: SwapUIProps) {
});
}
}}
gap="xs"
style={{
fontSize: fontSize.md,
padding: `${spacing.sm} ${spacing.md}`,
borderRadius: radius.lg,
borderRadius: radius.full,
opacity: disableContinue ? 0.5 : 1,
}}
variant="primary"
>
Swap
{preparedResultQuery.isFetching && <Spinner size="sm" />}
{preparedResultQuery.isFetching
? "Fetching Quote"
: notEnoughBalance
? "Insufficient Balance"
: "Swap"}
</Button>
)}
@@ -599,10 +679,12 @@ function DecimalInput(props: {
style={{
border: "none",
boxShadow: "none",
fontSize: fontSize.xxl,
fontSize: fontSize.xl,
fontWeight: 500,
paddingInline: 0,
paddingBlock: 0,
letterSpacing: "-0.025em",
height: "30px",
}}
type="text"
value={props.value}
@@ -612,13 +694,13 @@ function DecimalInput(props: {
}
function TokenSection(props: {
label: string;
notEnoughBalance: boolean;
type: "buy" | "sell";
amount: {
data: string;
isFetching: boolean;
};
setAmount: (amount: string) => void;
activeWalletInfo: ActiveWalletInfo | undefined;
selectedToken:
| {
data: TokenWithPrices | undefined;
@@ -633,7 +715,10 @@ function TokenSection(props: {
data: bigint | undefined;
isFetching: boolean;
};
onWalletClick: () => void;
onMaxClick: (() => void) | undefined;
}) {
const theme = useCustomTheme();
const chainQuery = useBridgeChains(props.client);
const chain = chainQuery.data?.find(
(chain) => chain.chainId === props.selectedToken?.data?.chainId,
@@ -648,140 +733,191 @@ function TokenSection(props: {
return (
<Container
p="sm"
style={{
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "solid",
position: "relative",
overflow: "hidden",
}}
borderColor="borderColor"
bg="tertiaryBg"
style={{ borderRadius: radius.lg, borderWidth: 1, borderStyle: "solid" }}
>
{/* row1 : label */}
<Text size="md" color="primaryText" weight={500}>
{props.label}
</Text>
{/* row2 : amount and select token */}
<div
{/* make the background semi-transparent */}
<Container
bg="tertiaryBg"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
paddingBottom: spacing.sm,
paddingTop: spacing.sm,
position: "absolute",
inset: 0,
opacity: 0.5,
zIndex: 0,
}}
/>
<Container
style={{
position: "relative",
zIndex: 1,
}}
>
{props.amount.isFetching ? (
<Skeleton
height={fontSize.xxl}
width="140px"
style={{
borderRadius: radius.lg,
}}
/>
) : (
<DecimalInput value={props.amount.data} setValue={props.setAmount} />
)}
{/* row1 : label */}
<Container
px="md"
py="sm"
relative
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Container flex="row" center="y" gap="3xs" color="secondaryText">
<Text
size="xs"
color="primaryText"
style={{
letterSpacing: "0.07em",
}}
>
{props.type === "buy" ? "BUY" : "SELL"}
</Text>
</Container>
{props.activeWalletInfo && (
<WalletButton
variant="ghost-solid"
style={{
paddingInline: spacing.xxs,
paddingBlock: "2px",
}}
onClick={props.onWalletClick}
>
<ActiveWalletDetails
activeWalletInfo={props.activeWalletInfo}
client={props.client}
/>
</WalletButton>
)}
</Container>
{!props.selectedToken ? (
<Button
variant="accent"
style={{
borderRadius: radius.full,
gap: spacing.xxs,
paddingBlock: spacing.sm,
paddingInline: spacing.sm,
}}
onClick={props.onSelectToken}
>
{" "}
Select Token
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
</Button>
) : (
<Container
bg="tertiaryBg"
style={{
position: "relative",
overflow: "hidden",
borderRadius: radius.xl,
borderTop: `1px solid ${theme.colors.borderColor}`,
}}
>
<SelectedTokenButton
selectedToken={props.selectedToken}
client={props.client}
onSelectToken={props.onSelectToken}
chain={chain}
/>
)}
</div>
{/* row3 : fiat value/error and balance */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
justifyContent: "space-between",
}}
>
{/* Exceeds Balance / Fiat Value */}
{props.notEnoughBalance ? (
<Text size="md" color="danger" weight={500}>
{" "}
Exceeds Balance{" "}
</Text>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<Text size="md" color="secondaryText" weight={500}>
{getFiatSymbol(props.currency)}
</Text>
{props.amount.isFetching ? (
<Skeleton height={fontSize.md} width="50px" />
) : (
<div>
<DecimalRenderer
integerSize="md"
fractionSize="sm"
value={totalFiatValue?.toFixed(5) || "0.00"}
color="secondaryText"
weight={500}
<Container px="md" py="md">
<Container
flex="row"
gap="xs"
center="y"
style={{
flexWrap: "nowrap",
}}
>
{props.amount.isFetching ? (
<div style={{ flexGrow: 1 }}>
<Skeleton
height="30px"
width="140px"
style={{
borderRadius: radius.lg,
}}
/>
</div>
) : (
<DecimalInput
value={props.amount.data}
setValue={props.setAmount}
/>
</div>
)}
</div>
)}
)}
{/* Balance */}
{props.isConnected && props.selectedToken && (
<div>
{props.balance.data === undefined ||
props.selectedToken.data === undefined ? (
<Skeleton height={fontSize.md} width="50px" />
) : (
<Text
size="md"
color="secondaryText"
weight={500}
{props.activeWalletInfo &&
props.onMaxClick &&
props.selectedToken && (
<Button
variant="outline"
style={{
paddingInline: spacing.xs,
paddingBlock: spacing.xxs,
borderRadius: radius.full,
fontSize: fontSize.xs,
fontWeight: 400,
}}
onClick={props.onMaxClick}
>
Max
</Button>
)}
</Container>
<Spacer y="sm" />
{/* row3 : fiat value and balance */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: spacing.xxs,
gap: "3px",
}}
>
<WalletDotIcon size={fontSize.xs} />
<DecimalRenderer
integerSize="md"
fractionSize="sm"
color="secondaryText"
weight={500}
value={formatTokenAmount(
props.balance.data,
props.selectedToken.data.decimals,
5,
{props.amount.isFetching ? (
<Skeleton height={fontSize.xs} width="50px" />
) : (
<Text size="xs" color="secondaryText">
{formatCurrencyAmount(props.currency, totalFiatValue || 0)}
</Text>
)}
</div>
{/* Balance */}
{props.isConnected && props.selectedToken && (
<div>
{props.balance.data === undefined ||
props.selectedToken.data === undefined ? (
<Skeleton height={fontSize.xs} width="100px" />
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}
>
<Text size="xs" color="secondaryText">
Balance:
</Text>
<Text size="xs" color="primaryText">
{formatTokenAmount(
props.balance.data,
props.selectedToken.data.decimals,
5,
)}
</Text>
</div>
)}
/>
</Text>
)}
</div>
)}
</div>
</div>
)}
</div>
</Container>
</Container>
</Container>
</Container>
);
}
@@ -797,92 +933,141 @@ function SelectedTokenButton(props: {
onSelectToken: () => void;
chain: BridgeChain | undefined;
}) {
const theme = useCustomTheme();
return (
<Button
variant="outline"
bg="secondaryButtonBg"
variant="ghost-solid"
hoverBg="secondaryButtonBg"
fullWidth
onClick={props.onSelectToken}
gap="xs"
gap="sm"
style={{
borderRadius: radius.full,
paddingBlock: spacing.xxs,
paddingInline: spacing.xs,
borderBottom: `1px dashed ${theme.colors.borderColor}`,
justifyContent: "space-between",
paddingInline: spacing.md,
paddingBlock: spacing.md,
borderRadius: 0,
}}
>
{/* icons */}
<Container relative color="secondaryText">
{/* token icon */}
<Img
key={props.selectedToken?.data?.iconUri}
src={
props.selectedToken?.data === undefined
? undefined
: props.selectedToken.data.iconUri || ""
}
client={props.client}
width={iconSize.lg}
height={iconSize.lg}
skeletonColor="modalBg"
fallback={<DiscIcon width={iconSize.lg} height={iconSize.lg} />}
style={{
borderRadius: radius.full,
}}
/>
<Container gap="sm" flex="row" center="y">
{/* icons */}
<Container relative color="secondaryText">
{/* token icon */}
{props.selectedToken ? (
<Img
key={props.selectedToken?.data?.iconUri}
src={
props.selectedToken?.data === undefined
? undefined
: props.selectedToken.data.iconUri || ""
}
client={props.client}
width="40"
height="40"
fallback={
<Container
style={{
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
borderRadius: radius.full,
width: "40px",
height: "40px",
}}
/>
}
style={{
objectFit: "cover",
borderRadius: radius.full,
}}
/>
) : (
<Container
style={{
border: `1px solid ${theme.colors.borderColor}`,
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
borderRadius: radius.full,
width: "40px",
height: "40px",
}}
/>
)}
{/* chain icon */}
<Container
bg="modalBg"
style={{
padding: "2px",
position: "absolute",
bottom: -2,
right: -2,
display: "flex",
borderRadius: radius.full,
}}
>
<Img
src={props.chain?.icon}
client={props.client}
width={iconSize.sm}
height={iconSize.sm}
style={{
borderRadius: radius.full,
}}
/>
{/* chain icon */}
{props.chain && (
<Container
bg="modalBg"
style={{
padding: "2px",
position: "absolute",
bottom: -2,
right: -2,
display: "flex",
borderRadius: radius.full,
}}
>
<Img
src={props.chain?.icon}
client={props.client}
width={iconSize.sm}
height={iconSize.sm}
style={{
borderRadius: radius.full,
}}
/>
</Container>
)}
</Container>
</Container>
{/* token symbol and chain name */}
<Container flex="column" style={{ gap: "2px" }}>
{props.selectedToken?.isFetching ? (
<Skeleton width="40px" height={fontSize.sm} color="modalBg" />
) : (
<Text size="sm" color="primaryText" weight={500}>
{props.selectedToken?.data?.symbol}
</Text>
)}
{/* token symbol and chain name */}
{props.selectedToken ? (
<Container flex="column" style={{ gap: "3px" }}>
{props.selectedToken?.isFetching ? (
<Skeleton width="60px" height={fontSize.md} />
) : (
<Text size="md" color="primaryText" weight={500}>
{props.selectedToken?.data?.symbol}
</Text>
)}
{props.chain ? (
<Text
size="xs"
color="secondaryText"
weight={500}
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cleanedChainName(props.chain.name)}
</Text>
{props.chain ? (
<Text
size="xs"
color="secondaryText"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cleanedChainName(props.chain.name)}
</Text>
) : (
<Skeleton width="140px" height={fontSize.sm} />
)}
</Container>
) : (
<Skeleton width="70px" height={fontSize.xs} color="modalBg" />
<Container flex="column" style={{ gap: "3px" }}>
<Text size="md" color="primaryText" weight={500}>
Select Token
</Text>
<Text size="xs" color="secondaryText">
Required
</Text>
</Container>
)}
</Container>
<Container color="secondaryText">
<ChevronRightIcon width={iconSize.xs} height={iconSize.xs} />
<Container
color="secondaryText"
flex="row"
center="both"
borderColor="borderColor"
style={{
borderRadius: radius.full,
borderWidth: 1,
borderStyle: "solid",
padding: spacing.xs,
}}
>
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
</Container>
</Button>
);
@@ -894,11 +1079,13 @@ function SwitchButton(props: { onClick: () => void }) {
style={{
display: "flex",
justifyContent: "center",
marginBlock: `-14px`,
marginBlock: `-13px`,
zIndex: 2,
position: "relative",
}}
>
<SwitchButtonInner
variant="outline"
variant="ghost-solid"
onClick={(e) => {
props.onClick();
const node = e.currentTarget.querySelector("svg");
@@ -912,7 +1099,7 @@ function SwitchButton(props: { onClick: () => void }) {
}
}}
>
<ArrowUpDownIcon size={iconSize.md} />
<ArrowUpDownIcon size={iconSize["sm+"]} />
</SwitchButtonInner>
</div>
);
@@ -922,10 +1109,11 @@ const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => {
const theme = useCustomTheme();
return {
"&:hover": {
background: theme.colors.modalBg,
background: theme.colors.secondaryButtonBg,
},
borderRadius: radius.lg,
borderRadius: radius.full,
padding: spacing.xs,
color: theme.colors.primaryText,
background: theme.colors.modalBg,
border: `1px solid ${theme.colors.borderColor}`,
};
@@ -948,3 +1136,78 @@ function useTokenBalance(props: {
: undefined,
});
}
function ActiveWalletDetails(props: {
activeWalletInfo: ActiveWalletInfo;
client: ThirdwebClient;
}) {
const wallet = props.activeWalletInfo.activeWallet;
const account = props.activeWalletInfo.activeAccount;
const accountBlobbie = (
<AccountBlobbie
style={{
width: `${iconSize.xs}px`,
height: `${iconSize.xs}px`,
borderRadius: radius.full,
}}
/>
);
const accountAvatarFallback = (
<WalletIcon
style={{
width: `${iconSize.xs}px`,
height: `${iconSize.xs}px`,
}}
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
return (
<WalletProvider id={props.activeWalletInfo.activeWallet.id}>
<AccountProvider address={account.address} client={props.client}>
<WalletProvider id={wallet.id}>
<Container flex="row" gap="xxs" center="y">
<AccountAvatar
style={{
width: `${iconSize.xs}px`,
height: `${iconSize.xs}px`,
borderRadius: radius.full,
}}
fallbackComponent={accountAvatarFallback}
loadingComponent={accountAvatarFallback}
/>
<span
style={{
fontSize: fontSize.xs,
letterSpacing: "0.025em",
}}
>
<AccountName
fallbackComponent={
<span>{shortenAddress(account.address)}</span>
}
loadingComponent={
<span>{shortenAddress(account.address)}</span>
}
/>
</span>
</Container>
</WalletProvider>
</AccountProvider>
</WalletProvider>
);
}
const WalletButton = /* @__PURE__ */ styled(Button)(() => {
const theme = useCustomTheme();
return {
color: theme.colors.secondaryText,
transition: "color 200ms ease",
"&:hover": {
color: theme.colors.primaryText,
},
};
});
@@ -1,3 +1,9 @@
export function cleanedChainName(name: string) {
return name.replace("Mainnet", "");
}
export const tokenAmountFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 5,
minimumFractionDigits: 2,
});
@@ -3,21 +3,21 @@ import type { IconFC } from "./types.js";
export const ArrowUpDownIcon: IconFC = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
role="presentation"
width={props.size}
height={props.size}
>
<path d="m21 16-4 4-4-4" />
<path d="M17 20V4" />
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
<g
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9.599 2.4v11.2l3.2-3.476M6.4 13.6V2.4L3.2 5.875"></path>
</g>
</svg>
);
};
@@ -84,7 +84,7 @@ export function Container(props: {
expand?: boolean;
center?: "x" | "y" | "both";
gap?: keyof typeof spacing;
children: React.ReactNode;
children?: React.ReactNode;
style?: React.CSSProperties;
p?: keyof typeof spacing;
px?: keyof typeof spacing;
@@ -21,6 +21,7 @@ type ButtonProps = {
fullWidth?: boolean;
gap?: keyof typeof spacing;
bg?: keyof Theme["colors"];
hoverBg?: keyof Theme["colors"];
};
export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
@@ -95,6 +96,9 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
transition: "border 200ms ease",
WebkitTapHighlightColor: "transparent",
width: props.fullWidth ? "100%" : undefined,
"&:hover": {
background: props.hoverBg ? theme.colors[props.hoverBg] : undefined,
},
...(() => {
if (props.variant === "outline") {
return {
@@ -120,7 +124,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
if (props.variant === "ghost-solid") {
return {
"&:hover": {
background: theme.colors.tertiaryBg,
background: theme.colors[props.hoverBg || "tertiaryBg"],
},
border: "1px solid transparent",
};
@@ -137,7 +141,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
if (props.variant === "secondary") {
return {
"&:hover": {
background: theme.colors.secondaryButtonHoverBg,
background: theme.colors[props.hoverBg || "secondaryButtonHoverBg"],
},
};
}
@@ -2,6 +2,7 @@
import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
import {
fontSize,
media,
radius,
spacing,
type Theme,
@@ -97,11 +98,14 @@ export const Input = /* @__PURE__ */ StyledInput<InputProps>((props) => {
color: theme.colors.primaryText,
display: "block",
fontFamily: "inherit",
fontSize: fontSize.md,
fontSize: fontSize.sm,
outline: "none",
padding: props.sm ? spacing.sm : fontSize.sm,
WebkitAppearance: "none",
width: "100%",
[media.mobile]: {
fontSize: fontSize.md,
},
};
});
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 640;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+1 -1
View File
@@ -125,7 +125,7 @@ export function getRpcClient(
inflight.request.id = index;
// also assign the jsonrpc version
inflight.request.jsonrpc = "2.0";
// assing the request to the requests array (so we don't have to map it again later)
// assign the request to the requests array (so we don't have to map it again later)
requests[index] = inflight.request;
return inflight;
});
@@ -16,13 +16,14 @@ const meta = {
} satisfies Meta<typeof SelectBridgeChain>;
export default meta;
export function WithData() {
export function WithDataDesktop() {
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
);
return (
<SwapWidgetContainer theme="dark" className="w-full">
<SelectBridgeChain
isMobile={false}
client={storyClient}
onSelectChain={setSelectedChain}
onBack={() => {}}
@@ -32,13 +33,50 @@ export function WithData() {
);
}
export function Loading() {
export function LoadingDesktop() {
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
);
return (
<SwapWidgetContainer theme="dark" className="w-full">
<SelectBridgeChainUI
isMobile={false}
client={storyClient}
onSelectChain={setSelectedChain}
onBack={() => {}}
isPending={true}
chains={[]}
selectedChain={selectedChain}
/>
</SwapWidgetContainer>
);
}
export function WithDataMobile() {
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
);
return (
<SwapWidgetContainer theme="dark" className="w-full">
<SelectBridgeChain
isMobile={true}
client={storyClient}
onSelectChain={setSelectedChain}
onBack={() => {}}
selectedChain={selectedChain}
/>
</SwapWidgetContainer>
);
}
export function LoadingMobile() {
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
);
return (
<SwapWidgetContainer theme="dark" className="w-full">
<SelectBridgeChainUI
isMobile={true}
client={storyClient}
onSelectChain={setSelectedChain}
onBack={() => {}}
@@ -1,7 +1,6 @@
import type { Meta } from "@storybook/react";
import { lightTheme } from "../../../react/core/design-system/index.js";
import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js";
import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js";
import { storyClient } from "../../utils.js";
const meta: Meta<typeof SwapWidget> = {
@@ -14,15 +13,6 @@ const meta: Meta<typeof SwapWidget> = {
return (
<div>
<Story />
<div
style={{
position: "absolute",
bottom: "20px",
right: "20px",
}}
>
<ConnectButton client={storyClient} />
</div>
</div>
);
},
@@ -31,15 +21,28 @@ const meta: Meta<typeof SwapWidget> = {
export default meta;
export function BasicUsage() {
return <SwapWidget client={storyClient} />;
return <SwapWidget client={storyClient} persistTokenSelections={false} />;
}
export function CurrencySet() {
return <SwapWidget client={storyClient} currency="JPY" />;
return (
<SwapWidget
client={storyClient}
currency="JPY"
persistTokenSelections={false}
/>
);
}
export function LightMode() {
return <SwapWidget client={storyClient} currency="JPY" theme="light" />;
return (
<SwapWidget
client={storyClient}
currency="JPY"
theme="light"
persistTokenSelections={false}
/>
);
}
export function NoThirdwebBranding() {
@@ -48,6 +51,7 @@ export function NoThirdwebBranding() {
client={storyClient}
currency="JPY"
showThirdwebBranding={false}
persistTokenSelections={false}
/>
);
}
@@ -57,6 +61,7 @@ export function CustomTheme() {
<SwapWidget
client={storyClient}
currency="JPY"
persistTokenSelections={false}
theme={lightTheme({
colors: {
modalBg: "#FFFFF0",
@@ -48,7 +48,7 @@ import type { EcosystemWallet } from "../core/wallet/types.js";
*
* ### Connect to a restricted ecosystem wallet with your designated partner ID
*
* The parnter ID will be provided to you by the ecosystem with which you're integrating.
* The partner ID will be provided to you by the ecosystem with which you're integrating.
*
* ```ts
* import { ecosystemWallet } from "thirdweb/wallets";
+242
View File
@@ -0,0 +1,242 @@
import {
type ERC20TokenAmount,
type Money,
moneySchema,
type Network,
SupportedEVMNetworks,
} from "x402/types";
import { getAddress } from "../utils/address.js";
import { decodePayment } from "./encode.js";
import type { facilitator as facilitatorType } from "./facilitator.js";
import {
type FacilitatorNetwork,
networkToChainId,
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
} from "./schemas.js";
import {
type PaymentArgs,
type PaymentRequiredResult,
x402Version,
} from "./types.js";
type GetPaymentRequirementsResult = {
status: 200;
paymentRequirements: RequestedPaymentRequirements[];
selectedPaymentRequirements: RequestedPaymentRequirements;
decodedPayment: RequestedPaymentPayload;
};
/**
* Decodes a payment request and returns the payment requirements, selected payment requirements, and decoded payment
* @param args
* @returns The payment requirements, selected payment requirements, and decoded payment
*/
export async function decodePaymentRequest(
args: PaymentArgs,
): Promise<GetPaymentRequirementsResult | PaymentRequiredResult> {
const {
price,
network,
facilitator,
resourceUrl,
routeConfig = {},
payTo,
method,
paymentData,
} = args;
const {
description,
mimeType,
maxTimeoutSeconds,
inputSchema,
outputSchema,
errorMessages,
discoverable,
} = routeConfig;
const atomicAmountForAsset = await processPriceToAtomicAmount(
price,
network,
facilitator,
);
if ("error" in atomicAmountForAsset) {
return {
status: 402,
responseHeaders: { "Content-Type": "application/json" },
responseBody: {
x402Version,
error: atomicAmountForAsset.error,
accepts: [],
},
};
}
const { maxAmountRequired, asset } = atomicAmountForAsset;
const paymentRequirements: RequestedPaymentRequirements[] = [];
if (
SupportedEVMNetworks.includes(network as Network) ||
network.startsWith("eip155:")
) {
paymentRequirements.push({
scheme: "exact",
network,
maxAmountRequired,
resource: resourceUrl,
description: description ?? "",
mimeType: mimeType ?? "application/json",
payTo: getAddress(payTo),
maxTimeoutSeconds: maxTimeoutSeconds ?? 300,
asset: getAddress(asset.address),
// TODO: Rename outputSchema to requestStructure
outputSchema: {
input: {
type: "http",
method,
discoverable: discoverable ?? true,
...inputSchema,
},
output: outputSchema,
},
extra: (asset as ERC20TokenAmount["asset"]).eip712,
});
} else {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: `Unsupported network: ${network}`,
accepts: paymentRequirements,
},
};
}
// Check for payment header
if (!paymentData) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: errorMessages?.paymentRequired || "X-PAYMENT header is required",
accepts: paymentRequirements,
},
};
}
// Verify payment
let decodedPayment: RequestedPaymentPayload;
try {
decodedPayment = decodePayment(paymentData);
decodedPayment.x402Version = x402Version;
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.invalidPayment ||
(error instanceof Error ? error.message : "Invalid payment"),
accepts: paymentRequirements,
},
};
}
const selectedPaymentRequirements = paymentRequirements.find(
(value) =>
value.scheme === decodedPayment.scheme &&
value.network === decodedPayment.network,
);
if (!selectedPaymentRequirements) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.noMatchingRequirements ||
"Unable to find matching payment requirements",
accepts: paymentRequirements,
},
};
}
return {
status: 200,
paymentRequirements,
decodedPayment,
selectedPaymentRequirements,
};
}
/**
* Parses the amount from the given price
*
* @param price - The price to parse
* @param network - The network to get the default asset for
* @returns The parsed amount or an error message
*/
async function processPriceToAtomicAmount(
price: Money | ERC20TokenAmount,
network: FacilitatorNetwork,
facilitator: ReturnType<typeof facilitatorType>,
): Promise<
| { maxAmountRequired: string; asset: ERC20TokenAmount["asset"] }
| { error: string }
> {
// Handle USDC amount (string) or token amount (ERC20TokenAmount)
let maxAmountRequired: string;
let asset: ERC20TokenAmount["asset"];
if (typeof price === "string" || typeof price === "number") {
// USDC amount in dollars
const parsedAmount = moneySchema.safeParse(price);
if (!parsedAmount.success) {
return {
error: `Invalid price (price: ${price}). Must be in the form "$3.10", 0.10, "0.001", ${parsedAmount.error}`,
};
}
const parsedUsdAmount = parsedAmount.data;
const defaultAsset = await getDefaultAsset(network, facilitator);
if (!defaultAsset) {
return {
error: `Unable to get default asset on ${network}. Please specify an asset in the payment requirements.`,
};
}
asset = defaultAsset;
maxAmountRequired = (parsedUsdAmount * 10 ** asset.decimals).toString();
} else {
// Token amount in atomic units
maxAmountRequired = price.amount;
asset = price.asset;
}
return {
maxAmountRequired,
asset,
};
}
async function getDefaultAsset(
network: FacilitatorNetwork,
facilitator: ReturnType<typeof facilitatorType>,
): Promise<ERC20TokenAmount["asset"] | undefined> {
const supportedAssets = await facilitator.supported();
const chainId = networkToChainId(network);
const matchingAsset = supportedAssets.kinds.find(
(supported) => supported.network === `eip155:${chainId}`,
);
const assetConfig = matchingAsset?.extra
?.defaultAsset as ERC20TokenAmount["asset"];
return assetConfig;
}
+81
View File
@@ -0,0 +1,81 @@
import type { ExactEvmPayload } from "x402/types";
import {
type RequestedPaymentPayload,
RequestedPaymentPayloadSchema,
} from "./schemas.js";
/**
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
*
* @param payment - The payment payload to encode
* @returns A base64 encoded string representation of the payment payload
*/
export function encodePayment(payment: RequestedPaymentPayload): string {
let safe: RequestedPaymentPayload;
// evm
const evmPayload = payment.payload as ExactEvmPayload;
safe = {
...payment,
payload: {
...evmPayload,
authorization: Object.fromEntries(
Object.entries(evmPayload.authorization).map(([key, value]) => [
key,
typeof value === "bigint" ? (value as bigint).toString() : value,
]),
) as ExactEvmPayload["authorization"],
},
};
return safeBase64Encode(JSON.stringify(safe));
}
/**
* Decodes a base64 encoded payment string back into a PaymentPayload object
*
* @param payment - The base64 encoded payment string to decode
* @returns The decoded and validated PaymentPayload object
*/
export function decodePayment(payment: string): RequestedPaymentPayload {
const decoded = safeBase64Decode(payment);
const parsed = JSON.parse(decoded);
const obj: RequestedPaymentPayload = {
...parsed,
payload: parsed.payload as ExactEvmPayload,
};
const validated = RequestedPaymentPayloadSchema.parse(obj);
return validated;
}
/**
* Encodes a string to base64 format
*
* @param data - The string to be encoded to base64
* @returns The base64 encoded string
*/
export function safeBase64Encode(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.btoa === "function"
) {
return globalThis.btoa(data);
}
return Buffer.from(data).toString("base64");
}
/**
* Decodes a base64 string back to its original format
*
* @param data - The base64 encoded string to be decoded
* @returns The decoded string in UTF-8 format
*/
function safeBase64Decode(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.atob === "function"
) {
return globalThis.atob(data);
}
return Buffer.from(data, "base64").toString("utf-8");
}
+114 -6
View File
@@ -1,5 +1,12 @@
import type { FacilitatorConfig } from "x402/types";
import type { SupportedPaymentKindsResponse, VerifyResponse } from "x402/types";
import type { ThirdwebClient } from "../client/client.js";
import { stringify } from "../utils/json.js";
import { withCache } from "../utils/promise/withCache.js";
import type {
FacilitatorSettleResponse,
RequestedPaymentPayload,
RequestedPaymentRequirements,
} from "./schemas.js";
export type ThirdwebX402FacilitatorConfig = {
client: ThirdwebClient;
@@ -12,7 +19,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
/**
* Creates a facilitator for the x402 payment protocol.
* Use this with any x402 middleware to enable settling transactions with your thirdweb server wallet.
* You can use this with `settlePayment` or with any x402 middleware to enable settling transactions with your thirdweb server wallet.
*
* @param config - The configuration for the facilitator
* @returns a x402 compatible FacilitatorConfig
@@ -48,9 +55,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
*
* @bridge x402
*/
export function facilitator(
config: ThirdwebX402FacilitatorConfig,
): FacilitatorConfig {
export function facilitator(config: ThirdwebX402FacilitatorConfig) {
const secretKey = config.client.secretKey;
if (!secretKey) {
throw new Error("Client secret key is required for the x402 facilitator");
@@ -61,7 +66,7 @@ export function facilitator(
"Server wallet address is required for the x402 facilitator",
);
}
return {
const facilitator = {
url: (config.baseUrl ?? DEFAULT_BASE_URL) as `${string}://${string}`,
createAuthHeaders: async () => {
return {
@@ -83,5 +88,108 @@ export function facilitator(
},
};
},
/**
* Verifies a payment payload with the facilitator service
*
* @param payload - The payment payload to verify
* @param paymentRequirements - The payment requirements to verify against
* @returns A promise that resolves to the verification response
*/
async verify(
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
): Promise<VerifyResponse> {
const url = config.baseUrl ?? DEFAULT_BASE_URL;
let headers = { "Content-Type": "application/json" };
const authHeaders = await facilitator.createAuthHeaders();
headers = { ...headers, ...authHeaders.verify };
const res = await fetch(`${url}/verify`, {
method: "POST",
headers,
body: stringify({
x402Version: payload.x402Version,
paymentPayload: payload,
paymentRequirements: paymentRequirements,
}),
});
if (res.status !== 200) {
const text = `${res.statusText} ${await res.text()}`;
throw new Error(`Failed to verify payment: ${res.status} ${text}`);
}
const data = await res.json();
return data as VerifyResponse;
},
/**
* Settles a payment with the facilitator service
*
* @param payload - The payment payload to settle
* @param paymentRequirements - The payment requirements for the settlement
* @returns A promise that resolves to the settlement response
*/
async settle(
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
): Promise<FacilitatorSettleResponse> {
const url = config.baseUrl ?? DEFAULT_BASE_URL;
let headers = { "Content-Type": "application/json" };
const authHeaders = await facilitator.createAuthHeaders();
headers = { ...headers, ...authHeaders.settle };
const res = await fetch(`${url}/settle`, {
method: "POST",
headers,
body: JSON.stringify({
x402Version: payload.x402Version,
paymentPayload: payload,
paymentRequirements: paymentRequirements,
}),
});
if (res.status !== 200) {
const text = `${res.statusText} ${await res.text()}`;
throw new Error(`Failed to settle payment: ${res.status} ${text}`);
}
const data = await res.json();
return data as FacilitatorSettleResponse;
},
/**
* Gets the supported payment kinds from the facilitator service.
*
* @returns A promise that resolves to the supported payment kinds
*/
async supported(): Promise<SupportedPaymentKindsResponse> {
const url = config.baseUrl ?? DEFAULT_BASE_URL;
return withCache(
async () => {
let headers = { "Content-Type": "application/json" };
const authHeaders = await facilitator.createAuthHeaders();
headers = { ...headers, ...authHeaders.supported };
const res = await fetch(`${url}/supported`, { headers });
if (res.status !== 200) {
throw new Error(
`Failed to get supported payment kinds: ${res.statusText}`,
);
}
const data = await res.json();
return data as SupportedPaymentKindsResponse;
},
{
cacheKey: `supported-payment-kinds-${url}`,
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
},
);
},
};
return facilitator;
}
+13 -27
View File
@@ -1,15 +1,13 @@
import { createPaymentHeader } from "x402/client";
import {
ChainIdToNetwork,
EvmNetworkToChainId,
type PaymentRequirements,
PaymentRequirementsSchema,
type Signer,
} from "x402/types";
import { viemAdapter } from "../adapters/viem.js";
import { ChainIdToNetwork } from "x402/types";
import { getCachedChain } from "../chains/utils.js";
import type { ThirdwebClient } from "../client/client.js";
import type { Wallet } from "../wallets/interfaces/wallet.js";
import {
networkToChainId,
type RequestedPaymentRequirements,
RequestedPaymentRequirementsSchema,
} from "./schemas.js";
import { createPaymentHeader } from "./sign.js";
/**
* Enables the payment of APIs using the x402 payment protocol.
@@ -52,7 +50,7 @@ import type { Wallet } from "../wallets/interfaces/wallet.js";
*/
export function wrapFetchWithPayment(
fetch: typeof globalThis.fetch,
client: ThirdwebClient,
_client: ThirdwebClient,
wallet: Wallet,
maxValue: bigint = BigInt(1 * 10 ** 6), // Default to 1 USDC
) {
@@ -68,7 +66,7 @@ export function wrapFetchWithPayment(
accepts: unknown[];
};
const parsedPaymentRequirements = accepts
.map((x) => PaymentRequirementsSchema.parse(x))
.map((x) => RequestedPaymentRequirementsSchema.parse(x))
.filter((x) => x.scheme === "exact"); // TODO (402): accept other schemes
const account = wallet.getAccount();
@@ -89,16 +87,10 @@ export function wrapFetchWithPayment(
throw new Error("Payment amount exceeds maximum allowed");
}
const paymentChainId = EvmNetworkToChainId.get(
const paymentChainId = networkToChainId(
selectedPaymentRequirements.network,
);
if (!paymentChainId) {
throw new Error(
`No chain found for the selected payment requirement: ${selectedPaymentRequirements.network}`,
);
}
// switch to the payment chain if it's not the current chain
if (paymentChainId !== chain.id) {
await wallet.switchChain(getCachedChain(paymentChainId));
@@ -108,14 +100,8 @@ export function wrapFetchWithPayment(
}
}
const walletClient = viemAdapter.wallet.toViem({
wallet: wallet,
chain,
client,
}) as Signer;
const paymentHeader = await createPaymentHeader(
walletClient,
account,
x402Version,
selectedPaymentRequirements,
);
@@ -142,7 +128,7 @@ export function wrapFetchWithPayment(
}
function defaultPaymentRequirementsSelector(
paymentRequirements: PaymentRequirements[],
paymentRequirements: RequestedPaymentRequirements[],
chainId: number,
scheme: "exact",
) {
@@ -151,7 +137,7 @@ function defaultPaymentRequirementsSelector(
"No valid payment requirements found in server 402 response",
);
}
const currentWalletNetwork = ChainIdToNetwork[chainId];
const currentWalletNetwork = ChainIdToNetwork[chainId] || `eip155:${chainId}`;
// find the payment requirements matching the connected wallet chain
const matchingPaymentRequirements = paymentRequirements.find(
(x) => x.network === currentWalletNetwork && x.scheme === scheme,
+76
View File
@@ -0,0 +1,76 @@
import {
EvmNetworkToChainId,
type ExactEvmPayload,
type Network,
PaymentPayloadSchema,
PaymentRequirementsSchema,
SettleResponseSchema,
} from "x402/types";
import { z } from "zod";
const FacilitatorNetworkSchema = z.union([
z.literal("base-sepolia"),
z.literal("base"),
z.literal("avalanche-fuji"),
z.literal("avalanche"),
z.literal("iotex"),
z.literal("solana-devnet"),
z.literal("solana"),
z.literal("sei"),
z.literal("sei-testnet"),
z.string().refine((value) => value.startsWith("eip155:"), {
message: "Invalid network",
}),
]);
export type FacilitatorNetwork = z.infer<typeof FacilitatorNetworkSchema>;
export const RequestedPaymentPayloadSchema = PaymentPayloadSchema.extend({
network: FacilitatorNetworkSchema,
});
export type RequestedPaymentPayload = z.infer<
typeof RequestedPaymentPayloadSchema
>;
export type UnsignedPaymentPayload = Omit<
RequestedPaymentPayload,
"payload"
> & {
payload: Omit<ExactEvmPayload, "signature"> & { signature: undefined };
};
export const RequestedPaymentRequirementsSchema =
PaymentRequirementsSchema.extend({
network: FacilitatorNetworkSchema,
});
export type RequestedPaymentRequirements = z.infer<
typeof RequestedPaymentRequirementsSchema
>;
const FacilitatorSettleResponseSchema = SettleResponseSchema.extend({
network: FacilitatorNetworkSchema,
});
export type FacilitatorSettleResponse = z.infer<
typeof FacilitatorSettleResponseSchema
>;
export function networkToChainId(network: string): number {
if (network.startsWith("eip155:")) {
const chainId = parseInt(network.split(":")[1] ?? "0");
if (!Number.isNaN(chainId) && chainId > 0) {
return chainId;
} else {
throw new Error(`Invalid network: ${network}`);
}
}
const mappedChainId = EvmNetworkToChainId.get(network as Network);
if (!mappedChainId) {
throw new Error(`Invalid network: ${network}`);
}
// TODO (402): support solana networks
if (mappedChainId === 101 || mappedChainId === 103) {
throw new Error("Solana networks not supported yet.");
}
return mappedChainId;
}
@@ -0,0 +1,186 @@
import { stringify } from "../utils/json.js";
import { decodePaymentRequest } from "./common.js";
import { safeBase64Encode } from "./encode.js";
import {
type PaymentArgs,
type SettlePaymentResult,
x402Version,
} from "./types.js";
/**
* Verifies and processes X402 payments for protected resources.
*
* This function implements the X402 payment protocol, verifying payment proofs
* and settling payments through a facilitator service. It handles the complete
* payment flow from validation to settlement.
*
* @param args - Configuration object containing payment verification parameters
* @returns A promise that resolves to either a successful payment result (200) or payment required error (402)
*
* @example
*
* ### Next.js API route example
*
* ```ts
* // Usage in a Next.js API route
* import { settlePayment, facilitator } from "thirdweb/x402";
* import { createThirdwebClient } from "thirdweb";
*
* const client = createThirdwebClient({
* secretKey: process.env.THIRDWEB_SECRET_KEY,
* });
*
* const thirdwebFacilitator = facilitator({
* client,
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
* });
*
* export async function GET(request: Request) {
* const paymentData = request.headers.get("x-payment");
*
* // verify and process the payment
* const result = await settlePayment({
* resourceUrl: "https://api.example.com/premium-content",
* method: "GET",
* paymentData,
* payTo: "0x1234567890123456789012345678901234567890",
* network: "eip155:84532", // CAIP2 format: "eip155:<chain_id>"
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
* facilitator: thirdwebFacilitator,
* routeConfig: {
* description: "Access to premium API content",
* mimeType: "application/json",
* maxTimeoutSeconds: 300,
* },
* });
*
* if (result.status === 200) {
* // Payment verified and settled successfully
* return Response.json({ data: "premium content" });
* } else {
* // Payment required
* return Response.json(result.responseBody, {
* status: result.status,
* headers: result.responseHeaders,
* });
* }
* }
* ```
*
* ### Express middleware example
*
* ```ts
* // Usage in Express middleware
* import express from "express";
* import { settlePayment, facilitator } from "thirdweb/x402";
* import { createThirdwebClient } from "thirdweb";
*
* const client = createThirdwebClient({
* secretKey: process.env.THIRDWEB_SECRET_KEY,
* });
*
* const thirdwebFacilitator = facilitator({
* client,
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
* });
*
* const app = express();
*
* async function paymentMiddleware(req, res, next) {
* // verify and process the payment
* const result = await settlePayment({
* resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
* method: req.method,
* paymentData: req.headers["x-payment"],
* payTo: "0x1234567890123456789012345678901234567890",
* network: "eip155:8453", // CAIP2 format: "eip155:<chain_id>"
* price: "$0.05",
* facilitator: thirdwebFacilitator,
* });
*
* if (result.status === 200) {
* // Set payment receipt headers and continue
* Object.entries(result.responseHeaders).forEach(([key, value]) => {
* res.setHeader(key, value);
* });
* next();
* } else {
* // Return payment required response
* res.status(result.status)
* .set(result.responseHeaders)
* .json(result.responseBody);
* }
* }
*
* app.get("/api/premium", paymentMiddleware, (req, res) => {
* res.json({ message: "This is premium content!" });
* });
* ```
*
* @public
* @beta
* @bridge x402
*/
export async function settlePayment(
args: PaymentArgs,
): Promise<SettlePaymentResult> {
const { routeConfig = {}, facilitator } = args;
const { errorMessages } = routeConfig;
const decodePaymentResult = await decodePaymentRequest(args);
if (decodePaymentResult.status !== 200) {
return decodePaymentResult;
}
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
decodePaymentResult;
// Settle payment
try {
const settlement = await facilitator.settle(
decodedPayment,
selectedPaymentRequirements,
);
if (settlement.success) {
return {
status: 200,
paymentReceipt: settlement,
responseHeaders: {
"Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE",
"X-PAYMENT-RESPONSE": safeBase64Encode(stringify(settlement)),
},
};
} else {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.settlementFailed ||
settlement.errorReason ||
"Settlement failed",
accepts: paymentRequirements,
},
};
}
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.settlementFailed ||
(error instanceof Error ? error.message : "Settlement error"),
accepts: paymentRequirements,
},
};
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { ExactEvmPayloadAuthorization } from "x402/types";
import { type Address, getAddress } from "../utils/address.js";
import { type Hex, toHex } from "../utils/encoding/hex.js";
import type { Account } from "../wallets/interfaces/wallet.js";
import { encodePayment } from "./encode.js";
import {
networkToChainId,
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
type UnsignedPaymentPayload,
} from "./schemas.js";
/**
* Prepares an unsigned payment header with the given sender address and payment requirements.
*
* @param from - The sender's address from which the payment will be made
* @param x402Version - The version of the X402 protocol to use
* @param paymentRequirements - The payment requirements containing scheme and network information
* @returns An unsigned payment payload containing authorization details
*/
function preparePaymentHeader(
from: Address,
x402Version: number,
paymentRequirements: RequestedPaymentRequirements,
): UnsignedPaymentPayload {
const nonce = createNonce();
const validAfter = BigInt(
Math.floor(Date.now() / 1000) - 600, // 10 minutes before
).toString();
const validBefore = BigInt(
Math.floor(Date.now() / 1000 + paymentRequirements.maxTimeoutSeconds),
).toString();
return {
x402Version,
scheme: paymentRequirements.scheme,
network: paymentRequirements.network,
payload: {
signature: undefined,
authorization: {
from,
to: paymentRequirements.payTo as Address,
value: paymentRequirements.maxAmountRequired,
validAfter: validAfter.toString(),
validBefore: validBefore.toString(),
nonce,
},
},
};
}
/**
* Signs a payment header using the provided client and payment requirements.
*
* @param client - The signer wallet instance used to sign the payment header
* @param paymentRequirements - The payment requirements containing scheme and network information
* @param unsignedPaymentHeader - The unsigned payment payload to be signed
* @returns A promise that resolves to the signed payment payload
*/
async function signPaymentHeader(
account: Account,
paymentRequirements: RequestedPaymentRequirements,
unsignedPaymentHeader: UnsignedPaymentPayload,
): Promise<RequestedPaymentPayload> {
const { signature } = await signAuthorization(
account,
unsignedPaymentHeader.payload.authorization,
paymentRequirements,
);
return {
...unsignedPaymentHeader,
payload: {
...unsignedPaymentHeader.payload,
signature,
},
};
}
/**
* Creates a complete payment payload by preparing and signing a payment header.
*
* @param client - The signer wallet instance used to create and sign the payment
* @param x402Version - The version of the X402 protocol to use
* @param paymentRequirements - The payment requirements containing scheme and network information
* @returns A promise that resolves to the complete signed payment payload
*/
async function createPayment(
account: Account,
x402Version: number,
paymentRequirements: RequestedPaymentRequirements,
): Promise<RequestedPaymentPayload> {
const from = getAddress(account.address);
const unsignedPaymentHeader = preparePaymentHeader(
from,
x402Version,
paymentRequirements,
);
return signPaymentHeader(account, paymentRequirements, unsignedPaymentHeader);
}
/**
* Creates and encodes a payment header for the given client and payment requirements.
*
* @param client - The signer wallet instance used to create the payment header
* @param x402Version - The version of the X402 protocol to use
* @param paymentRequirements - The payment requirements containing scheme and network information
* @returns A promise that resolves to the encoded payment header string
*/
export async function createPaymentHeader(
account: Account,
x402Version: number,
paymentRequirements: RequestedPaymentRequirements,
): Promise<string> {
const payment = await createPayment(
account,
x402Version,
paymentRequirements,
);
return encodePayment(payment);
}
/**
* Signs an EIP-3009 authorization for USDC transfer
*
* @param walletClient - The wallet client that will sign the authorization
* @param params - The authorization parameters containing transfer details
* @param params.from - The address tokens will be transferred from
* @param params.to - The address tokens will be transferred to
* @param params.value - The amount of USDC tokens to transfer (in base units)
* @param params.validAfter - Unix timestamp after which the authorization becomes valid
* @param params.validBefore - Unix timestamp before which the authorization is valid
* @param params.nonce - Random 32-byte nonce to prevent replay attacks
* @param paymentRequirements - The payment requirements containing asset and network information
* @param paymentRequirements.asset - The address of the USDC contract
* @param paymentRequirements.network - The network where the USDC contract exists
* @param paymentRequirements.extra - The extra information containing the name and version of the ERC20 contract
* @returns The signature for the authorization
*/
async function signAuthorization(
account: Account,
{
from,
to,
value,
validAfter,
validBefore,
nonce,
}: ExactEvmPayloadAuthorization,
{ asset, network, extra }: RequestedPaymentRequirements,
): Promise<{ signature: Hex }> {
const chainId = networkToChainId(network);
const name = extra?.name;
const version = extra?.version;
// TODO (402): detect permit vs transfer on asset contract
const data = {
types: {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
},
domain: {
name,
version,
chainId,
verifyingContract: getAddress(asset),
},
primaryType: "TransferWithAuthorization" as const,
message: {
from: getAddress(from),
to: getAddress(to),
value,
validAfter,
validBefore,
nonce: nonce,
},
};
const signature = await account.signTypedData(data);
return {
signature,
};
}
/**
* Generates a random 32-byte nonce for use in authorization signatures
*
* @returns A random 32-byte nonce as a hex string
*/
function createNonce(): Hex {
const cryptoObj =
typeof globalThis.crypto !== "undefined" &&
typeof globalThis.crypto.getRandomValues === "function"
? globalThis.crypto
: // Dynamic require is needed to support node.js
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("crypto").webcrypto;
return toHex(cryptoObj.getRandomValues(new Uint8Array(32)));
}
+90
View File
@@ -0,0 +1,90 @@
import type {
ERC20TokenAmount,
Money,
PaymentMiddlewareConfig,
} from "x402/types";
import type { Address } from "../utils/address.js";
import type { Prettify } from "../utils/type-utils.js";
import type { facilitator as facilitatorType } from "./facilitator.js";
import type {
FacilitatorNetwork,
FacilitatorSettleResponse,
RequestedPaymentPayload,
RequestedPaymentRequirements,
} from "./schemas.js";
export const x402Version = 1;
/**
* Configuration object for verifying or processing X402 payments.
*
* @public
*/
export type PaymentArgs = {
/** The URL of the resource being protected by the payment */
resourceUrl: string;
/** The HTTP method used to access the resource */
method: "GET" | "POST" | ({} & string);
/** The payment data/proof provided by the client, typically from the X-PAYMENT header */
paymentData?: string | null;
/** The wallet address that should receive the payment */
payTo: Address;
/** The blockchain network where the payment should be processed */
network: FacilitatorNetwork;
/** The price for accessing the resource - either a USD amount (e.g., "$0.10") or a specific token amount */
price: Money | ERC20TokenAmount;
/** The payment facilitator instance used to verify and settle payments */
facilitator: ReturnType<typeof facilitatorType>;
/** Optional configuration for the payment middleware route */
routeConfig?: PaymentMiddlewareConfig;
};
export type PaymentRequiredResult = {
/** HTTP 402 - Payment Required, verification or processing failed or payment missing */
status: 402;
/** The error response body containing payment requirements */
responseBody: {
/** The X402 protocol version */
x402Version: number;
/** Human-readable error message */
error: string;
/** Array of acceptable payment methods and requirements */
accepts: RequestedPaymentRequirements[];
/** Optional payer address if verification partially succeeded */
payer?: string;
};
/** Response headers for the error response */
responseHeaders: Record<string, string>;
};
/**
* The result of a payment settlement operation.
*
* @public
*/
export type SettlePaymentResult = Prettify<
| {
/** HTTP 200 - Payment was successfully processed */
status: 200;
/** Response headers including payment receipt information */
responseHeaders: Record<string, string>;
/** The payment receipt from the payment facilitator */
paymentReceipt: FacilitatorSettleResponse;
}
| PaymentRequiredResult
>;
/**
* The result of a payment verification operation.
*
* @public
*/
export type VerifyPaymentResult = Prettify<
| {
/** HTTP 200 - Payment was successfully verified */
status: 200;
decodedPayment: RequestedPaymentPayload;
selectedPaymentRequirements: RequestedPaymentRequirements;
}
| PaymentRequiredResult
>;
@@ -0,0 +1,133 @@
import { decodePaymentRequest } from "./common.js";
import {
type PaymentArgs,
type VerifyPaymentResult,
x402Version,
} from "./types.js";
/**
* Verifies X402 payments for protected resources. This function only verifies the payment,
* you should use `settlePayment` to settle the payment.
*
* @param args - Configuration object containing payment verification parameters
* @returns A promise that resolves to either a successful verification result (200) or payment required error (402)
*
* @example
* ```ts
* // Usage in a Next.js API route
* import { verifyPayment, facilitator } from "thirdweb/x402";
* import { createThirdwebClient } from "thirdweb";
*
* const client = createThirdwebClient({
* secretKey: process.env.THIRDWEB_SECRET_KEY,
* });
*
* const thirdwebFacilitator = facilitator({
* client,
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
* });
*
* export async function GET(request: Request) {
* const paymentData = request.headers.get("x-payment");
*
* const paymentArgs = {
* resourceUrl: "https://api.example.com/premium-content",
* method: "GET",
* paymentData,
* payTo: "0x1234567890123456789012345678901234567890",
* network: "eip155:84532", // CAIP2 format: "eip155:<chain_id>"
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
* facilitator: thirdwebFacilitator,
* routeConfig: {
* description: "Access to premium API content",
* mimeType: "application/json",
* maxTimeoutSeconds: 300,
* },
* };
*
* // verify the payment
* const result = await verifyPayment(paymentArgs);
*
* if (result.status === 200) {
* // Payment verified, but not settled yet
* // you can do the work that requires payment first
* const result = await doSomething();
* // then settle the payment
* const settleResult = await settlePayment(paymentArgs);
*
* // then return the result
* return Response.json(result);
* } else {
* // verification failed, return payment required
* return Response.json(result.responseBody, {
* status: result.status,
* headers: result.responseHeaders,
* });
* }
* }
* ```
*
* @public
* @beta
* @bridge x402
*/
export async function verifyPayment(
args: PaymentArgs,
): Promise<VerifyPaymentResult> {
const { routeConfig = {}, facilitator } = args;
const { errorMessages } = routeConfig;
const decodePaymentResult = await decodePaymentRequest(args);
if (decodePaymentResult.status !== 200) {
return decodePaymentResult;
}
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
decodePaymentResult;
// Verify payment
try {
const verification = await facilitator.verify(
decodedPayment,
selectedPaymentRequirements,
);
if (verification.isValid) {
return {
status: 200,
decodedPayment,
selectedPaymentRequirements,
};
} else {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.verificationFailed ||
verification.invalidReason ||
"Verification failed",
accepts: paymentRequirements,
},
};
}
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.verificationFailed ||
(error instanceof Error ? error.message : "Verification error"),
accepts: paymentRequirements,
},
};
}
}

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