Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8910f55ca2 Version Packages (#7245)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 00:31:34 -07:00
Joaquim VergesandGitHub b237318b7d [SDK] Support EIP7702 execution for ecosystem wallets (#7268) 2025-06-04 00:09:00 -07:00
Jonas DanielsandGitHub 082d9630f4 [Dashboard] Add crypto payment option for invoices (#7270) 2025-06-04 00:03:15 -07:00
Jonas DanielsandGitHub d4bc8e199d [Dashboard] Fix Stripe balance calculation in getStripeBalance function (#7269) 2025-06-03 22:45:25 -07:00
Jonas DanielsandGitHub 4edf1ebe95 [Dashboard] Add crypto top-up functionality for account credits (#7239) 2025-06-03 22:07:33 -07:00
MananTank b12725b231 [TOOL-4673] Dashboard: Enable posthog session recording for nebula app (#7267)
<!--

## 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 `PHProvider` component by introducing a `disable_session_recording` prop, allowing for more control over session recording. Additionally, it renames the `Providers` component to `PayProviders` and `BridgeProviders` in their respective files for clarity.

### Detailed summary
- Added `disable_session_recording` prop to `<PHProvider>` in multiple files.
- Renamed `Providers` to `PayProviders` in `Providers.client.tsx` and `pay/layout.tsx`.
- Renamed `Providers` to `BridgeProviders` in `Providers.client.tsx` and `bridge/layout.tsx`.
- Updated the `PHProvider` function signature to accept `disable_session_recording` as a prop.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **New Features**
	- Added explicit controls to enable or disable session recording across different sections of the dashboard.
- **Refactor**
	- Renamed provider components for improved clarity in the Bridge and Pay sections.
	- Updated component usage to reflect new provider names.
- **Style**
	- No visual changes introduced.
- **Bug Fixes**
	- None.
- **Documentation**
	- None.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-04 04:13:00 +00:00
MananTank 3d1c321bd6 [TOOL-4642] Fix useTxNotifications errors not shown in project contract pages (#7259)
<!--

## 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 adding an `ErrorProvider` wrapper around the layout of the `ContractLayout` component to handle errors more effectively.

### Detailed summary
- Added import for `ErrorProvider`.
- Wrapped the main layout in an `ErrorProvider`.
- Removed the previous layout structure while integrating the `ErrorProvider`.
- Maintained the structure of `TeamHeaderLoggedIn`, `SharedContractLayout`, `SaveLastUsedProject`, and `AppFooter` within the new error handling context.

> ✨ 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**
  - Improved error handling on the contract page with the addition of enhanced error context support.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-03 22:50:54 +00:00
MananTank 810ec6efa7 [TOOL-4651] Dashboard: Show error on invalid token distribution in ERC20 asset creation form (#7260)
<!--

## 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 `TokenDistributionFieldset` component by adding error handling for token distribution and improving the UI by displaying error messages. It also updates the `StepCard` and `DistributionBarChart` components to accommodate new features.

### Detailed summary
- Added `disabled` prop to `StepCard`.
- Integrated error handling in `TokenDistributionFieldset` for distribution validation.
- Displayed error messages when distribution exceeds supply.
- Improved `DistributionBarChart` to conditionally style text based on segment percentages.
- Introduced `getDistributionError` and `SafeNumber` utility functions for validation logic.

> ✨ 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 validation to ensure token distribution does not exceed total supply, with error messages displayed when limits are surpassed.
  - The "Next" submit button in the token creation flow can now be disabled based on validation errors.

- **Bug Fixes**
  - Owner percentage in the distribution chart is now prevented from displaying negative values.

- **Style**
  - Segment labels in the distribution chart legend are visually highlighted if their percentage is outside the 0–100% range.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-03 22:41:55 +00:00
MananTank 1c6ed9bd0d [TOOL-4667] Dashboard: Various improvements in ERC20 asset creation flow (#7264)
<!--

## 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 renaming references from "Token" to "Coin" throughout the codebase, along with some improvements in error handling and component structure.

### Detailed summary
- Renamed "Token" to "Coin" in various components and UI elements.
- Updated the `parseError` function to be exported.
- Introduced `ContractTypeCellUI` for better contract type rendering.
- Modified multi-step status handling to include more structured error messages.

> ✨ 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**
	- Enhanced multi-step status tracking with structured error messages and improved error display in step-based processes.

- **Improvements**
	- Updated all user-facing terminology from "Token" to "Coin" across creation, distribution, and sale interfaces.
	- Distribution and allocation charts now reflect "Coin" terminology and dynamically display the coin symbol when available.
	- Improved contract table to show "Coin" for relevant contract types.
	- Refined event tracking to trigger only when relevant actions occur.

- **Bug Fixes**
	- Error messages in step-based processes are now displayed more clearly with detailed information.

- **Refactor**
	- Streamlined contract type badge rendering for consistency and maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-03 22:33:11 +00:00
MananTank 82035fd0b7 [TOOL-4670] Dashboard: Update Managed Engine CTA, UI tweaks (#7266)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `bottomLineClassName` prop to the `TabLinks` and `TabPathLinks` components, enhancing customization options for styling. It also simplifies the UI in the `EmptyEngineState` component and updates the layout for better structure and readability.

### Detailed summary
- Added `bottomLineClassName` prop to `TabLinks` and `TabPathLinks`.
- Updated `TabLinks` to use `bottomLineClassName` for styling.
- Simplified `EmptyEngineState` content and button structure.
- Refactored layout in `Layout` component for improved organization.
- Adjusted `EngineLegacyBannerUI` for better spacing and button layout.

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

<!-- end pr-codex -->

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

- **Refactor**
  - Updated navigation layout for the engine section with new tabbed navigation and improved header structure.
  - Adjusted container layouts for more consistent spacing and alignment.

- **Style**
  - Restyled the legacy banner, including button order, alignment, and icon adjustments for a clearer appearance.
  - Enhanced tab navigation with customizable bottom line styling for better visual integration.

- **Bug Fixes**
  - Unified the messaging and actions for adding a managed engine instance, removing plan-based differences for a more consistent user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-03 22:21:32 +00:00
gregandGitHub 8b324597fb Feature: Update UB Docs for latest SDK and Guides (#7265) 2025-06-03 15:53:14 -05:00
FirekeeperandGitHub 5ebb7549ca [SDK] Force legacy for Plume 98866 (#7263) 2025-06-03 12:14:33 -07:00
MananTank dfe4cf0928 [TOOL-4645] Dashboard: Improve Project overview page loading performance (#7261)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the loading state of various chart components in the dashboard by enhancing the `LoadingChartState` component with additional styling and refactoring the code to utilize asynchronous components for better performance and user experience.

### Detailed summary
- Updated `LoadingChartState` to accept a `className` prop for custom styling.
- Modified `Suspense` components in `Transactions`, `RpcMethodBarChartCard`, and `EngineCloudChartCard` to use improved `LoadingChartState`.
- Introduced new async components: `AsyncTotalSponsoredCard`, `AsyncAuthMethodDistributionCard`, and `AsyncAppHighlightsCard`.
- Refactored fetching logic for analytics data to use async/await pattern, improving readability and maintainability.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **Refactor**
  - Improved loading placeholders across analytics cards by simplifying their appearance and updating styling for a more consistent look.
  - Modularized data fetching for analytics cards, allowing each card to load and handle errors independently for a smoother user experience.

- **New Features**
  - Loading states for analytics cards now display updated visual styles with borders and adjusted heights.

- **Style**
  - Unified and customizable loading chart styles for better visual consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-03 16:31:36 +00:00
Joaquim VergesandGitHub 6cce31d828 [Dashboard] Add support for Sophon mainnet (#7258) 2025-06-03 01:59:10 -07:00
Joaquim VergesandGitHub aaad876a6f [Docs] Add Bundler API reference documentation (#7257) 2025-06-03 01:31:16 -07:00
gregandGitHub de297e4c97 [SDK] Feature: Track insufficient funds error across SDK (#7256) 2025-06-02 17:16:29 -05:00
pinkhoodie 2114e5f91f Add text input option to airdrop setup (#7238)
- Add manual text input alternative to CSV upload for airdrop addresses

- Support multiple input formats: space, comma, equals, and tab separated

- Parse text input and convert to CSV format for existing validation system

- Reuse all existing functionality: ENS resolution, address validation, duplicate removal

- Maintain same validation flow and error handling as CSV upload

- Change button text from 'Upload CSV' to 'Set up Airdrop' for clarity

![image](https://github.com/user-attachments/assets/e43afdd8-eed8-491a-ac93-9f02eb6ee703)
![image](https://github.com/user-attachments/assets/42522fa1-3886-4d68-a113-079d58b3da96)

<!--

## 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 CSV upload functionality in the `TokenAirdropSection` component. It introduces a new method for processing CSV data, allows manual entry of addresses and amounts, and updates UI labels to reflect the new functionality.

### Detailed summary
- Added `processData` function in `useCsvUpload` for processing parsed CSV data.
- Updated `TokenAirdropSection` UI labels from "CSV File Uploaded" to "Airdrop List Set".
- Changed button text from "View CSV" to "View List".
- Introduced `parseTextInput` function for manual input parsing.
- Added text input for entering addresses and amounts.
- Implemented handling for text input submission.
- Updated the layout to separate CSV upload and manual entry sections.

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

<!-- end pr-codex -->

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

- **New Features**
  - Added the ability to manually enter airdrop addresses and amounts using a text area, supporting multiple input formats.
  - Users can now choose between uploading a CSV file or entering addresses and amounts directly.

- **UI Updates**
  - Updated labels and button texts to reflect support for both CSV and manual list input.
  - Improved layout with a visual divider and clear instructions for manual entry.
  - Enhanced reset and validation behaviors for both input methods.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-02 20:57:20 +00:00
Phillip HoandGitHub ca0841745a fix: docs formatting for verified domains page (#7248) 2025-06-02 17:08:03 +08:00
Phillip HoandGitHub d150ea1c91 docs: Add page on domain verification (#7247) 2025-06-02 16:33:17 +08:00
saminacodes f1479b85f0 [Docs] Improvements to acct, ec, and vault docs (#7246)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating various documentation and component links within the `thirdweb` portal, as well as enhancing the content of FAQs and guides to provide clearer information on account management, migration to the TypeScript SDK v5, and Engine Cloud features.

### Detailed summary
- Added `Account` link to `supportLinks` in `Header.tsx`.
- Included warning about deprecation of TypeScript SDK v4 in `page.mdx`.
- Added troubleshooting details for transactions in `Engine Troubleshoot Guide`.
- Updated FAQ on customized plans in `faq/page.mdx`.
- Introduced ownership transfer steps in `manage-team/page.mdx`.
- Removed outdated Nebula access details in `faqs/page.mdx`.
- Added details about server wallet keys and admin key usage in `vault/faqs/page.mdx`.
- Updated pricing explanation and added a new chains support detail in `Engine Cloud` FAQ.
- Introduced a new section on `Smart Server Wallets`, detailing benefits and security in `server-wallets/page.mdx`.

> ✨ 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 new documentation page for Smart Server Wallets, outlining features, security, and pricing.
  - Added new FAQ entries to the Vault documentation regarding key requirements for server wallets and admin key handling.
  - Added a new FAQ entry detailing supported chains for Engine Cloud.

- **Improvements**
  - Updated navigation links in the header, moving "Account" to support links and adding a "Status" link.
  - Enhanced troubleshooting guidance for Engine Cloud transactions.
  - Updated Engine Cloud pricing FAQ to reference the pricing page.
  - Added a warning callout about TypeScript SDK v4 deprecation to the migration guide.
  - Expanded billing documentation with a new section on transferring team ownership.
  - Clarified the FAQ about signing up for customized or enterprise plans.

- **Removals**
  - Removed outdated Nebula FAQ entries related to access approval and pricing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-02 02:24:09 +00:00
Joaquim VergesandGitHub 283dc289fc [SDK] Include native tokens in Insight.getOwnedTokens (#7243) 2025-06-01 01:31:23 -07:00
92 changed files with 3940 additions and 1476 deletions
@@ -59,3 +59,16 @@ export async function getTeamInvoices(
throw new Error("Failed to fetch billing history");
}
}
async function getStripeCustomer(customerId: string) {
return await getStripe().customers.retrieve(customerId);
}
export async function getStripeBalance(customerId: string) {
const customer = await getStripeCustomer(customerId);
if (customer.deleted) {
return 0;
}
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
return customer.balance === 0 ? 0 : customer.balance / -100;
}
@@ -10,6 +10,7 @@ type DistributionBarChartProps = {
segments: Segment[];
title: string;
};
export function DistributionBarChart(props: DistributionBarChartProps) {
const totalPercentage = props.segments.reduce(
(sum, segment) => sum + segment.percent,
@@ -59,7 +60,13 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
backgroundColor: segment.color,
}}
/>
<p className="text-sm">
<p
className={cn(
"text-sm",
(segment.percent > 100 || segment.percent < 0) &&
"text-destructive-text",
)}
>
{segment.label}: {segment.percent}%
</p>
</div>
@@ -22,41 +22,36 @@ export const AllStates: Story = {
args: {
steps: [
{
status: "completed",
status: { type: "completed" },
label: "Connect Wallet",
retryLabel: "Failed to connect wallet",
execute: async () => {
await sleep(1000);
},
},
{
status: "pending",
status: { type: "pending" },
label: "Sign Message",
retryLabel: "Failed to sign message",
execute: async () => {
await sleep(1000);
},
},
{
status: "error",
status: { type: "error", message: "This is an error message" },
label: "Approve Transaction",
retryLabel: "Transaction approval failed",
execute: async () => {
await sleep(1000);
},
},
{
status: "idle",
status: { type: "idle" },
label: "Confirm Transaction",
retryLabel: "Transaction confirmation failed",
execute: async () => {
await sleep(1000);
},
},
{
status: "idle",
status: { type: "idle" },
label: "Finalize",
retryLabel: "Finalization failed",
execute: async () => {
await sleep(1000);
},
@@ -11,8 +11,14 @@ import { DynamicHeight } from "../../ui/DynamicHeight";
import { Spinner } from "../../ui/Spinner/Spinner";
export type MultiStepState = {
status: "idle" | "pending" | "completed" | "error";
retryLabel: string;
status:
| {
type: "idle" | "pending" | "completed";
}
| {
type: "error";
message: string | React.ReactNode;
};
label: string;
execute: () => Promise<void>;
};
@@ -25,11 +31,11 @@ export function MultiStepStatus(props: {
<div className="space-y-4">
{props.steps.map((step) => (
<div key={step.label} className="flex items-start space-x-3 ">
{step.status === "completed" ? (
{step.status.type === "completed" ? (
<CircleCheckIcon className="mt-0.5 size-5 flex-shrink-0 text-green-500" />
) : step.status === "pending" ? (
) : step.status.type === "pending" ? (
<Spinner className="mt-0.5 size-5 flex-shrink-0 text-foreground" />
) : step.status === "error" ? (
) : step.status.type === "error" ? (
<AlertCircleIcon className="mt-0.5 size-5 flex-shrink-0 text-red-500" />
) : (
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
@@ -37,11 +43,11 @@ export function MultiStepStatus(props: {
<div className="flex-1">
<p
className={`font-medium ${
step.status === "pending"
step.status.type === "pending"
? "text-foreground"
: step.status === "completed"
: step.status.type === "completed"
? "text-green-500"
: step.status === "error"
: step.status.type === "error"
? "text-red-500"
: "text-muted-foreground/70"
}`}
@@ -49,9 +55,11 @@ export function MultiStepStatus(props: {
{step.label}
</p>
{step.status === "error" && (
{step.status.type === "error" && (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">{step.retryLabel}</p>
<p className="mb-1 text-red-500 text-sm">
{step.status.message}
</p>
<Button
variant="destructive"
size="sm"
+8 -1
View File
@@ -22,6 +22,7 @@ export function TabLinks(props: {
tabContainerClassName?: string;
shadowColor?: string;
scrollableClassName?: string;
bottomLineClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLAnchorElement>();
@@ -29,7 +30,12 @@ export function TabLinks(props: {
return (
<div className={cn("relative", props.className)}>
{/* Bottom line */}
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
<div
className={cn(
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
props.bottomLineClassName,
)}
/>
<ScrollShadow
scrollableClassName={cn("pb-[8px] relative", props.scrollableClassName)}
@@ -216,6 +222,7 @@ export function TabPathLinks(props: {
tabContainerClassName?: string;
shadowColor?: string;
scrollableClassName?: string;
bottomLineClassName?: string;
}) {
const pathname = usePathname() || "";
const { links, ...restProps } = props;
@@ -42,10 +42,13 @@ export function getConfiguredThirdwebClient(options: {
if (!getTransactionDecorator()) {
setTransactionDecorator(async ({ account, transaction }) => {
// special override for sophon testnet (zk chain)
// special override for sophon (zk chain)
// sophon only allows transactions through their paymaster
// so always use eip712 tx + paymaster
if (transaction.chain.id === 531050104) {
if (
transaction.chain.id === 531050104 ||
transaction.chain.id === 50104
) {
const serializedTx = await populateEip712Transaction({
transaction,
account,
@@ -1,28 +1,78 @@
import type { ProductSKU } from "@/lib/billing";
import { redirect } from "next/navigation";
import { StripeRedirectErrorPage } from "../../../_components/StripeRedirectErrorPage";
import { getBillingCheckoutUrl } from "../../../utils/billing";
import {
getBillingCheckoutUrl,
getCryptoTopupUrl,
getInvoicePaymentUrl,
} from "../../../utils/billing";
export default async function CheckoutPage(props: {
params: Promise<{
team_slug: string;
sku: string;
}>;
searchParams: Promise<{
amount?: string;
invoice_id?: string;
}>;
}) {
const params = await props.params;
const billingUrl = await getBillingCheckoutUrl({
teamSlug: params.team_slug,
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
});
switch (params.sku) {
case "topup": {
const amountUSD = Number.parseInt(
(await props.searchParams).amount || "10",
);
if (Number.isNaN(amountUSD)) {
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
}
const topupUrl = await getCryptoTopupUrl({
teamSlug: params.team_slug,
amountUSD,
});
if (!topupUrl) {
// TODO: make a better error page
return (
<StripeRedirectErrorPage errorMessage="Failed to load topup page" />
);
}
redirect(topupUrl);
break;
}
case "invoice": {
const invoiceId = (await props.searchParams).invoice_id;
if (!invoiceId) {
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
}
const invoice = await getInvoicePaymentUrl({
teamSlug: params.team_slug,
invoiceId,
});
if (!invoice) {
return (
<StripeRedirectErrorPage errorMessage="Failed to load invoice payment page" />
);
}
redirect(invoice);
break;
}
default: {
const billingUrl = await getBillingCheckoutUrl({
teamSlug: params.team_slug,
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
});
if (!billingUrl) {
return (
<StripeRedirectErrorPage errorMessage="Failed to load checkout page" />
);
if (!billingUrl) {
return (
<StripeRedirectErrorPage errorMessage="Failed to load checkout page" />
);
}
redirect(billingUrl);
break;
}
}
redirect(billingUrl);
return null;
}
@@ -120,3 +120,80 @@ function getAbsoluteStripeRedirectUrl() {
url.pathname = "/stripe-redirect";
return url.toString();
}
export async function getCryptoTopupUrl(options: {
teamSlug: string;
amountUSD: number;
}): Promise<string | undefined> {
const token = await getAuthToken();
if (!token) {
return undefined;
}
const res = await fetch(
new URL(
`/v1/teams/${options.teamSlug}/checkout/crypto-top-up`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
method: "POST",
body: JSON.stringify({
amountUSD: options.amountUSD,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return undefined;
}
const json = await res.json();
if (!json.result) {
return undefined;
}
return json.result as string;
}
export async function getInvoicePaymentUrl(options: {
teamSlug: string;
invoiceId: string;
}): Promise<string | undefined> {
const token = await getAuthToken();
if (!token) {
return undefined;
}
const res = await fetch(
new URL(
`/v1/teams/${options.teamSlug}/checkout/crypto-pay-invoice`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
method: "POST",
body: JSON.stringify({
invoiceId: options.invoiceId,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return undefined;
}
const json = await res.json();
if (!json.result) {
return undefined;
}
return json.result as string;
}
+1 -1
View File
@@ -63,7 +63,7 @@ export default function RootLayout({
/>
<PosthogHeadSetup />
</head>
<PHProvider>
<PHProvider disable_session_recording={true}>
<PostHogPageView />
<body
className={cn(
@@ -1,4 +1,5 @@
"use client";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { useEcosystem } from "../../../hooks/use-ecosystem";
import { AuthOptionsSection } from "../server/auth-options-section";
import { EcosystemPartnersSection } from "../server/ecosystem-partners-section";
@@ -18,6 +19,8 @@ export function EcosystemPermissionsPage({
teamIdOrSlug: params.team_slug,
});
const client = getClientThirdwebClient({ jwt: authToken, teamId });
return (
<div className="flex flex-col gap-8">
<IntegrationPermissionsSection
@@ -29,6 +32,7 @@ export function EcosystemPermissionsPage({
ecosystem={ecosystem}
authToken={authToken}
teamId={teamId}
client={client}
/>
{ecosystem?.permission === "PARTNER_WHITELIST" && (
<EcosystemPartnersSection
@@ -1,4 +1,5 @@
"use client";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -27,6 +28,7 @@ import { PlusIcon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { isAddress } from "thirdweb";
import type { ThirdwebClient } from "thirdweb";
import { getSocialIcon } from "thirdweb/wallets/in-app";
import {
DEFAULT_ACCOUNT_FACTORY_V0_6,
@@ -47,13 +49,20 @@ type AuthOptionsFormData = {
defaultChainId: number;
accountFactoryType: "v0.6" | "v0.7" | "custom";
customAccountFactoryAddress: string;
executionMode: "EIP4337" | "EIP7702";
};
export function AuthOptionsForm({
ecosystem,
authToken,
teamId,
}: { ecosystem: Ecosystem; authToken: string; teamId: string }) {
client,
}: {
ecosystem: Ecosystem;
authToken: string;
teamId: string;
client: ThirdwebClient;
}) {
const form = useForm<AuthOptionsFormData>({
defaultValues: {
authOptions: ecosystem.authOptions || [],
@@ -71,6 +80,7 @@ export function AuthOptionsForm({
DEFAULT_ACCOUNT_FACTORY_V0_6
? "v0.6"
: "custom",
executionMode: ecosystem.smartAccountOptions?.executionMode || "EIP4337",
customAccountFactoryAddress:
ecosystem.smartAccountOptions?.accountFactoryAddress || "",
},
@@ -97,6 +107,7 @@ export function AuthOptionsForm({
.optional(),
accountFactoryType: z.enum(["v0.6", "v0.7", "custom"]),
customAccountFactoryAddress: z.string().optional(),
executionMode: z.enum(["EIP4337", "EIP7702"]),
})
.refine(
(data) => {
@@ -203,6 +214,7 @@ export function AuthOptionsForm({
defaultChainId: data.defaultChainId,
sponsorGas: data.sponsorGas,
accountFactoryAddress,
executionMode: data.executionMode,
};
}
@@ -437,26 +449,71 @@ export function AuthOptionsForm({
/>
{form.watch("useSmartAccount") && (
<div className="mt-1 flex flex-col gap-4">
<FormField
control={form.control}
name="sponsorGas"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Sponsor Gas</FormLabel>
<FormDescription>
Enable gas sponsorship for smart accounts
</FormDescription>
</div>
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row md:gap-6">
<FormField
control={form.control}
name="executionMode"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Execution Mode</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select execution mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="EIP4337">EIP-4337</SelectItem>
<SelectItem value="EIP7702">EIP-7702</SelectItem>
</SelectContent>
</Select>
{(() => {
const originalExecutionMode =
ecosystem.smartAccountOptions?.executionMode ||
"EIP4337";
const currentExecutionMode =
form.watch("executionMode");
const hasChanged =
currentExecutionMode !== originalExecutionMode;
return (
<FormDescription
className={hasChanged ? "text-warning-text" : ""}
>
{hasChanged
? "Changing execution mode will change the final user addresses when they connect to your ecosystem."
: "Smart account standard (EIP-7702 is recommended)"}
</FormDescription>
);
})()}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sponsorGas"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 md:flex-1">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Sponsor Gas</FormLabel>
<FormDescription>
Enable gas sponsorship for smart accounts
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="defaultChainId"
@@ -464,7 +521,11 @@ export function AuthOptionsForm({
<FormItem>
<FormLabel>Default Chain ID</FormLabel>
<FormControl>
<Input {...field} placeholder="1" />
<SingleNetworkSelector
client={client}
chainId={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>
This will be the chain ID the smart account will be
@@ -484,56 +545,63 @@ export function AuthOptionsForm({
)}
/>
<FormField
control={form.control}
name="accountFactoryType"
render={({ field }) => (
<FormItem>
<FormLabel>Account Factory</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account factory type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="v0.6">
Default Account Factory (v0.6)
</SelectItem>
<SelectItem value="v0.7">
Default Account Factory (v0.7)
</SelectItem>
<SelectItem value="custom">Custom factory</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose a default account factory or select custom to enter
your own address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("accountFactoryType") === "custom" && (
<FormField
control={form.control}
name="customAccountFactoryAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Account Factory Address</FormLabel>
<FormControl>
<Input {...field} placeholder="0x..." />
</FormControl>
<FormDescription>
Enter your own smart account factory contract address
</FormDescription>
<FormMessage />
</FormItem>
{form.watch("executionMode") === "EIP4337" && (
<>
<FormField
control={form.control}
name="accountFactoryType"
render={({ field }) => (
<FormItem>
<FormLabel>Account Factory</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account factory type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="v0.6">
Default Account Factory (v0.6)
</SelectItem>
<SelectItem value="v0.7">
Default Account Factory (v0.7)
</SelectItem>
<SelectItem value="custom">
Custom factory
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose a default account factory or select custom to
enter your own address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("accountFactoryType") === "custom" && (
<FormField
control={form.control}
name="customAccountFactoryAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Account Factory Address</FormLabel>
<FormControl>
<Input {...field} placeholder="0x..." />
</FormControl>
<FormDescription>
Enter your own smart account factory contract
address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</>
)}
</div>
)}
@@ -1,3 +1,4 @@
import type { ThirdwebClient } from "thirdweb";
import type { Ecosystem } from "../../../../../types";
import {
AuthOptionsForm,
@@ -8,7 +9,13 @@ export function AuthOptionsSection({
ecosystem,
authToken,
teamId,
}: { ecosystem?: Ecosystem; authToken: string; teamId: string }) {
client,
}: {
ecosystem?: Ecosystem;
authToken: string;
teamId: string;
client: ThirdwebClient;
}) {
return (
<section className="flex flex-col gap-4 md:gap-8">
{ecosystem ? (
@@ -16,6 +23,7 @@ export function AuthOptionsSection({
ecosystem={ecosystem}
authToken={authToken}
teamId={teamId}
client={client}
/>
) : (
<AuthOptionsFormSkeleton />
@@ -38,7 +38,8 @@ export type Ecosystem = {
smartAccountOptions?: {
defaultChainId: number;
sponsorGas: boolean;
accountFactoryAddress: string;
accountFactoryAddress?: string;
executionMode?: "EIP4337" | "EIP7702";
} | null;
url: string;
status: "active" | "requested" | "paymentFailed";
@@ -0,0 +1,250 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
import Link from "next/link";
import { Suspense, use, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
const predefinedAmounts = [
{ value: "25", label: "$25" },
{ value: "100", label: "$100" },
{ value: "500", label: "$500" },
{ value: "1000", label: "$1,000" },
] as const;
interface CreditBalanceSectionProps {
balancePromise: Promise<number>;
teamSlug: string;
}
export function CreditBalanceSection({
balancePromise,
teamSlug,
}: CreditBalanceSectionProps) {
const [selectedAmount, setSelectedAmount] = useState<string>(
predefinedAmounts[0].value,
);
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Credit Balance
</CardTitle>
<CardDescription className="mt-2">
Your credit balance automatically applies to all invoices before your
default payment method is charged.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Balance */}
<ErrorBoundary fallback={<CurrentBalanceErrorBoundary />}>
<Suspense fallback={<CurrentBalanceSkeleton />}>
<CurrentBalance balancePromise={balancePromise} />
</Suspense>
</ErrorBoundary>
<Separator />
<div className="space-y-2">
<h3 className="font-medium text-lg">Top Up Credits</h3>
<p className="text-muted-foreground text-sm">
Add credits to your account for future billing cycles. Credits are
non-refundable and do not expire.
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Amount Selection */}
<div className="space-y-4 lg:col-span-2">
<Label className="font-medium text-base">Select Amount</Label>
<RadioGroup
value={selectedAmount}
onValueChange={setSelectedAmount}
className="grid grid-cols-4 gap-3"
>
{predefinedAmounts.map((amount) => (
<div key={amount.value}>
<RadioGroupItem
value={amount.value}
id={amount.value}
className="peer sr-only"
/>
<Label
htmlFor={amount.value}
className="flex cursor-pointer flex-col items-center justify-center rounded-md border-2 border-muted bg-popover p-4 transition-colors hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<span className="font-semibold text-lg">
{amount.label}
</span>
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Top-up Summary and Button */}
<div className="space-y-4">
<ErrorBoundary
fallback={
<TopUpSummaryErrorBoundary selectedAmount={selectedAmount} />
}
>
<Suspense
fallback={
<TopUpSummarySkeleton selectedAmount={selectedAmount} />
}
>
<TopUpSummary
selectedAmount={selectedAmount}
currentBalancePromise={balancePromise}
/>
</Suspense>
</ErrorBoundary>
<Button asChild className="w-full" size="lg">
<Link
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
prefetch={false}
target="_blank"
>
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
Top Up With Crypto
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function CurrentBalance({
balancePromise,
}: { balancePromise: Promise<number> }) {
const currentBalance = use(balancePromise);
return (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-2">
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">Current Credit Balance</span>
</div>
<span className="font-semibold text-lg">{formatUsd(currentBalance)}</span>
</div>
);
}
function CurrentBalanceSkeleton() {
return (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-2">
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">Current Credit Balance</span>
</div>
<Skeleton className="h-6 w-24" />
</div>
);
}
function CurrentBalanceErrorBoundary() {
return (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
<div className="flex items-center gap-2">
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">Current Credit Balance</span>
</div>
<span className="text-destructive-text text-sm">
Failed to load current credit balance, please try again later.
</span>
</div>
);
}
function TopUpSummary({
selectedAmount,
currentBalancePromise,
}: {
selectedAmount: string;
currentBalancePromise: Promise<number>;
}) {
const currentBalance = use(currentBalancePromise);
return (
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
<h3 className="font-medium text-sm">Summary</h3>
<div className="flex justify-between text-sm">
<span>Top-up amount:</span>
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
</div>
<div className="flex justify-between text-sm">
<span>New balance:</span>
<span className="font-medium">
{formatUsd(currentBalance + Number(selectedAmount))}
</span>
</div>
</div>
);
}
function TopUpSummarySkeleton({
selectedAmount,
}: {
selectedAmount: string;
}) {
return (
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
<h3 className="font-medium text-sm">Summary</h3>
<div className="flex justify-between text-sm">
<span>Top-up amount:</span>
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
</div>
<div className="flex justify-between text-sm">
<span>New balance:</span>
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
function TopUpSummaryErrorBoundary({
selectedAmount,
}: {
selectedAmount: string;
}) {
return (
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
<h3 className="font-medium text-sm">Summary</h3>
<div className="flex justify-between text-sm">
<span>Top-up amount:</span>
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
</div>
<div className="flex justify-between text-sm">
<span>New balance:</span>
<span className="text-destructive-text text-sm">
Unable to calculate
</span>
</div>
</div>
);
}
// utils
function formatUsd(amount: number) {
return amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
}
@@ -1,10 +1,14 @@
import { getStripeBalance } from "@/actions/stripe-actions";
import { type Team, getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { Billing } from "components/settings/Account/Billing";
import { redirect } from "next/navigation";
import { CreditsInfoCard } from "../../../../../../../../components/settings/Account/Billing/PlanCard";
import { Coupons } from "../../../../../../../../components/settings/Account/Billing/SubscriptionCoupons/Coupons";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
import { CreditBalanceSection } from "./components/credit-balance-section.client";
export default async function Page(props: {
params: Promise<{
@@ -15,8 +19,10 @@ export default async function Page(props: {
highlight?: string | string[];
}>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const pagePath = `/team/${params.team_slug}/settings/billing`;
const [account, team, authToken] = await Promise.all([
@@ -31,11 +37,6 @@ export default async function Page(props: {
const subscriptions = await getTeamSubscriptions(team.slug);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
if (!subscriptions) {
return (
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
@@ -44,18 +45,44 @@ export default async function Page(props: {
);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const highlightPlan =
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
: undefined;
const validPayment =
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
return (
<Billing
highlightPlan={
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
: undefined
}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
team={team}
subscriptions={subscriptions}
twAccount={account}
client={client}
/>
<div className="flex flex-col gap-12">
<div>
<PlanInfoCardClient
team={team}
subscriptions={subscriptions}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
highlightPlan={highlightPlan}
/>
</div>
{/* Credit Balance Section */}
{team.stripeCustomerId && (
<CreditBalanceSection
teamSlug={team.slug}
balancePromise={getStripeBalance(team.stripeCustomerId)}
/>
)}
<CreditsInfoCard
twAccount={account}
client={client}
teamSlug={team.slug}
/>
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
</div>
);
}
@@ -18,12 +18,15 @@ import {
DownloadIcon,
ReceiptIcon,
} from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { useTransition } from "react";
import type Stripe from "stripe";
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
import { searchParams } from "../search-params";
export function BillingHistory(props: {
teamSlug: string;
invoices: Stripe.Invoice[];
status: "all" | "past_due" | "open";
hasMore: boolean;
@@ -61,7 +64,7 @@ export function BillingHistory(props: {
// we treate "uncollectible" as unpaid
case "uncollectible": {
// if the invoice due date is in the past, we want to display it as past due
if (invoice.due_date && invoice.due_date < Date.now()) {
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
return <Badge variant="destructive">Past Due</Badge>;
}
return <Badge variant="outline">Open</Badge>;
@@ -122,19 +125,33 @@ export function BillingHistory(props: {
<TableCell>{getStatusBadge(invoice)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{invoice.status === "open" &&
invoice.hosted_invoice_url && (
{invoice.status === "open" && (
<>
{/* always show the crypto payment button */}
<Button variant="default" size="sm" asChild>
<a
href={invoice.hosted_invoice_url}
<Link
target="_blank"
rel="noopener noreferrer"
href={`/checkout/${props.teamSlug}/invoice?invoice_id=${invoice.id}`}
>
<CreditCardIcon className="mr-2 h-4 w-4 text-muted-foreground" />
Pay Now
</a>
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
Pay with crypto
</Link>
</Button>
)}
{/* if we have a hosted invoice url, show that */}
{invoice.hosted_invoice_url && (
<Button variant="outline" size="sm" asChild>
<a
href={invoice.hosted_invoice_url}
target="_blank"
rel="noopener noreferrer"
>
<CreditCardIcon className="mr-2 h-4 w-4" />
Pay with Card
</a>
</Button>
)}
</>
)}
{invoice.invoice_pdf && (
<Button variant="ghost" size="sm" asChild>
@@ -143,7 +160,7 @@ export function BillingHistory(props: {
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="mr-2 h-4 w-4 text-muted-foreground" />
<DownloadIcon className="mr-2 h-4 w-4 " />
PDF
</a>
</Button>
@@ -49,6 +49,7 @@ export default async function Page(props: {
<BillingFilter />
</div>
<BillingHistory
teamSlug={params.team_slug}
invoices={invoices.data}
hasMore={invoices.has_more}
// fall back to "all" if the status is not set
@@ -32,8 +32,8 @@ export function Cards(props: {
/>
<CardLink
title="Create Token"
description="Launch your own ERC-20 token"
title="Create Coin"
description="Launch your own ERC-20 coin"
href={`/team/${props.teamSlug}/${props.projectSlug}/assets/create`}
icon={CoinsIcon}
trackingLabel="create-token"
@@ -76,12 +76,14 @@ function CardLink(props: {
const trackEvent = useTrack();
function handleClick() {
trackEvent({
category: "assets-landing-page",
action: "click",
label: props.trackingLabel,
});
onClick?.();
if (onClick) {
trackEvent({
category: "assets-landing-page",
action: "click",
label: props.trackingLabel,
});
onClick();
}
}
return (
@@ -15,6 +15,7 @@ export function StepCard(props: {
| undefined
| {
type: "submit";
disabled?: boolean;
}
| {
type: "custom";
@@ -56,6 +57,7 @@ export function StepCard(props: {
variant="default"
className="gap-2"
type="submit"
disabled={props.nextButton.disabled}
onClick={() => {
trackEvent(
getStepCardTrackingData({
@@ -336,7 +336,7 @@ export function CreateTokenAssetPage(props: {
metadata: {
name:
formValues.saleEnabled && salePercent > 0
? "Token Sale phase"
? "Coin Sale phase"
: "Only Owner phase",
},
overrideList: [
@@ -101,6 +101,7 @@ export function CreateTokenAssetPageUI(props: {
{step === "distribution" && (
<TokenDistributionFieldset
tokenSymbol={tokenInfoForm.watch("symbol")}
client={props.client}
form={tokenDistributionForm}
accountAddress={props.accountAddress}
@@ -23,6 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useCsvUpload } from "hooks/useCsvUpload";
import {
@@ -92,7 +93,7 @@ export function TokenAirdropSection(props: {
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
{/* left */}
<div>
<h3 className="font-medium text-sm">CSV File Uploaded</h3>
<h3 className="font-medium text-sm">Airdrop List Set</h3>
<p className="text-muted-foreground text-sm">
<span className="font-semibold">
{airdropAddresses.length}
@@ -109,14 +110,14 @@ export function TokenAirdropSection(props: {
<SheetTrigger asChild>
<Button size="sm" variant="outline">
<FileTextIcon className="mr-2 size-4" />
View CSV
View List
</Button>
</SheetTrigger>
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
<SheetHeader className="mb-3">
<SheetTitle className="text-left">
Airdrop CSV
Airdrop List
</SheetTitle>
</SheetHeader>
<AirdropTable
@@ -152,11 +153,11 @@ export function TokenAirdropSection(props: {
<SheetContent className="flex h-dvh w-full flex-col gap-0 overflow-hidden lg:max-w-2xl">
<SheetHeader className="mb-3">
<SheetTitle className="text-left font-semibold text-lg">
Airdrop CSV File
Set up Airdrop
</SheetTitle>
<SheetDescription>
Upload a CSV file to airdrop tokens to a list of
addresses
Upload a CSV file or enter comma-separated addresses and
amounts to airdrop tokens
</SheetDescription>
</SheetHeader>
<AirdropUpload
@@ -176,7 +177,7 @@ export function TokenAirdropSection(props: {
className="min-w-44 gap-2 bg-background"
>
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
Upload CSV
Set up Airdrop
</Button>
</div>
)}
@@ -193,14 +194,43 @@ type AirdropUploadProps = {
client: ThirdwebClient;
};
// CSV parser for airdrop data
const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => {
return items
.map(({ address, quantity }) => ({
address: (address || "").trim(),
quantity: (quantity || "1").trim(),
}))
.filter(({ address }) => address !== "");
// Parse text input and convert to CSV-like format
const parseTextInput = (text: string): AirdropAddressInput[] => {
const lines = text
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
const result: AirdropAddressInput[] = [];
for (const line of lines) {
let parts: string[] = [];
if (line.includes("=")) {
parts = line.split("=");
} else if (line.includes(",")) {
parts = line.split(",");
} else if (line.includes("\t")) {
parts = line.split("\t");
} else {
parts = line.split(/\s+/);
}
parts = parts.map((part) => part.trim()).filter((part) => part !== "");
if (parts.length >= 1) {
const address = parts[0];
const quantity = parts[1] || "1";
if (address) {
result.push({
address: address.trim(),
quantity: quantity.trim(),
});
}
}
}
return result;
};
const AirdropUpload: React.FC<AirdropUploadProps> = ({
@@ -208,6 +238,8 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
onClose,
client,
}) => {
const [textInput, setTextInput] = useState("");
const {
normalizeQuery,
getInputProps,
@@ -216,11 +248,30 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
noCsv,
reset,
removeInvalid,
} = useCsvUpload<AirdropAddressInput>({ csvParser, client });
processData,
} = useCsvUpload<AirdropAddressInput>({
csvParser: (items: AirdropAddressInput[]) => {
return items
.map(({ address, quantity }) => ({
address: (address || "").trim(),
quantity: (quantity || "1").trim(),
}))
.filter(({ address }) => address !== "");
},
client,
});
const normalizeData = normalizeQuery.data;
if (!normalizeData) {
// Handle text input - directly process the parsed data
const handleTextSubmit = () => {
if (!textInput.trim()) return;
const parsedData = parseTextInput(textInput);
processData(parsedData);
};
if (!normalizeData && rawData.length > 0) {
return (
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
<Spinner className="size-10" />
@@ -229,6 +280,8 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
}
const handleContinue = () => {
if (!normalizeData) return;
setAirdrop(
normalizeData.result.map((o) => ({
address: o.resolvedAddress || o.address,
@@ -239,9 +292,16 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
onClose();
};
const handleReset = () => {
reset();
setTextInput("");
};
return (
<div className="flex w-full grow flex-col gap-6 overflow-hidden">
{normalizeData.result.length && rawData.length > 0 ? (
{normalizeData &&
normalizeData.result.length > 0 &&
rawData.length > 0 ? (
<div className="flex grow flex-col overflow-hidden outline">
{normalizeQuery.data.invalidFound && (
<p className="mb-3 text-red-500 text-sm">
@@ -253,19 +313,12 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
className="rounded-b-none"
/>
<div className="flex justify-between gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<Button
variant="outline"
disabled={rawData.length === 0}
onClick={() => {
reset();
}}
>
<Button variant="outline" onClick={handleReset}>
<RotateCcwIcon className="mr-2 size-4" />
Reset
</Button>
{normalizeQuery.data.invalidFound ? (
<Button
disabled={rawData.length === 0}
onClick={() => {
removeInvalid();
}}
@@ -274,69 +327,120 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
Remove invalid addresses
</Button>
) : (
<Button onClick={handleContinue} disabled={rawData.length === 0}>
<Button onClick={handleContinue}>
Continue <ArrowRightIcon className="ml-2 size-4" />
</Button>
)}
</div>
</div>
) : (
<div>
<div className="relative w-full">
<div
className={cn(
"flex h-[300px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
noCsv &&
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
)}
{...getRootProps()}
>
<input {...getInputProps()} accept=".csv" />
<div className="flex flex-col items-center justify-center gap-3">
{!noCsv && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
<UploadIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-lg">
Upload CSV File
</h2>
<p className="text-center font-medium text-muted-foreground text-sm">
Drag and drop your file or click here to upload
</p>
</div>
)}
<div className="flex flex-col gap-6">
{/* CSV Upload Section - First */}
<div className="space-y-4">
<CSVFormatDetails />
{noCsv && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
<XIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
Invalid CSV
</h2>
<p className="text-balance text-center text-sm">
Your CSV does not contain the "address" & "quantity"
columns
</p>
<Button
className="relative z-50 mt-4"
size="sm"
onClick={(e) => {
e.stopPropagation();
reset();
}}
>
Remove Invalid CSV
</Button>
</div>
<div className="relative w-full">
<div
className={cn(
"flex h-[180px] cursor-pointer items-center justify-center rounded-md border border-dashed bg-card hover:border-active-border",
noCsv &&
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
)}
{...getRootProps()}
>
<input {...getInputProps()} accept=".csv" />
<div className="flex flex-col items-center justify-center gap-3">
{!noCsv && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
<UploadIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-lg">
Upload CSV File
</h2>
<p className="text-center font-medium text-muted-foreground text-sm">
Drag and drop your file or click here to upload
</p>
</div>
)}
{noCsv && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
<XIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
Invalid CSV
</h2>
<p className="text-balance text-center text-sm">
Your CSV does not contain the "address" & "quantity"
columns
</p>
<Button
className="relative z-50 mt-4"
size="sm"
onClick={(e) => {
e.stopPropagation();
reset();
}}
>
Remove Invalid CSV
</Button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or enter manually
</span>
</div>
</div>
{/* Text Input Section - Second */}
<div className="space-y-4">
<div>
<h3 className="mb-2 font-semibold">
Enter Addresses and Amounts
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Enter one address and amount on each line. Supports various
formats. (space, comma, or =)
</p>
<div className="space-y-3">
<Textarea
placeholder={`0x314ab97b76e39d63c78d5c86c2daf8eaa306b182 3.141592
thirdweb.eth,2.7182
0x141ca95b6177615fb1417cf70e930e102bf8f384=1.41421`}
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
className="min-h-[120px] font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
handleTextSubmit();
}
}}
/>
<Button
onClick={handleTextSubmit}
disabled={!textInput.trim()}
className="w-full"
>
Enter
</Button>
</div>
</div>
</div>
<div className="h-6" />
<CSVFormatDetails />
</div>
)}
</div>
@@ -23,20 +23,23 @@ export function TokenDistributionFieldset(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
tokenSymbol: string | undefined;
}) {
const { form } = props;
const distributionError = getDistributionError(form);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(props.onNext)}>
<StepCard
title="Token Distribution"
title="Coin Distribution"
page="distribution"
prevButton={{
onClick: props.onPrevious,
}}
nextButton={{
type: "submit",
disabled: !!distributionError,
}}
>
<div>
@@ -50,14 +53,22 @@ export function TokenDistributionFieldset(props: {
<div className="relative">
<Input id="supply" {...form.register("supply")} />
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
Tokens
{props.tokenSymbol || "Tokens"}
</span>
</div>
</FormFieldSetup>
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
<div className="flex flex-col gap-3">
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
{distributionError && (
<div className="text-destructive-text text-sm">
{distributionError}
</div>
)}
</div>
</div>
<TokenAirdropSection form={form} client={props.client} />
@@ -73,6 +84,36 @@ export function TokenDistributionFieldset(props: {
);
}
function getDistributionError(form: TokenDistributionForm) {
const supply = Number(form.watch("supply"));
const totalAirdrop = form.watch("airdropAddresses").reduce((sum, addr) => {
return sum + SafeNumber(addr.quantity);
}, 0);
if (totalAirdrop > supply) {
return "Total airdrop quantity exceeds total supply";
}
const saleSupplyPercentage = SafeNumber(
form.watch("saleAllocationPercentage"),
);
const saleSupply = (saleSupplyPercentage / 100) * supply;
const ownerSupply = Math.max(supply - totalAirdrop - saleSupply, 0);
const totalSumOfSupply = totalAirdrop + saleSupply + ownerSupply;
if (totalSumOfSupply > supply) {
return "Token distribution exceeds total supply";
}
return undefined;
}
function SafeNumber(value: string) {
const num = Number(value);
return Number.isNaN(num) ? 0 : num;
}
export function TokenDistributionBarChart(props: {
distributionFormValues: TokenDistributionFormValues;
}) {
@@ -86,7 +127,8 @@ export function TokenDistributionBarChart(props: {
const salePercentage = Number(
props.distributionFormValues.saleAllocationPercentage,
);
const ownerPercentage = 100 - airdropPercentage - salePercentage;
const ownerPercentage = Math.max(100 - airdropPercentage - salePercentage, 0);
const tokenAllocations: Segment[] = [
{
@@ -107,9 +149,6 @@ export function TokenDistributionBarChart(props: {
];
return (
<DistributionBarChart
segments={tokenAllocations}
title="Token Allocation"
/>
<DistributionBarChart segments={tokenAllocations} title="Coin Allocation" />
);
}
@@ -26,7 +26,7 @@ export function TokenSaleSection(props: {
<div>
<h2 className="font-semibold text-lg">Sale</h2>
<p className="text-muted-foreground text-sm">
Make your token available for purchase by setting a price
Make your coin available for purchase by setting a price
</p>
</div>
@@ -16,11 +16,16 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { ChainIconClient } from "components/icons/ChainIcon";
import { useTrack } from "hooks/analytics/useTrack";
import { useAllChainsData } from "hooks/chains/allChains";
import { ArrowRightIcon, ImageOffIcon, RocketIcon } from "lucide-react";
import {
ArrowRightIcon,
ArrowUpFromLineIcon,
ImageOffIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet } from "thirdweb/react";
import { parseError } from "../../../../../../../../../utils/errorParser";
import { StepCard } from "../create-token-card";
import type { CreateTokenFunctions } from "../create-token-page.client";
import { TokenDistributionBarChart } from "../distribution/token-distribution";
@@ -85,10 +90,10 @@ export function LaunchTokenStatus(props: {
executeFn: (values: CreateAssetFormValues) => Promise<void>,
) {
return async () => {
updateStatus(index, "pending");
updateStatus(index, { type: "pending" });
try {
await executeFn(formValues);
updateStatus(index, "completed");
updateStatus(index, { type: "completed" });
// start next one
const nextStep = initialSteps[index + 1];
if (nextStep) {
@@ -101,7 +106,7 @@ export function LaunchTokenStatus(props: {
props.onLaunchSuccess();
}
} catch (error) {
updateStatus(index, "error");
updateStatus(index, { type: "error", message: parseError(error) });
launchTracking({
type: "error",
errorMessage:
@@ -115,8 +120,7 @@ export function LaunchTokenStatus(props: {
const initialSteps: MultiStepState[] = [
{
label: "Deploy contract",
status: "idle",
retryLabel: "Failed to deploy contract",
status: { type: "idle" },
execute: createSequenceExecutorFn(0, async (values) => {
const result = await createTokenFunctions.deployContract(values);
setContractLink(
@@ -126,8 +130,7 @@ export function LaunchTokenStatus(props: {
},
{
label: "Set claim conditions",
status: "idle",
retryLabel: "Failed to set claim conditions",
status: { type: "idle" },
execute: createSequenceExecutorFn(
1,
createTokenFunctions.setClaimConditions,
@@ -135,8 +138,7 @@ export function LaunchTokenStatus(props: {
},
{
label: "Mint tokens",
status: "idle",
retryLabel: "Failed to mint tokens",
status: { type: "idle" },
execute: createSequenceExecutorFn(2, createTokenFunctions.mintTokens),
},
];
@@ -144,8 +146,7 @@ export function LaunchTokenStatus(props: {
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
label: "Airdrop tokens",
status: "idle",
retryLabel: "Failed to airdrop tokens",
status: { type: "idle" },
execute: createSequenceExecutorFn(
3,
createTokenFunctions.airdropTokens,
@@ -160,13 +161,13 @@ export function LaunchTokenStatus(props: {
initialSteps[0]?.execute();
}
const isComplete = steps.every((step) => step.status === "completed");
const isPending = steps.some((step) => step.status === "pending");
const isComplete = steps.every((step) => step.status.type === "completed");
const isPending = steps.some((step) => step.status.type === "pending");
return (
<StepCard
page="launch"
title="Launch Token"
title="Launch Coin"
prevButton={{
onClick: props.onPrevious,
}}
@@ -182,8 +183,8 @@ export function LaunchTokenStatus(props: {
transactionCount={undefined}
onClick={handleSubmitClick}
>
<RocketIcon className="size-4" />
Launch Token
<ArrowUpFromLineIcon className="size-4" />
Launch Coin
</TransactionButton>
),
}}
@@ -256,7 +257,7 @@ export function LaunchTokenStatus(props: {
<div>
<Button asChild className="gap-2">
<Link href={contractLink}>
View Launched Token <ArrowRightIcon className="size-4" />
View Coin <ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
@@ -89,7 +89,7 @@ function PageHeader(props: {
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create Token</BreadcrumbPage>
<BreadcrumbPage>Create Coin</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -98,10 +98,10 @@ function PageHeader(props: {
<div className="container flex max-w-5xl flex-col gap-3 py-8 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
Create Token
Create Coin
</h1>
<p className="text-muted-foreground">
Launch an ERC-20 token for your project
Launch an ERC-20 coin for your project
</p>
</div>
</div>
@@ -32,7 +32,7 @@ export function TokenInfoFieldset(props: {
<form onSubmit={form.handleSubmit(props.onNext)}>
<StepCard
page="info"
title="Token Information"
title="Coin Information"
prevButton={undefined}
nextButton={{
type: "submit",
@@ -70,7 +70,7 @@ export function TokenInfoFieldset(props: {
>
<Input
id="name"
placeholder="My Token"
placeholder="My Coin"
{...form.register("name")}
/>
</FormFieldSetup>
@@ -120,7 +120,7 @@ export function TokenInfoFieldset(props: {
>
<Textarea
id="description"
placeholder="Describe your token"
placeholder="Describe your coin"
className="grow"
{...form.register("description")}
/>
@@ -6,13 +6,7 @@ import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export function EngineCloudChartCard(props: AnalyticsQueryParams) {
return (
<Suspense
fallback={
<div className="h-[400px]">
<LoadingChartState />
</div>
}
>
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<EngineCloudChartCardAsync {...props} />
</Suspense>
);
@@ -7,13 +7,7 @@ import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI";
export function RpcMethodBarChartCard(props: AnalyticsQueryParams) {
return (
// TODO: Add better LoadingChartState
<Suspense
fallback={
<div className="h-[400px]">
<LoadingChartState />
</div>
}
>
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<RpcMethodBarChartCardAsync {...props} />
</Suspense>
);
@@ -13,13 +13,7 @@ export function TransactionsCharts(
) {
return (
// TODO: Add better LoadingChartState
<Suspense
fallback={
<div className="h-[400px]">
<LoadingChartState />
</div>
}
>
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<TransactionsChartCardAsync {...props} />
</Suspense>
);
@@ -33,7 +33,7 @@ export default async function Layout(props: {
tokens via cross-chain routing.{" "}
<UnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/pay/overview"
href="https://portal.thirdweb.com/pay"
>
Learn more
</UnderlineLink>
@@ -1,11 +1,10 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DatabaseIcon } from "lucide-react";
import { DatabaseIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { EngineIcon } from "../../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { TabPathLinks } from "../../../../../../../../../@/components/ui/tabs";
import { ImportEngineLink } from "./_components";
export default async function Layout(props: {
@@ -17,46 +16,35 @@ export default async function Layout(props: {
}) {
const params = await props.params;
const linkPrefix = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`;
const sidebarLinks: SidebarLink[] = [
{
label: "Engine Instances",
href: `${linkPrefix}`,
exactMatch: true,
},
{
label: "Import Engine",
href: `${linkPrefix}/import`,
},
];
return (
<div className="container flex max-w-7xl grow flex-col">
<div className="flex grow flex-col">
{/* header */}
<header className="border-border border-b py-10">
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex flex-col gap-2">
<h1 className="flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="warning"
className="flex items-center gap-2 text-sm"
>
<DatabaseIcon className="size-4" /> Dedicated
</Badge>
</h1>
<p className="flex flex-col text-muted-foreground text-sm">
Manage your deployed Engine instances
</p>
<header className="pt-10 pb-6">
<div className="container max-w-7xl">
<div className="flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex flex-col gap-2">
<h1 className="flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="warning"
className="flex items-center gap-2 text-sm"
>
<DatabaseIcon className="size-4" /> Dedicated
</Badge>
</h1>
<p className="flex flex-col text-muted-foreground text-sm">
Manage your deployed Engine instances
</p>
</div>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
<div className="h-6" />
<div className="container">
<div className="h-6" />
<EngineLegacyBannerUI
teamSlug={params.team_slug}
projectSlug={params.project_slug}
@@ -64,10 +52,28 @@ export default async function Layout(props: {
</div>
</header>
{/* sidebar layout */}
<SidebarLayout sidebarLinks={sidebarLinks}>
{props.children}
</SidebarLayout>
<div className="relative">
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
<div className="container max-w-7xl">
<TabPathLinks
className="pt-2"
bottomLineClassName="hidden"
links={[
{
name: "Engine Instances",
path: `${linkPrefix}`,
exactMatch: true,
},
{
name: "Import Engine",
path: `${linkPrefix}/import`,
},
]}
/>
</div>
</div>
<div className="container max-w-7xl pt-8 pb-10">{props.children}</div>
</div>
);
}
@@ -78,32 +84,37 @@ function EngineLegacyBannerUI(props: {
}) {
return (
<Alert variant="info">
<EngineIcon className="size-5" />
<AlertTitle>Engine Cloud (Beta)</AlertTitle>
<AlertDescription>
<div className="h-2" />
<p className="text-primary-foreground text-sm">
<AlertDescription className="pt-1">
<p className="text-foreground text-sm">
Try Engine Cloud (Beta) - now included for free in every thirdweb
project.
</p>
<div className="h-2" />
<ul className="list-disc pl-4 text-muted-foreground text-sm">
<ul className="list-disc space-y-1.5 pl-4 text-muted-foreground text-sm">
<li>No recurring monthly cost, pay-per-request model</li>
<li>Powered by Vault: our new TEE based key management system</li>
<li>Improved performance and simplified transaction API</li>
</ul>
<div className="h-4" />
<div className="flex justify-end gap-2">
<Link href={"https://portal.thirdweb.com/engine/v3"} target="_blank">
<Button variant="outline">Learn More</Button>
</Link>
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/cloud`}
>
<Button variant={"primary"} className="flex items-center gap-2">
<div className="h-6" />
<div className="flex justify-start gap-3">
<Button className="flex items-center gap-2" asChild size="sm">
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/cloud`}
>
<EngineIcon className="size-4" /> Try Engine Cloud
</Button>
</Link>
</Link>
</Button>
<Button variant="outline" asChild size="sm">
<Link
href={"https://portal.thirdweb.com/engine/v3"}
target="_blank"
className="gap-2 bg-background"
>
Learn More <ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
</AlertDescription>
</Alert>
@@ -2,7 +2,6 @@
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -53,7 +52,6 @@ import {
} from "@3rdweb-sdk/react/hooks/useEngine";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { PRO_CONTACT_US_URL } from "constants/pro";
import { useTrack } from "hooks/analytics/useTrack";
import {
CheckIcon,
@@ -895,37 +893,17 @@ function EmptyEngineState(props: {
Managed Engine
</h3>
<p className="text-muted-foreground text-sm">
{props.teamPlan !== "pro" ? (
<>
Upgrade your team plan to Accelerate or Scale to get a
fully managed engine instance.{" "}
<UnderlineLink href="/pricing" target="_blank">
View pricing
</UnderlineLink>
</>
) : (
<>
Contact us to add a managed engine instance to your team
</>
)}
Contact us to add a managed engine instance to your team.{" "}
<br /> We recommend using Engine Cloud in most cases
</p>
<div className="h-4" />
<div className="mt-auto">
{props.teamPlan !== "pro" ? (
<Button className="w-full gap-2" size="sm" asChild>
<Link href={`/team/${props.teamSlug}/~/settings/billing`}>
Upgrade Plan
<ArrowRightIcon size={16} />
</Link>
</Button>
) : (
<Button className="w-full gap-2" size="sm" asChild>
<Link href={PRO_CONTACT_US_URL} target="_blank">
Contact Us
<ArrowRightIcon size={16} />
</Link>
</Button>
)}
<Button className="w-full gap-2" size="sm" asChild>
<Link href={"/contact-us"} target="_blank">
Contact Us
<ArrowRightIcon size={16} />
</Link>
</Button>
</div>
</div>
)}
@@ -37,6 +37,7 @@ import {
getChainMetadata,
} from "thirdweb/chains";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import { LoadingChartState } from "../../../../../../components/analytics/empty-chart-state";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
@@ -148,107 +149,29 @@ async function ProjectAnalytics(props: {
}) {
const { project, params, range, interval, searchParams, client } = props;
// Fetch all analytics data in parallel
const [
walletConnections,
walletUserStatsTimeSeries,
inAppWalletUsage,
userOpUsageTimeSeries,
userOpUsage,
universalBridgeUsage,
] = await Promise.allSettled([
// Aggregated wallet connections
getWalletConnections({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: "all",
}),
// Time series data for wallet users
getWalletUsers({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: interval,
}),
// In-app wallet usage
getInAppWalletUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: "all",
}),
// User operations usage
getUserOpUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: interval,
}),
getUserOpUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: "all",
}),
// Universal Bridge
getUniversalBridgeUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: interval,
}),
]);
return (
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled" &&
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ? (
<div className="">
<AppHighlightsCard
chartKey={
(searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={params}
userStats={walletUserStatsTimeSeries.value}
volumeStats={universalBridgeUsage.value}
searchParams={searchParams}
/>
</div>
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<AsyncAppHighlightsCard
project={project}
range={range}
interval={interval}
searchParams={searchParams}
client={client}
params={params}
/>
)}
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
{walletConnections.status === "fulfilled" &&
walletConnections.value.length > 0 ? (
<WalletDistributionCard data={walletConnections.value} />
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
{inAppWalletUsage.status === "fulfilled" &&
inAppWalletUsage.value.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage.value} />
) : (
<EmptyStateCard
metric="In-App Wallets"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
)}
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<AsyncWalletDistributionCard project={project} range={range} />
</Suspense>
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<AsyncAuthMethodDistributionCard project={project} range={range} />
</Suspense>
</div>
<TransactionsCharts
searchParams={searchParams}
from={range.from}
@@ -257,22 +180,16 @@ async function ProjectAnalytics(props: {
teamId={project.teamId}
client={client}
/>
{userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
<div className="">
<TotalSponsoredCard
searchParams={searchParams}
data={userOpUsageTimeSeries.value}
aggregatedData={userOpUsage.value}
/>
</div>
) : (
<EmptyStateCard
metric="Sponsored Transactions"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<AsyncTotalSponsoredCard
project={project}
range={range}
interval={interval}
searchParams={searchParams}
/>
)}
</Suspense>
<RpcMethodBarChartCard
from={range.from}
to={range.to}
@@ -332,6 +249,154 @@ function processTimeSeriesData(
return metrics;
}
async function AsyncTotalSponsoredCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
}) {
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
getUserOpUsage({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: props.interval,
}),
getUserOpUsage({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: "all",
}),
]);
return userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
<div className="">
<TotalSponsoredCard
searchParams={props.searchParams}
data={userOpUsageTimeSeries.value}
aggregatedData={userOpUsage.value}
/>
</div>
) : (
<EmptyStateCard
metric="Sponsored Transactions"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
);
}
async function AsyncAuthMethodDistributionCard(props: {
project: Project;
range: Range;
}) {
const inAppWalletUsage = await getInAppWalletUsage({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: "all",
}).catch(() => undefined);
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
) : (
<EmptyStateCard
metric="In-App Wallets"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
);
}
async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
params: PageParams;
}) {
const [walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getWalletUsers({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: props.interval,
}),
getUniversalBridgeUsage({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: props.interval,
}),
]);
if (
walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled" &&
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0)
) {
return (
<div>
<AppHighlightsCard
chartKey={
(props.searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={props.params}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
: []
}
volumeStats={
universalBridgeUsage.status === "fulfilled"
? universalBridgeUsage.value
: []
}
searchParams={props.searchParams}
/>
</div>
);
}
return (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
);
}
async function AsyncWalletDistributionCard(props: {
project: Project;
range: Range;
}) {
const walletConnections = await getWalletConnections({
teamId: props.project.teamId,
projectId: props.project.id,
from: props.range.from,
to: props.range.to,
period: "all",
}).catch(() => undefined);
return walletConnections && walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
);
}
function AppHighlightsCard({
chartKey,
userStats,
@@ -5,6 +5,7 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { redirect } from "next/navigation";
import { SaveLastUsedProject } from "../../../(sidebar)/components/SaveLastUsedProject";
import { SharedContractLayout } from "../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/shared-layout";
import { ErrorProvider } from "../../../../../../../../contexts/error-handler";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import {
getAuthToken,
@@ -59,31 +60,33 @@ export default async function ContractLayout(props: {
});
return (
<div className="flex min-h-dvh flex-col bg-background">
<div className="border-border border-b bg-card">
<TeamHeaderLoggedIn
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
account={account}
accountAddress={accountAddress}
client={client}
/>
<ErrorProvider>
<div className="flex min-h-dvh flex-col bg-background">
<div className="border-border border-b bg-card">
<TeamHeaderLoggedIn
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
account={account}
accountAddress={accountAddress}
client={client}
/>
</div>
<SharedContractLayout
contractAddress={params.contractAddress}
chainIdOrSlug={params.chainIdOrSlug}
authToken={authToken}
projectMeta={{
teamId: team.id,
projectSlug: params.project_slug,
teamSlug: params.team_slug,
}}
>
{props.children}
</SharedContractLayout>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
<AppFooter />
</div>
<SharedContractLayout
contractAddress={params.contractAddress}
chainIdOrSlug={params.chainIdOrSlug}
authToken={authToken}
projectMeta={{
teamId: team.id,
projectSlug: params.project_slug,
teamSlug: params.team_slug,
}}
>
{props.children}
</SharedContractLayout>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
<AppFooter />
</div>
</ErrorProvider>
);
}
@@ -5,7 +5,7 @@ import { ThirdwebProvider } from "thirdweb/react";
import { PHProvider } from "../../../../lib/posthog/Posthog";
import { PostHogPageView } from "../../../../lib/posthog/PosthogPageView";
export function Providers({ children }: { children: React.ReactNode }) {
export function BridgeProviders({ children }: { children: React.ReactNode }) {
return (
<ThirdwebProvider>
<ThemeProvider
@@ -14,7 +14,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
enableSystem={false}
defaultTheme="dark"
>
<PHProvider>
<PHProvider disable_session_recording={true}>
<PostHogPageView />
{children}
<Toaster richColors theme="dark" />
+2 -2
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { Inter } from "next/font/google";
import "../../global.css";
import { Providers } from "./components/client/Providers.client";
import { BridgeProviders } from "./components/client/Providers.client";
const fontSans = Inter({
subsets: ["latin"],
@@ -22,7 +22,7 @@ export default function BridgeLayout({
fontSans.variable,
)}
>
<Providers>{children}</Providers>
<BridgeProviders>{children}</BridgeProviders>
</body>
</html>
);
+1 -1
View File
@@ -39,7 +39,7 @@ export default function Layout(props: {
<link rel="icon" href="/assets/nebula/favicon.ico" />
<PosthogHeadSetup />
</head>
<PHProvider>
<PHProvider disable_session_recording={false}>
<PostHogPageView />
<body
className={cn(
@@ -4,10 +4,10 @@ import { ThirdwebProvider } from "thirdweb/react";
import { PHProvider } from "../../../../lib/posthog/Posthog";
import { PostHogPageView } from "../../../../lib/posthog/PosthogPageView";
export function Providers({ children }: { children: React.ReactNode }) {
export function PayProviders({ children }: { children: React.ReactNode }) {
return (
<ThirdwebProvider>
<PHProvider>
<PHProvider disable_session_recording={true}>
<PostHogPageView />
{children}
<Toaster richColors theme="dark" />
+3 -3
View File
@@ -2,7 +2,7 @@ import "../../global.css";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "next-themes";
import { Inter } from "next/font/google";
import { Providers } from "./components/client/Providers.client";
import { PayProviders } from "./components/client/Providers.client";
const fontSans = Inter({
subsets: ["latin"],
@@ -23,7 +23,7 @@ export default async function PayLayout({
fontSans.variable,
)}
>
<Providers>
<PayProviders>
<ThemeProvider
attribute="class"
disableTransitionOnChange
@@ -43,7 +43,7 @@ export default async function PayLayout({
/>
</div>
</ThemeProvider>
</Providers>
</PayProviders>
</body>
</html>
);
@@ -3,6 +3,7 @@ import { LoadingDots } from "@/components/ui/LoadingDots";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import { useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { cn } from "../../@/lib/utils";
type FakeCartData = {
value: number;
@@ -37,9 +38,14 @@ export function EmptyChartState({
);
}
export function LoadingChartState() {
export function LoadingChartState({ className }: { className?: string }) {
return (
<div className="pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30">
<div
className={cn(
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
className,
)}
>
<LoadingDots />
</div>
);
@@ -59,6 +59,7 @@ const GAS_FREE_CHAINS = [
75513, // Geek verse testnet
75512, // Geek verse mainnet
531050104, // sophon testnet
50104, // sophon mainnet
37111, // lens sepolia
4457845, // zero testnet
978658, // treasure topaz
@@ -103,23 +103,21 @@ export const ContractTypeCell = memo(function ContractTypeCell(props: {
loadedData={contractType || contractMetadata.data?.contractType}
skeletonData="Custom Contract"
render={(v) => {
if (v === contractType) {
return (
<Badge
className="line-clamp-1 inline-block max-w-[200px] truncate py-1"
variant="outline"
>
{v}
</Badge>
);
}
return (
<Badge variant="outline" className="inline-block py-1">
{v || "Custom"}
</Badge>
);
return <ContractTypeCellUI name={v} />;
}}
/>
);
});
export function ContractTypeCellUI(props: {
name: string | undefined;
}) {
return (
<Badge
variant="outline"
className="line-clamp-1 inline-block max-w-[200px] truncate py-1"
>
{props.name || "Custom"}
</Badge>
);
}
@@ -33,7 +33,11 @@ import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import type { ProjectContract } from "../../../app/(app)/account/contracts/_components/getProjectContracts";
import { removeContractFromProject } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/hooks/project-contracts";
import { ContractNameCell, ContractTypeCell } from "./cells";
import {
ContractNameCell,
ContractTypeCell,
ContractTypeCellUI,
} from "./cells";
type ContractTableFilters = {
chainId: number | undefined;
@@ -186,11 +190,21 @@ export function ContractTableUI(props: {
</TableCell>
<TableCell>
<ContractTypeCell
chainId={contract.chainId}
contractAddress={contract.contractAddress}
client={props.client}
/>
{contract.contractType &&
props.variant === "asset" &&
contractTypeToAssetTypeRecord[contract.contractType] ? (
<ContractTypeCellUI
name={
contractTypeToAssetTypeRecord[contract.contractType]
}
/>
) : (
<ContractTypeCell
chainId={contract.chainId}
contractAddress={contract.contractAddress}
client={props.client}
/>
)}
</TableCell>
<TableCell>
@@ -278,6 +292,10 @@ export function ContractTableUI(props: {
);
}
const contractTypeToAssetTypeRecord: Record<string, string | undefined> = {
DropERC20: "Coin",
};
const NetworkFilterCell = React.memo(function NetworkFilterCell({
chainId: selectedChain,
setChainId: setSelectedChain,
@@ -1,50 +0,0 @@
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { ThirdwebClient } from "thirdweb";
import { PlanInfoCardClient } from "../../../../app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client";
import { CreditsInfoCard } from "./PlanCard";
import { Coupons } from "./SubscriptionCoupons/Coupons";
// TODO - move this in app router folder in other pr
interface BillingProps {
team: Team;
subscriptions: TeamSubscription[];
twAccount: Account;
client: ThirdwebClient;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}
export const Billing: React.FC<BillingProps> = ({
team,
subscriptions,
twAccount,
client,
openPlanSheetButtonByDefault,
highlightPlan,
}) => {
const validPayment =
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
return (
<div className="flex flex-col gap-12">
<div>
<PlanInfoCardClient
team={team}
subscriptions={subscriptions}
openPlanSheetButtonByDefault={openPlanSheetButtonByDefault}
highlightPlan={highlightPlan}
/>
</div>
<CreditsInfoCard
twAccount={twAccount}
client={client}
teamSlug={team.slug}
/>
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
</div>
);
};
+15
View File
@@ -161,6 +161,20 @@ export function useCsvUpload<
// Also filteredData's type is the superset of T[]
setRawData(filteredData as unknown as T[]);
}, [normalizeQuery.data?.result]);
const processData = useCallback(
(data: T[]) => {
setNoCsv(false);
const processedData = props.csvParser(data);
if (!processedData[0]?.address) {
setNoCsv(true);
return;
}
setRawData(processedData);
},
[props.csvParser],
);
return {
normalizeQuery,
getInputProps,
@@ -170,5 +184,6 @@ export function useCsvUpload<
noCsv,
reset,
removeInvalid,
processData,
};
}
+5 -6
View File
@@ -7,10 +7,9 @@ import { useEffect } from "react";
const NEXT_PUBLIC_POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
export function PHProvider({
children,
}: {
export function PHProvider(props: {
children: React.ReactNode;
disable_session_recording: boolean;
}) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -19,10 +18,10 @@ export function PHProvider({
api_host: "https://a.thirdweb.com",
capture_pageview: false,
debug: false,
disable_session_recording: true,
disable_session_recording: props.disable_session_recording,
});
}
}, []);
}, [props.disable_session_recording]);
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
return <PostHogProvider client={posthog}>{props.children}</PostHogProvider>;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function parseErrorToMessage(error: unknown): string | JSX.Element {
);
}
function parseError(error: unknown): string | JSX.Element {
export function parseError(error: unknown): string | JSX.Element {
// if the error is a straight string just return it
if (typeof error === "string") {
return error;
@@ -8,7 +8,7 @@ const getEcosystemWallet = () => {
process.env.NEXT_PUBLIC_IN_APP_WALLET_URL?.endsWith(".thirdweb-dev.com")
) {
// dev ecosystem
return ecosystemWallet("ecosystem.catlovers");
return ecosystemWallet("ecosystem.catfans");
}
// prod ecosystem
return ecosystemWallet("ecosystem.thirdweb-engs", {
+6 -6
View File
@@ -74,10 +74,6 @@ const toolLinks = [
name: "Hex Converter",
href: "https://thirdweb.com/tools/hex-converter",
},
{
name: "Account",
href: "/account",
},
{
name: "API Keys",
href: "/account/api-keys",
@@ -188,8 +184,12 @@ const supportLinks = [
href: "/knowledge-base",
},
{
name: "Contact Sales",
href: "https://thirdweb.com/contact-us",
name: "Account",
href: "/account",
},
{
name: "Status",
href: "https://status.thirdweb.com",
},
];
@@ -30,4 +30,20 @@ Invite your team members using email addresses and assign a member or owner role
</Step>
</Steps>
## Transfer Ownership
Owners may transfer ownership of a team to another member by adding a new owner and deleting the current owner. This is useful if you want to change the primary contact for billing or project management.
<Steps>
<Step title="Navigate to Settings > Members">
While logged in, navigate to settings on your dashboard.
</Step>
<Step title="Transfer Ownership">
Invite the member you want to transfer ownership to and select the "Owner" role for them. Ensure that the member has accepted the invitation and is listed as a team member.
</Step>
<Step title="Delete Prior Owner">
Using the three dots next to the member's name, select "Remove Member" to remove the owner from the team.
</Step>
</Steps>
+2 -2
View File
@@ -65,8 +65,8 @@ If you are developing a Unity native or mobile application, you will need to obt
## Billing
<Details summary="How do I sign up for a thirdweb Pro plan?">
To subscribe to the thirdweb Pro plan, please [contact Sales](https://thirdweb.com/contact-us) for more details about its features and to receive a quote for the monthly subscription fee.
<Details summary="How do I sign up for a customized or enterprise thirdweb plan?">
To subscribe to a customized plan, please [contact Sales](https://thirdweb.com/contact-us) for more details.
</Details>
<Details summary="How do I update my payment method?">
@@ -0,0 +1,490 @@
# Thirdweb Bundler - API Reference
The Thirdweb Bundler is a service that provides Account Abstraction (ERC-4337 and EIP-7702) bundler functionality with integrated paymaster services, zkSync chains support, and EIP-7702 transaction delegation.
## Table of Contents
- [Url format](#url-format)
- [Authentication](#authentication)
- [Request Format](#request-format)
- [Paymaster Methods](#paymaster-methods)
- [User Operation Methods](#user-operation-methods)
- [Gas Price Methods](#gas-price-methods)
- [Status and Tracking Methods](#status-and-tracking-methods)
- [zkSync-Specific Methods](#zksync-specific-methods)
- [EIP-7702 Methods](#eip-7702-methods)
- [Standard ERC-4337 Methods](#standard-erc-4337-methods)
- [Error Responses](#error-responses)
- [Policy Enforcement](#policy-enforcement)
## Url format
- **Chain Parameter**: the chain id is always specified in the subdomain (e.g., `<chain_id>.bundler.thirdweb.com`)
- **Version Parameter**: the version is specified in the path, we recommend using `/v2`
```
https://<chain_id>.bundler.thirdweb.com/v2
```
## Authentication
All requests require authentication via:
- **Client ID**: Provided as `X-Client-Id: <client_id>` header - used for frontend authentication
- **Secret Key**: Provided as `X-Secret-Key: <secret_key>` header - used for backend authentication
## Request Format
All RPC requests follow the JSON-RPC 2.0 specification:
```json
{
"id": 1,
"jsonrpc": "2.0",
"method": "<method_name>",
"params": [...]
}
```
## Paymaster Methods
### `pm_sponsorUserOperation`
**Purpose**: Sponsors a user operation by providing paymaster data to cover gas costs.
**Parameters**:
```json
[
{
"sender": "0x...", // Address of the smart account
"nonce": "0x...", // Nonce for the operation
"initCode": "0x...", // Account initialization code (if needed)
"callData": "0x...", // The call data to execute
"callGasLimit": "0x...", // Gas limit for the call
"verificationGasLimit": "0x...", // Gas limit for verification
"preVerificationGas": "0x...", // Pre-verification gas
"maxFeePerGas": "0x...", // Maximum fee per gas
"maxPriorityFeePerGas": "0x...", // Maximum priority fee per gas
"paymasterAndData": "0x...", // Paymaster data (EntryPoint v0.6)
// OR for EntryPoint v0.7:
"paymaster": "0x...", // Paymaster address
"paymasterData": "0x...", // Paymaster data
"paymasterVerificationGasLimit": "0x...",
"paymasterPostOpGasLimit": "0x..."
},
"0x...", // Entry point address
"0x...", // Chain ID (hex)
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"paymaster": "0x...", // Paymaster address
"paymasterData": "0x...", // Paymaster data
"paymasterAndData": "0x..." // Combined paymaster data (v0.6)
}
}
```
### `pm_getPaymasterData`
**Purpose**: Gets paymaster data for sponsoring a user operation (functionally equivalent to `pm_sponsorUserOperation`).
**Parameters**: Same as `pm_sponsorUserOperation`
**Response**: Same as `pm_sponsorUserOperation`
### `pm_getPaymasterStubData`
**Purpose**: Gets stub paymaster data for gas estimation purposes without actual sponsorship.
**Parameters**: Same as `pm_sponsorUserOperation`
**Response**: Similar to `pm_sponsorUserOperation` but with placeholder data for estimation.
### `pm_getAcceptedPaymentTokens`
**Purpose**: Returns the list of tokens accepted for gas payments by the paymaster.
**Parameters**: `[]`
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"acceptedTokens": []
}
}
```
## User Operation Methods
### `eth_estimateUserOperationGas`
**Purpose**: Estimates gas limits required for a user operation.
**Parameters**:
```json
[
{
"sender": "0x...", // Smart account address
"nonce": "0x...", // Account nonce
"initCode": "0x...", // Initialization code (if deploying)
"callData": "0x...", // Transaction call data
"maxFeePerGas": "0x...", // Maximum fee per gas
"maxPriorityFeePerGas": "0x..." // Maximum priority fee
},
"0x...", // Entry point address
{} // State overrides (optional)
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"preVerificationGas": "0x...", // Pre-verification gas
"verificationGasLimit": "0x...", // Verification gas limit
"callGasLimit": "0x...", // Call gas limit
// For EntryPoint v0.7:
"paymasterVerificationGasLimit": "0x...",
"paymasterPostOpGasLimit": "0x..."
}
}
```
### `eth_sendUserOperation`
**Purpose**: Submits a user operation to the bundler for execution on-chain.
**Parameters**: Same format as the user operation object used in other methods.
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": "0x..." // User operation hash
}
```
## Gas Price Methods
### `thirdweb_getUserOperationGasPrice`
**Purpose**: Gets current gas prices optimized for user operations.
**Parameters**: `[]`
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"maxFeePerGas": "0x...", // Recommended max fee per gas
"maxPriorityFeePerGas": "0x..." // Recommended priority fee
}
}
```
## Status and Tracking Methods
### `thirdweb_getUserOperationStatus`
**Purpose**: Gets the current status of a submitted user operation.
**Parameters**:
```json
["0x..."] // User operation hash (hex string)
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"status": "not_found|not_submitted|submitted|rejected|included|failed|queued",
"transactionHash": "0x..." // Transaction hash (null if not yet included)
}
}
```
**Status Values**:
- `not_found`: User operation not found
- `not_submitted`: Not yet submitted to mempool
- `submitted`: Submitted to mempool
- `rejected`: Rejected by bundler
- `included`: Successfully included in a block
- `failed`: Transaction failed on-chain
- `queued`: Queued for processing
## zkSync-Specific Methods
### `zk_paymasterData`
**Purpose**: Gets paymaster data for zkSync chains to enable gasless transactions.
**Parameters**:
```json
[
{
"nonce": "0x...", // Transaction nonce
"from": "0x...", // Sender address
"to": "0x...", // Recipient address
"gas": "0x...", // Gas limit
"gasPrice": "0x...", // Gas price
"value": "0x...", // ETH value to send
"data": "0x...", // Transaction data
"maxFeePerGas": "0x...", // EIP-1559 max fee
"maxPriorityFeePerGas": "0x...", // EIP-1559 priority fee
"chainId": "0x..." // Chain ID
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"paymaster": "0x...", // Paymaster contract address
"paymasterInput": "0x..." // Encoded paymaster input data
}
}
```
### `zk_broadcastTransaction`
**Purpose**: Broadcasts a signed transaction on zkSync chains.
**Parameters**:
```json
[
{
"nonce": "0x...",
"from": "0x...",
"to": "0x...",
"gas": "0x...",
"gasPrice": "0x...",
"value": "0x...",
"data": "0x...",
"maxFeePerGas": "0x...",
"maxPriorityFeePerGas": "0x...",
"chainId": "0x...",
"signedTransaction": "0x..." // Raw signed transaction bytes
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": "0x..." // Transaction hash
}
```
## EIP-7702 Methods
### `tw_execute`
**Purpose**: Executes transactions using EIP-7702 (Account Abstraction via Authorization Lists). This enables EOAs to temporarily act as smart contracts.
**Parameters**:
```json
[
"0x...", // EOA address that will execute the calls
{
"calls": [
{
"target": "0x...", // Contract address to call
"value": "0x...", // ETH value to send (hex)
"data": "0x..." // Call data
}
// ... more calls
],
"uid": "0x..." // Unique identifier for the batch (bytes32)
},
"0x...", // Signature authorizing the execution
{
// Authorization list (optional)
"chainId": 1,
"address": "0x...",
"nonce": "0x...",
"yParity": 0,
"r": "0x...",
"s": "0x..."
}
]
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"queueId": "..." // Queue ID for tracking the transaction
}
}
```
### `tw_getTransactionHash`
**Purpose**: Gets the transaction hash for a queued EIP-7702 transaction.
**Parameters**:
```json
["queue_id"] // Queue ID returned from tw_execute
```
**Response**:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"transactionHash": "0x..." // Transaction hash (null if not yet mined)
}
}
```
## Standard ERC-4337 Methods
The service supports all the standard ERC-4337 bundler methods:
- `eth_chainId` - Returns the current chain ID
- `eth_getUserOperationByHash` - Retrieves user operation by hash
- `eth_getUserOperationReceipt` - Gets the receipt for a user operation
- `eth_supportedEntryPoints` - Lists supported EntryPoint contracts
- Any other standard bundler RPC methods defined in ERC-4337
## Error Responses
All methods may return error responses following the JSON-RPC 2.0 error format:
```json
{
"id": 1,
"jsonrpc": "2.0",
"error": {
"code": 400,
"message": "Detailed error description"
}
}
```
### Common Error Codes
- **400**: Invalid parameters, malformed request, or unsupported chain
- **401**: Invalid authentication credentials or insufficient permissions
- **500**: Internal server error
- **502**: Upstream bundler provider error
### Example Error Scenarios
```json
// Invalid chain
{
"error": "Invalid chain: unsupported_chain",
"chain": "unsupported_chain",
"code": 400
}
// Mainnet access denied
{
"error": "Mainnets not enabled for this account, please enable billing...",
"code": 401
}
// Invalid user operation hash
{
"error": "Invalid userOpHash parameter",
"code": 400
}
```
## Policy Enforcement
Paymaster methods support policy-based access control. When a transaction is subject to policy evaluation:
### Policy Check Process
1. The system evaluates paymaster requests against configured policies
2. Policies can restrict based on:
- Contract addresses being called
- Transaction data
- Gas limits
- Sender addresses
- Custom endpoint logic
### Policy Rejection Response
If a request is rejected by policy, the response format is:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"policyId": "policy_123", // ID of the policy that rejected the request
"reason": "Transaction rejected by policy: Unauthorized contract call"
}
}
```
### Affected Methods
Policy enforcement applies to:
- `pm_sponsorUserOperation`
- `pm_getPaymasterData`
- `zk_paymasterData`
## Rate Limits and Quotas
- Rate limits may apply based on your billing plan
- Mainnet access requires billing information
- Testnet access is available on free plans
## Supported Networks
For the most up-to-date list of supported chains, check the [thirdweb dashboard](https://thirdweb.com/dashboard).
## SDK Integration
For easier integration, consider using the thirdweb SDKs:
- [JavaScript/TypeScript SDK](/typescript)
- [React SDK](/react)
- [Unity SDK](/unity)
- [Dotnet SDK](/dotnet)
- [Unreal Engine SDK](/unreal)
+4
View File
@@ -198,6 +198,10 @@ export const sidebar: SideBar = {
name: "Sponsorship rules",
href: `${aAslug}/sponsorship-rules`,
},
{
name: "API Reference",
href: `${aAslug}/api`,
},
{
name: "FAQs",
href: `${aAslug}/faq`,
@@ -0,0 +1,25 @@
# Smart Server Wallets
Smart server wallets are smart accounts designed to perform blockchain operations for backend applications.
## Benefits
Smart server wallets are the recommended way to perform blockchain operations with Engine.
- **Managed Gas Fees**: Smart server wallets manage gas fees for you by automatically paying the fees using the payment method associated with your thirdweb account. You do not have to worry about topping up
gas funds on your server wallets.
- **Secure Key Management**: Smart server wallets are secured by Vault. Read more about Vault in the Security section below.
- **Minimal Setup:** Smart server wallets can be created with just a few clicks directly in the thirdweb dashboard. No need to manage private keys or seed phrases or secure with external key management systems.
- **Higher Throughput:** Smart server wallets can handle higher throughput by using multi-dimensional nonces. While Engine handles concurrent transactions through a queue system, smart wallets use parallelization to process multiple transactions at the same time, more efficiently than EOAs.
## Security
Smart server wallets are secured by Vault, thirdweb's secure key management system. This setup ensures that your private keys are never exposed to the server or client, and all transactions are signed securely.
Keys used to manage server wallets including the admin key and access keys can be rotated or re-rolled at any time, should you need to rotate keys for security purposes. Read more about [Vault](/vault).
## Pricing
Smart server wallets cost a gas premium fee for each transaction completed on any mainnet as part of account abstraction fees. There are no gas charges for testnets.
[See pricing page for details](https://thirdweb.com/pricing).
+8 -1
View File
@@ -3,7 +3,7 @@ import { Callout, Details } from "@doc";
# Engine FAQs
<Details summary="How is pricing calculated for Engine Cloud?">
Pricing is calculated through the number of write requests (ex: /v1/write/contract) and sign requests (ex: /v1/sign/transaction) made through the Engine API. Write and sign requests cost $1 per 1,000 requests. Read requests made through Engine API are free, within the RPC limits/plan.
Pricing is calculated through the number of write requests (ex: /v1/write/contract) and sign requests (ex: /v1/sign/transaction) made through the Engine API. For requests costs, [see the thirdweb pricing page](https://thirdweb.com/pricing). Read requests made through Engine API are free, within the RPC limits/plan.
For transactions through server wallets paid through the user's thirdweb account, users will pay a 5% premium on the gas fee of each transaction completed on any mainnet as part of account abstraction fees. There are no gas charges for testnets.
@@ -11,3 +11,10 @@ For transactions through server wallets paid through the user's thirdweb account
The breakdown for usage and transaction fees can be found in your usage dashboard under the team overview.
</Details>
<Details summary="Which chains does Engine Cloud support?">
While in beta, Engine Cloud currently supports every non ZK EVM-compatible chain which includes support for Account Abstraction. See the current list of supported chains.
If your chain is not listed, please [contact us](https://thirdweb.com/contact-us) to prioritize enabling support.
</Details>
@@ -1,3 +1,10 @@
import { Details } from "@doc";
# Engine Troubleshoot Guide
More information coming soon.
<Details summary="Transactions stuck in a pending state using Engine Cloud.">
Ensure that Account Abstraction is enabled for your project by navigating to Project Settings > Enabled Services > Account Abstraction.
If disabled, toggle on Account Abstraction and try the transaction again.
</Details>
@@ -0,0 +1,61 @@
# Verify your team email domain
## Why should I verify my domain?
- **Verified badge**: Your team gets a special badge, so it’s easier to confirm you're in the right place.
- **Recommended team members**: On the Settings → Members page, you’ll see users with emails that match your domain but aren’t in the team yet — making it super easy to invite everyone on the team.
- **Instant invite alerts**: Team owners will get a notification (in the dashboard and optionally via email) when someone signs up with your verified domain — with a 1-click invite option.
## How to verify your domain
1. Navigate to [Team Settings](https://thirdweb.com/team/~/~/settings) and look for *Domain Verification*.
1. Enter your domain name.
- Your domain name is the part after the `@` symbol in your company email addresses. It is _not_ a website and should not contain `https://` nor `www`.
1. Follow the instructions to add a `TXT record` on your domain to complete verification.
- Type: `TXT`
- Name: `_tw-<YOUR_TEAM_ID>.<YOUR_DOMAIN>`
- Value: `tw-verify=<YOUR_VERIFICATION_CODE>`
- See below for provider-specific instructions.
TXT record changes will be detected automatically. DNS changes may take several hours to update.
Check this dashboard page periodically to confirm when it's completed.
### Verify your TXT record
Use a tool like [MxToolbox](https://mxtoolbox.com/txtlookup.aspx) or `dig` in the command line to check if your TXT record is correct.
```bash
$ dig +short -t txt _tw-<TEAM_ID>.<YOUR_DOMAIN>
# You should see this response if correctly configured
"tw-verify=<YOUR_VERIFICATION_CODE>"
```
## Provider-specific instructions
Here are instructions for common DNS providers. Please refer to the provider's documentation for further support.
### Cloudflare
- Navigate to the Cloudflare dashboard.
- Select your zone and domain.
- Select *DNS* > *Records*.
- Select *Add record*.
[Cloudflare documentation](https://www.cloudflare.com/learning/dns/dns-records/dns-txt-record/)
### AWS Route 53
- Navigate to the Route 53 console.
- Select *Hosted Zones* and select your domain.
- Select *Create record*.
[AWS Documentation](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-creating.html)
### Google Cloud DNS
- Navigate to the Google Cloud Console.
- Select *Network Services* > *Cloud DNS*.
- Select your zone.
- Select *Add record set*.
[Google Cloud documentation](https://cloud.google.com/dns/docs/records#add-rrset)
@@ -22,12 +22,17 @@ export const sidebar: SideBar = {
icon: <BracesIcon />,
links: [
{
name: "Creating Soulbound NFTs",
name: "Verify your team email domain",
expanded: true,
href: "/knowledge-base/how-to/verify-your-team-email-domain",
},
{
name: "Creating soulbound NFTs",
expanded: true,
href: "/knowledge-base/how-to/creating-soulbound-nfts",
},
{
name: "Console Error Sharing",
name: "Console error sharing",
expanded: true,
href: "/knowledge-base/how-to/console-error-sharing",
},
-8
View File
@@ -2,10 +2,6 @@ import { Details } from "@doc";
# Nebula FAQs
<Details summary="I am still waiting on access to Nebula. How can I be approved?">
We are rolling out Nebula approval in batches daily - stay tuned!
</Details>
<Details summary="What underlying model does Nebula use?">
During Alpha, we are primarily testing the t0 model while conducting limited trials of the upcoming t1 model.
The t0 model utilizes a mixture-of-agents architecture. We are targeting an early Q2 launch for t1, and will
@@ -67,7 +63,3 @@ Nebula is not currently open source. We are exploring open sourcing Nebula in th
<Details summary="Can Nebula be used in a stateless manner?">
Depending on the use case, Nebula can be used in a stateless manner by not reusing the same session. Nebula learns from each session, so having more contextual requests will be beneficial and are recommended.
</Details>
<Details summary="When will pricing be available for Nebula?">
Pricing options will be available in beta. Nebula is still being tested in alpha.
</Details>
+6
View File
@@ -140,6 +140,12 @@ function ReferenceSection() {
icon={ConnectIcon}
iconClassName="text-muted-foreground"
/>
<SDKCard
title="Bundler"
href="/connect/account-abstraction/api" // TODO: actual openAPI docs
icon={ConnectIcon}
iconClassName="text-muted-foreground"
/>
<SDKCard
title="Nebula"
href="/nebula/api-reference" // TODO: actual openAPI docs
+229 -63
View File
@@ -5,9 +5,12 @@ import {
Steps,
Step,
InstallTabs,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@doc";
import GetStartedSend from "../assets/get-started-send.png";
import GetStartedEmbed from "../assets/get-started-embed.png";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Get Started",
@@ -15,21 +18,19 @@ export const metadata = createMetadata({
},
title: "Universal Bridge Implementation Guide — thirdweb docs",
description:
"Learn how to implement all-in-one web3 payment solution, Universal bridge: the technical step-by-step process",
"Learn how to implement cross-chain payments, bridging, and onramps with the Universal Bridge SDK",
});
# Get Started
Learn how to add onramps and crypto purchases to your application. To see which integration is right for you, refer to our [integrations overview](/connect/pay/overview#integration-options).
The following guide uses our React SDK. You can also learn how to integrate Universal Bridge with our [Unity SDK](https://portal.thirdweb.com/unity/pay/getbuywithcryptoquote).
Learn how to integrate Universal Bridge into your application for cross-chain payments, bridging, swapping, and fiat onramps. This guide covers the core SDK modules and practical implementation examples.
---
## Installation
<Steps>
<Step title="Install the Connect SDK">
<Step title="Install the SDK">
<InstallTabs
npm="npm i thirdweb"
yarn="yarn add thirdweb"
@@ -38,95 +39,260 @@ The following guide uses our React SDK. You can also learn how to integrate Univ
</Step>
<Step title="Get Your Client ID">
Log in to [the thirdweb dashboard](https://thirdweb.com/team). Navigate to the **Settings** page and create an API key to get your **Client ID**. You'll need your Client ID to interact with the Connect SDK.
Log in to [the thirdweb dashboard](https://thirdweb.com/team). Navigate to the **Settings** page and create an API key to get your **Client ID**. You'll need your Client ID to interact with the Universal Bridge.
</Step>
<Step title="Initialize the Client">
```typescript
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
clientId: "your_client_id"
});
```
</Step>
<Step title="Prepare a Swap">
Get a quote and prepare your first cross-chain swap:
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
const prepared = await Bridge.Buy.prepare({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 137,
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.1"),
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
client,
});
```
</Step>
</Steps>
---
## Option 1: ConnectButton
## Recipes
Universal Bridge is available by default with our `ConnectButton` component. When users log in with Connect, they'll be able to onramp and purchase crypto directly from the logged in Connect interface. You can read more about `ConnectButton` [in this guide](/connect/sign-in/ConnectButton).
<Tabs defaultValue="buy">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="buy">Bridge & Swap</TabsTrigger>
<TabsTrigger value="transfer">Transfers</TabsTrigger>
<TabsTrigger value="onramp">Fiat Onramps</TabsTrigger>
<TabsTrigger value="routes">Route Discovery</TabsTrigger>
</TabsList>
```tsx
import { ThirdwebProvider, ConnectButton } from "thirdweb/react";
<TabsContent value="buy">
const client = createThirdwebClient({ clientId: your_client_id });
Use the `Buy` module to purchase tokens on any supported chain using tokens from another chain:
export default function App() {
return (
<ThirdwebProvider>
<ConnectButton client={client} />
</ThirdwebProvider>
);
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
// Step 1: Get a quote for the purchase
const quote = await Bridge.Buy.quote({
originChainId: 1, // Ethereum
originTokenAddress: NATIVE_TOKEN_ADDRESS, // ETH
destinationChainId: 137, // Polygon
destinationTokenAddress: NATIVE_TOKEN_ADDRESS, // MATIC
amount: toWei("0.1"), // 0.1 MATIC
client,
});
console.log(`Need ${quote.originAmount} origin tokens`);
// Step 2: Prepare a Swap
const prepared = await Bridge.Buy.prepare({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 137,
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.1"),
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
client,
});
// Step 3: Execute transactions
for (const step of prepared.steps) {
for (const transaction of step.transactions) {
const result = await sendTransaction({
transaction,
account: wallet.account,
});
console.log("Transaction sent:", result.transactionHash);
}
}
```
Our `ConnectButton` has a number of [customization options](/connect/pay/customization/connectbutton) so you can tailor it to your experience.
**Best for:** NFT purchases, token swaps across chains, DeFi interactions
---
</TabsContent>
## Option 2: Embed
<TabsContent value="transfer">
The `PayEmbed` allows users to onramp and purchase crypto directly from your application interface.
Use the `Transfer` module for same-chain or cross-chain token transfers:
```tsx
import { ThirdwebProvider, PayEmbed } from "thirdweb/react";
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
const client = createThirdwebClient({ clientId: your_client_id });
// Prepare transfer
const transfer = await Bridge.Transfer.prepare({
chainId: 1, // Ethereum
tokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.05"), // 0.05 ETH
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
receiver: "0x742d35Cc6634C0532925a3b8D400A1B5000d3ae5",
client,
});
export default function App() {
return (
<ThirdwebProvider>
<PayEmbed client={client} />
</ThirdwebProvider>
);
// Execute transfer transactions
for (const step of transfer.steps) {
for (const transaction of step.transactions) {
const result = await sendTransaction({
transaction,
account: wallet.account,
});
}
}
```
The embedded component will be displayed like so in your application interface:
**Best for:** P2P payments, payroll, direct transfers
<DocImage src={GetStartedEmbed} />
</TabsContent>
And voila! Your users can now onramp and convert crypto directly from your application.
<TabsContent value="onramp">
Our `PayEmbed` has a number of [customization options](/connect/pay/customization/payembed) so you can tailor it to your experience.
Use the `Onramp` module to convert fiat currency to crypto via supported providers:
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
// Prepare onramp session
const onramp = await Bridge.Onramp.prepare({
client,
onramp: "stripe", // or "coinbase", "transak"
chainId: 1,
tokenAddress: NATIVE_TOKEN_ADDRESS,
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
amount: toWei("0.1"), // 0.1 ETH
country: "US",
});
console.log(`Cost: $${onramp.currencyAmount} ${onramp.currency}`);
// Redirect user to complete purchase
window.location.href = onramp.link;
```
**Best for:** New users entering crypto, applications requiring fiat payments
</TabsContent>
<TabsContent value="routes">
Use the `routes` function to discover available bridging paths:
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
// Find all routes from Ethereum ETH
const allRoutes = await Bridge.routes({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
client,
});
// Find specific routes with pagination
const filteredRoutes = await Bridge.routes({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 137,
limit: 10,
sortBy: "popularity",
client,
});
console.log(`Found ${filteredRoutes.length} routes`);
```
**Best for:** Building route selectors, showing available options to users
</TabsContent>
</Tabs>
---
## Option 3: Send a Transaction with Pay
## Transaction Status Tracking
Universal Bridge is enabled by default for any contract call sent with `sendTransaction`. It will automatically be invoked when a user does not have enough funds to complete the transaction.
Monitor the progress of your Universal Bridge transactions:
<Callout variant="info">
ℹ️ `sendTransaction` is available in [Connect SDK v5](/typescript/v5) or
above.
</Callout>
```typescript
import { Bridge } from "thirdweb";
```tsx
import { useSendTransaction } from "thirdweb/react";
import { mintTo } from "thirdweb/extensions/erc721";
// Check transaction status
const status = await Bridge.status({
transactionHash: "0x5959b9321ec581640db531b80bac53cbd968f3d34fc6cb1d5f4ea75f26df2ad7",
chainId: 137,
client,
});
const { mutate: sendTx, data: transactionResult } = useSendTransaction();
const onClick = () => {
const transaction = mintTo({
contract,
to: "0x...",
nft: {
name: "NFT Name",
description: "NFT Description",
image: "https://example.com/image.png",
},
});
sendTx(transaction);
};
switch (status.status) {
case "COMPLETED":
console.log("Bridge completed!");
console.log("Final amount:", status.destinationAmount);
break;
case "PENDING":
console.log("Still processing...");
break;
case "FAILED":
console.log("Transaction failed");
break;
case "NOT_FOUND":
console.log("Transaction not found");
break;
}
```
When a user clicks this button, Universal Bridge will perform gas estimation to determine if the user has a sufficient balance. If their balance is sufficient, the transaction will execute normally. If their balance is not sufficient, the following modal will pop up asking them to either onramp funds or convert crypto to the required amount:
---
<DocImage src={GetStartedSend} />
## Error Handling
Once a user onramps or converts their funds to the required token, the user will be prompted once more to confirm the transaction. The transaction will then execute as expected.
Universal Bridge functions throw `ApiError` for failed requests:
For deeper customization of the Universal Bridge transaction modal, you can refer to our [sendTransaction customization guide](/connect/pay/customization/send-transaction).
```typescript
import { Bridge, ApiError } from "thirdweb";
try {
const quote = await Bridge.Buy.quote({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 999999, // Invalid chain
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.1"),
client,
});
} catch (error) {
if (error instanceof ApiError) {
console.log("API Error:", error.message);
console.log("Status Code:", error.statusCode);
console.log("Correlation ID:", error.correlationId);
}
}
```
---
## Next Steps
- **[API Reference](https://bridge.thirdweb.com/reference)** - Complete REST API documentation
- **[SDK Reference](/typescript/v5/buy/quote)** - TypeScript SDK function reference
- **[Webhooks](/pay/webhooks)** - Set up real-time notifications
- **[Customization Guides](/pay/customization)** - Advanced configuration options
- **[Playground](https://playground.thirdweb.com/connect/pay)** - Interactive testing environment
@@ -1,104 +0,0 @@
import { createMetadata, Callout, DocImage, Steps, Step } from "@doc";
import DirectPaymentsFlow from "../../assets/direct-payments-flow.png";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Accept Direct Payments",
icon: "thirdweb",
},
title: "thirdweb Universal Bridge - Accept Direct Payments | thirdweb",
description:
"Combine Universal Bridge and Engine to create a smooth point of sale experience on any chain",
});
# Accept Direct Payments with Universal Bridge & Engine
Learn how to accept fiat and crypto from your customers with Universal Bridge and trigger Engine actions, like minting NFTs or transferring tokens to user wallets.
### Requirements
- [A thirdweb Client ID](https://portal.thirdweb.com/typescript/v5/client)
- [An Engine Instance](https://thirdweb.com/team/~/~/engine)
### Purchase Flow Overview
Before jumping in, it's important to understand the full system, from initial purchase to the eventual transfer of assets. This is a four step process (with an optional fifth):
<DocImage src={DirectPaymentsFlow} />
1. **User Initiates Purchase:** User sends specified funds (ETH, MATIC, USD, etc.) to your wallet via thirdweb Universal Bridge.
- Your wallet can be any business wallet you choose or your Engine backend wallet.
- Use the `purchaseData` field to pass any item data needed in the webhook.
E.g., `{ nftId: 1 }`.
2. **Universal Bridge Processes Purchase:** thirdweb Universal Bridge processes the transaction and sends a [Purchase Complete webhook](/connect/pay/webhooks#purchase-complete) to your dapp’s server when the transaction is complete.
3. **Your Server Calls Engine:** Your server processes the webhook and sends a contract call to the thirdweb Engine backend wallet to perform the item purchase.
4. **Engine Executes the Transaction:** Engine backend wallet executes the transaction needed to deliver the token(s) to the user’s address.
5. **(Optional) User Is Notified :** Your server confirms the completed Engine transaction and notifies the user.
Now, let's get started.
<Steps>
<Step title="Set Up thirdweb Engine">
1. If you haven't already, log in and [deploy an Engine
instance](https://thirdweb.com/team/~/~/engine)
2. Create or import a backend wallet for Engine in [your Engine dashboard](https://thirdweb.com/team/~/~/engine).
3. Send the required funds to the backend wallet
* For example, if purchasing with Arb Nova USDC, then your backend wallet should hold this ERC20 token.
* If your backend wallet holds an ERC20 token, ensure to add a native token balance as well to cover gas costs. For example, for a transaction on Arbitrum Nova, your backend wallet should hold both Arb Nova USDC (for payment) and Arb Nova ETH (for gas).
* Your backend wallet should hold enough liquidity to handle any anticipated purchasing volume.
4. Make a plan to monitor and keep the float wallet topped up to avoid any issues with the service.
</Step>
<Step title="Integrate thirdweb Universal Bridge (Client)">
1. [Integrate thirdweb Universal Bridge](https://portal.thirdweb.com/connect/pay/build-a-custom-experience) on the client side to enable fiat and crypto payments.
2. Specify the required parameters for your Universal Bridge transaction.
```ts
const quote = await getBuyWithFiatQuote({
client: client, // thirdweb client
fromCurrencySymbol: "USD", // fiat currency symbol
toChainId: arbnova.id, // arbitrum nova chain id
toAmount: "100", // amount of token to buy
toTokenAddress: USDC_TOKEN_ADDRESS, // address of payment token
fromAddress: "<USERS_WALLET_ADDRESS>",
toAddress: "<YOUR_WALLET_ADDRESS>",
purchaseData: {
nftId: 1
}
});
```
|Parameter|Type|Description|
|----|--|--|
|fromCurrencySymbol|`string`|The fiat currency symbol you'd like to accept (currently limited to USD and EUR) |
|toChainId|`string`|[Chain ID](https://thirdweb.com/chainlist) for the destination token.|
|toTokenAddress|`string`|Address of the destination token.|
|toAmount|`string`|The price of the asset denominated in your desination token.|
|fromAddress|`string`|The user's wallet address.|
|toAddress|`string`|The wallet you'd like funds to be sent to. This can be your business wallet or an Engine backend wallet. |
|purchaseData|`Object`|Any details you need to pass in order to track your transaction. If this is a secondary sale, you may want to include details like `{ nftId: 1, orderId: 1 }` |
</Step>
<Step title="Integrate thirdweb Universal Bridge (Server)">
1. Log in & create a “Purchase Complete” webhook in your Team > Connect > Universal Bridge > Webhooks page.
2. The [Universal Bridge webhook POST response](/connect/pay/webhooks#purchase-complete) will include the BuyWithCryptoStatus or BuyWithFiatStatus in the body.
* This will also include the purchaseData you previously passed (metadata identifying the customer information, etc.).
* A request will be sent on every status change.
3. Listen for [completed purchase statuses](https://portal.thirdweb.com/connect/pay/build-a-custom-experience#Poll%20for%20Transaction%20Status). Note that if this is a two-step BuyWithFiat transaction (onramp, then swap), you should also listen for `CRYPTO_SWAP_COMPLETED`
4. When a purchase is completed, determine the purchaser, order and validate the amounts.
- The fromAddress in the status will show purchasing address
- The purchaseData will contain more information you previously for the purchase details
- Validate the information. For example, 100 USDC was sent to your wallet.
5. Send a contract call to Engine for the float wallet to purchase the item
- For example, send a transaction to the float wallet for `mintTo(fromAddress)`
- For secondary purchases, like a marketplace direct listing, you can use the appropriate Engine endpoint, passing the ultimate recipient of the NFT as the buyer
6. Confirm the purchase was successful.
</Step>
</Steps>
Once you've completed all of these steps, you'll be ready to offer a great purchase UX for your users.
@@ -1,320 +0,0 @@
import {
createMetadata,
Callout,
DocImage,
InstallTabs,
Steps,
Step,
} from "@doc";
import OnrampStepOne from "../../assets/avax-to-usd.png";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Custom Experience",
icon: "thirdweb",
},
title: "thirdweb Universal Bridge - Buy With Fiat - Custom Experience | thirdweb",
description:
"Learn how to build a custom onramp experience with thirdweb Universal Bridge.",
});
# Build a Custom Fiat Experience
Learn how to enable your users to purchase your application’s token with any fiat payment method with our headless flow.
In this guide, we'll show you how to purchase 0.01 Base ETH from USD in Typescript.
<Callout variant="info">
Our `PayEmbed` component handles the complexity of the process detailed below
and can be integrated in minutes. We encourage you to check out the [PayEmbed
guide here](/connect/pay/get-started#option-2-embed-pay).
</Callout>
---
<Steps>
<Step title='Install the Connect SDK'>
<InstallTabs
npm="npm i thirdweb"
yarn="yarn add thirdweb"
pnpm="pnpm i thirdweb"
/>
<Step title='Get Your Client ID'>
Log in to the [thirdweb dashboard](https://thirdweb.com/team). Click on Create New > Project to get your **Client ID**. You'll need your Client ID to interact with the Connect SDK.
</Step>
</Step>
<Step title='Get a “Buy with Fiat” quote'>
Buying with fiat can require one or two steps depending on your destination token:
If the destination token _*can*_ be bought directly with fiat, your users can onramp directly to their destination token.
If the destination token _*can not*_ be bought directly with fiat, your users will need to onramp to an intermediate token, then convert the onramp token to the destination token.
For example, when Buying Base ETH:
1. Users will receive Avalance AVAX ( native token ) in exchange for USD
2. Users will be prompted to convert Avalanche AVAX to Base ETH
<DocImage src={OnrampStepOne} />
This process requires your user's wallet address. Refer to [this guide](https://portal.thirdweb.com/typescript/v5/connecting-wallets) to learn how to connect a wallet.
```tsx
import { getBuyWithFiatQuote } from "thirdweb/pay";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { base } from "thirdweb/chains";
// create a thirdweb client
const client = createThirdwebClient({
clientId: "<your_client_id>",
});
// Get a quote for buying 0.01 Base ETH with USD
const quote = await getBuyWithFiatQuote({
client: client, // thirdweb client
fromCurrencySymbol: "USD", // fiat currency symbol
toChainId: base.id, // base chain id
toAmount: "0.01", // amount of token to buy
toTokenAddress: NATIVE_TOKEN_ADDRESS, // native token
toAddress: "0x...", // user's wallet address
});
// display quote information to user
console.log(quote.fromCurrencyWithFees);
console.log(quote.processingFees);
console.log(quote.onRampToken);
console.log(quote.toToken);
console.log(quote.estimatedDurationSeconds);
// etc...
```
The `quote` object contains detailed transaction information including the estimated time, processing fees, amount of fiat currency required, and more that you can display in your application.
</Step>
<Step title='Check for a Buy With Crypto Step'>
The `quote` object contains `quote.onRampToken` and `quote.toToken` objects containing intermediate and destination token information.
If `quote.onRampToken` is not the same as `quote.toToken`, then your users will need to onramp to intermediary token before arriving at their destination token.
You can use `isSwapRequiredPostOnramp` to check this
```tsx
import { isSwapRequiredPostOnramp } from "thirdweb/pay";
const hasTwoSteps = isSwapRequiredPostOnramp(quote);
if (hasTwoSteps) {
// display the two steps to the user
}
```
</Step>
<Step title='Display the Onramp Experience'>
Once you have a `quote` from `getBuyWithFiatQuote`, you can open a new tab with `quote.onRampLink` to show the onramp experience. This onramp experience handles all regulatory requirements, know your customer (KYC) verifications, and sanctions screening.
After they've KYC'd (if required), customers have the option of saving payment methods, KYC data, and wallet information in the onramp, which makes the returning onramp experience much faster.
Your users will be able to purchase the `quote.onRampToken` with the specified fiat currency.
```ts
window.open(quote.onRampLink, "_blank");
```
</Step>
<Step title='Poll for Transaction Status'>
When you open the `quote.onRampLink` in a new tab, you can begin polling for the onramp transaction status in your app by calling `getBuyWithFiatStatus`.
`getBuyWithFiatStatus` requires an `intentId` which you can get from `quote` object.
`getBuyWithFiatStatus` requires passing an `intentId` which you can get from `quote` object.
There are a number of transactions statuses:
```tsx
// Keep calling the below code at some regular intervals to poll the status
const fiatStatus = await getBuyWithFiatStatus({
client: client, // thirdweb client
intentId: quote.intentId, // pass intentId from quote
});
if (fiatStatus.status === "NOT_FOUND") {
// invalid intentId
// Show error in your page
}
if (fiatStatus.status === "NONE") {
// No information available yet
// Show "loading" status on your page
// keep polling
}
if (fiatStatus.status === "PENDING_PAYMENT") {
// Payment is in progress in the on-ramp provider
// Show "loading" status on your page
// keep polling
}
if (fiatStatus.status === "PENDING_ON_RAMP_TRANSFER") {
// payment is done, on-ramp process has not started
// show "loading" status on your page
// keep polling
}
if (fiatStatus.status === "ON_RAMP_TRANSFER_IN_PROGRESS") {
// on-ramp provider is doing on-ramp with fiat currency
// show "loading" status on your page
// keep polling
}
if (fiatStatus.status === "ON_RAMP_TRANSFER_FAILED") {
// on-ramp provider failed to do onramp
// show error in your UI
// STOP polling
}
if (fiatStatus.status === "ON_RAMP_TRANSFER_COMPLETED") {
// if only on-ramp is required - process is done!
if (!hasTwoSteps) {
// show "success"
// Stop polling
// That's it!
} else {
// Wait for "CRYPTO_SWAP_REQUIRED" state to convert tokens
// Show "loading" status on your page
// Stop polling
}
}
if (fiatStatus.status === "CRYPTO_SWAP_REQUIRED") {
// go to step 5
// Show UI for Buy with Crypto quote.onRampToken to quote.toToken
// Stop polling
}
```
</Step>
<Step title='Get a Buy With Crypto Quote (Optional)'>
This step is only relevant when a crypto-to-crypto purchase is required after perfmorming an onramp to an intermediary token.
In this case, you can use `getPostOnRampQuote` to get a quote to convert the intermediary token to the destination token.
```tsx
// when fiatStatus.status === "CRYPTO_SWAP_REQUIRED"
// and hasTwoStep === true
const swapQuote = await getPostOnRampQuote({
client: client,
buyWithFiatStatus: fiatStatus,
});
if (!swapQuote) {
// invalid fiatStatus status
} else {
// Go to step 6 to kick off the "Swap" flow
}
```
</Step>
<Step title='Execute Buy With Crypto'>
Executing Buy With Crypto may involve either a single step or 2 steps
If your source token is an ERC-20 token, an approval step is required before executing the Buy With Crypto transaction.
You can check if approval is required by checking `quote.approval`
```tsx
import { sendTransaction, waitForReceipt } from "thirdweb";
const account = wallet.getAccount();
// If approval is required, send the approval transaction
// show a button to user to request approval and send the transaction on click
if (quote.approval) {
// request spending tokens from wallet
const approveTxResult = await sendTransaction({
transaction: quote.approval, // approval transaction
account: account, // account from user's connected wallet
});
await waitForReceipt(approveTxResult);
}
// The above step may result in error if user rejects or transaction fails
// If it results in error, it needs to be done again until its successful
// Once the approval is done, you can send the buyWithCrypto transaction
// show a button to user to request sending buyWithCrypto transaction and send the transaction on click
const buyWithCryptoTxResult = await sendTransaction({
transaction: quote.transactionRequest,
account: account,
});
await waitForReceipt(buyWithCryptoTxResult);
// Save the buy with crypto transaction hash for polling the status as mentioned in step 7
const buyWithCryptoTxHash = buyWithCryptoTxResult.transactionHash;
```
</Step>
<Step title='Poll for Buy With Crypto Status'>
Once you've initiated your Buy With Crypto transaction, you'll want to track the status. You can notify users throughout this journey by checking for the following statuses:
```tsx
import { getBuyWithCryptoStatus } from 'thirdweb/pay'
// Keep calling the below code at regular intervals if the status is in a pending state
const swapStatus = await getBuyWithCryptoStatus({
client: client,
transactionHash: swapTxHash,
}});
if (swapStatus.status === "NOT_FOUND") {
// invalid swap tx
// Show error in your page
// Stop polling
}
if (swapStatus.status === "NONE") {
// No information available yet
// show "loading" in UI
// Keep polling
}
if (swapStatus.status === "FAILED") {
// swap failed
// show "error" in UI - show a retry option in UI
// Stop polling
}
if (swapStatus.status === "COMPLETED") {
// swap completed
// show "success" in UI
// Stop polling
}
if (swapStatus.status === "PENDING") {
// swap is in progress
// show "loading" in UI
// Keep polling
}
```
</Step>
</Steps>
---
### Build a Custom Experience in React
If you are using React, we provide Hooks for each of the functions mentioned above:
| React Hook | Typescript |
| :------------------- | :------------------- |
| useBuyWithFiatQuote | getBuyWithFiatQuote |
| usePostOnrampQuote | getPostOnrampQuote |
| useBuyWithFiatStatus | getBuyWithFiatStatus |
@@ -93,7 +93,7 @@ const buyQuote = await Bridge.Buy.quote({
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 10, // Optimism
destinationTokenAddress: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Optimism USDC
buyAmountWei: 10000000n, // 10 USDC
amount: 10000000n, // 10 USDC
client: thirdwebClient,
});
@@ -117,98 +117,108 @@ const preparedBuy = await Bridge.Buy.prepare({
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 10, // Optimism
destinationTokenAddress: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Optimism USDC
buyAmountWei: 10000000n, // 10 USDC
sender: "0x...", // Your user's wallet address
receiver: "0x...", // Recipient address (can be the same as sender)
amount: 10000000n, // 10 USDC
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", // Your user's wallet address
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", // Recipient address (can be the same as sender)
client: thirdwebClient,
});
// The prepared quote contains the transactions you need to execute
console.log(`Transactions to execute: ${preparedBuy.transactions.length}`);
// The prepared quote contains the steps and transactions you need to execute
console.log(`Steps: ${preparedBuy.steps.length}`);
```
This will return a `PreparedQuote` object. It will look very similar to the `Quote` you received in the previous step, but it will include a `transactions` array. This array will contain the transactions you need to execute to complete the swap.
This will return a `PreparedQuote` object. It will look very similar to the `Quote` you received in the previous step, but it will include a `steps` array containing all the transactions you need to execute to complete the swap.
</Step>
<Step title='Execute the swap'>
To execute the swap, we'll need to send all transactions in the `transactions` array one after the other.
<Callout variant="warning">
Currently, the `transactions` array does not include approvals. You'll need to execute any necessary approvals prior to executing the swap transactions.
</Callout>
To execute the swap, we'll need to send all transactions in each step one after the other. The SDK automatically includes any necessary approvals in the transactions array.
```tsx
import { sendTransaction, waitForReceipt } from "thirdweb";
import { sendAndConfirmTransaction } from "thirdweb";
const preparedBuy = await Bridge.Buy.prepare({
originChainId: 8453, // Base
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 10, // Optimism
destinationTokenAddress: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Optimism USDC
buyAmountWei: 10000000n, // 10 USDC
sender: "0x...", // Your user's wallet address
receiver: "0x...", // Recipient address (can be the same as sender)
amount: 10000000n, // 10 USDC
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", // Your user's wallet address
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709", // Recipient address (can be the same as sender)
client: thirdwebClient,
});
};
for (const transaction of preparedBuy.transactions) {
const tx = prepareTransaction({
to: transaction.to as string,
value: BigInt(transaction.value ?? 0n),
data: transaction.data,
chain: defineChain(transaction.chainId),
client
});
const result = await sendAndConfirmTransaction({ transaction: tx, account });
let swapStatus;
do {
swapStatus = await Bridge.status({
transactionHash: result.transactionHash,
client,
});
} while (swapStatus.status !== "COMPLETED");
};
// Execute all transactions in sequence
for (const step of preparedBuy.steps) {
for (const transaction of step.transactions) {
// Handle approvals first
if (transaction.action === "approval") {
const result = await sendAndConfirmTransaction({
transaction,
account: wallet.account
});
console.log("Approval sent:", result.transactionHash);
} else {
// Execute the main swap transaction
const result = await sendAndConfirmTransaction({
transaction,
account: wallet.account
});
console.log("Swap transaction sent:", result.transactionHash);
// Wait for completion before continuing
let swapStatus;
do {
swapStatus = await Bridge.status({
transactionHash: result.transactionHash,
chainId: transaction.chainId,
client: thirdwebClient,
});
if (swapStatus.status === "PENDING") {
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3 seconds
}
} while (swapStatus.status === "PENDING");
if (swapStatus.status === "FAILED") {
throw new Error("Swap transaction failed");
}
}
}
}
```
<Callout variant="info">
The returned transactions follow the [Ox](https://oxlib.sh/) standard [TransactionEnvelopeEip1559](https://oxlib.sh/guides/transaction-envelopes)
format. This is a simple transaction with `data`, `to`, `value`, and `chainId` properties. **Gas is not included in the transaction, and should
be estimated separately if necessary.** Normally, the thirdweb SDK will handle this for you.
The transactions come pre-configured with all necessary parameters including gas estimation. Each transaction has an `action` property that indicates whether it's an "approval" or the main swap transaction.
</Callout>
</Step>
<Step title='Getting the swap status'>
You'll notice in the previous step we call `Bridge.status` to get the status of the swap. We can get the status of any past swap using just the transaction hash and chain ID of its origin transaction.
You can get the status of any swap using the transaction hash and chain ID of its origin transaction.
When sending the transactions in a prepared quote, you **must** use `Bridge.status` to get the `COMPLETED` status before moving on to the next transaction. This is because `status` waits for both the origin
and destination transactions to complete. Waiting for the origin transaction receipt is not sufficient since the funds might not have arrived on the destination chain yet.
When sending transactions in a prepared quote, you **must** use `Bridge.status` to confirm `COMPLETED` status before moving to the next transaction. This ensures both origin and destination transactions have completed successfully.
The `status` will also return all transactions (origin and destination) involved in the swap.
```tsx
import { Bridge } from "thirdweb";
// Check the status of a bridge transaction
const bridgeStatus = await Bridge.status({
transactionHash:
"0xe199ef82a0b6215221536e18ec512813c1aa10b4f5ed0d4dfdfcd703578da56d",
chainId: 8453, // The chain ID where the transaction was initiated
transactionHash: "0x5959b9321ec581640db531b80bac53cbd968f3d34fc6cb1d5f4ea75f26df2ad7",
chainId: 137, // The chain ID where the transaction was initiated
client: thirdwebClient,
});
// The status will be one of: "COMPLETED", "PENDING", "FAILED", or "NOT_FOUND"
if (bridgeStatus.status === "completed") {
if (bridgeStatus.status === "COMPLETED") {
console.log(`
Bridge completed!
Sent: ${bridgeStatus.originAmount} wei on chain ${bridgeStatus.originChainId}
Received: ${bridgeStatus.destinationAmount} wei on chain ${bridgeStatus.destinationChainId}
`);
} else if (bridgeStatus.status === "pending") {
} else if (bridgeStatus.status === "PENDING") {
console.log("Bridge transaction is still pending...");
} else {
} else if (bridgeStatus.status === "FAILED") {
console.log("Bridge transaction failed");
} else {
console.log("Transaction not found");
}
```
</Step>
</Steps>
@@ -0,0 +1,706 @@
import {
createMetadata,
Callout,
DocImage,
InstallTabs,
Steps,
Step,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@doc";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - NFT Checkout",
icon: "thirdweb",
},
title: "thirdweb Universal Bridge - NFT Checkout | thirdweb",
description:
"Learn how to build a complete NFT checkout flow with in-app wallet login, onramp, and minting using Universal Bridge.",
});
# NFT Checkout with Universal Bridge
Learn how to build a seamless NFT checkout experience that combines in-app wallet authentication, fiat-to-crypto onramps, and NFT minting. This tutorial demonstrates a complete user flow where users can purchase NFTs directly with fiat currency.
This pattern is perfect for NFT marketplaces, gaming platforms, and any application where you want to minimize friction for new crypto users.
---
<Steps>
<Step title='Install the SDK'>
<InstallTabs
npm="npm i thirdweb"
yarn="yarn add thirdweb"
pnpm="pnpm i thirdweb"
/>
</Step>
<Step title='Setup Project and Contract'>
Configure your client and prepare an NFT contract for minting:
```typescript
import { createThirdwebClient } from "thirdweb";
import { getContract } from "thirdweb";
import { sepolia } from "thirdweb/chains"; // Using testnet for demo
const client = createThirdwebClient({
clientId: "your_client_id"
});
// Your NFT contract (ERC721 or ERC1155)
const nftContract = getContract({
client,
chain: sepolia,
address: "0x...", // Your NFT contract address
});
// NFT pricing configuration
const NFT_PRICE_ETH = "0.01"; // 0.01 ETH per NFT
const NFT_METADATA = {
name: "Awesome NFT Collection",
description: "A limited edition NFT with exclusive benefits",
image: "https://your-domain.com/nft-image.png",
attributes: [
{ trait_type: "Rarity", value: "Rare" },
{ trait_type: "Collection", value: "Genesis" },
],
};
```
<Callout variant="info">
For production, deploy your NFT contract on mainnet and update the chain configuration. You can use thirdweb's contract deployment tools to create ERC721 or ERC1155 contracts easily.
</Callout>
</Step>
<Step title='Implement In-App Wallet Authentication'>
Set up seamless wallet authentication for new users:
```typescript
import { inAppWallet } from "thirdweb/wallets/in-app";
import { ConnectButton, useActiveAccount, useConnect } from "thirdweb/react";
// Configure in-app wallet for seamless onboarding
const wallet = inAppWallet({
auth: {
options: ["google", "apple", "facebook", "email"],
},
});
// Authentication component
function WalletAuth({ onConnect }: { onConnect: (address: string) => void }) {
const account = useActiveAccount();
const { connect } = useConnect();
const handleConnect = async () => {
try {
const connectedAccount = await connect(async () => {
const account = await wallet.connect({
client,
strategy: "google", // or other auth methods
});
return account;
});
console.log("Wallet connected:", connectedAccount.address);
onConnect(connectedAccount.address);
} catch (error) {
console.error("Connection failed:", error);
}
};
if (account) {
onConnect(account.address); // Auto-proceed when wallet is connected
return (
<div className="text-center space-y-2">
<p className="text-green-600">✅ Wallet Connected</p>
<p className="text-sm text-gray-600">
{account.address.slice(0, 6)}...{account.address.slice(-4)}
</p>
</div>
);
}
return (
<div className="space-y-4">
<ConnectButton
client={client}
wallets={[wallet]}
connectButton={{
label: "Sign In to Buy NFT",
style: {
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
border: "none",
borderRadius: "8px",
padding: "12px 24px",
fontSize: "16px",
fontWeight: "600",
},
}}
/>
<p className="text-xs text-gray-500 text-center">
New to crypto? No worries! Sign in with your existing account.
</p>
</div>
);
}
```
</Step>
<Step title='Build NFT Checkout Flow'>
Create the main checkout component that handles the complete purchase flow:
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei, sendTransaction } from "thirdweb";
import { prepareContractCall } from "thirdweb";
import { mintTo } from "thirdweb/extensions/erc721";
import { useState, useEffect } from "react";
interface CheckoutState {
step: "connect" | "payment" | "processing" | "success" | "error";
selectedProvider?: "stripe" | "coinbase" | "transak";
onrampSession?: any;
mintTxHash?: string;
error?: string;
}
function NFTCheckout() {
const [state, setState] = useState<CheckoutState>({ step: "connect" });
const account = useActiveAccount();
const handleWalletConnect = (address: string) => {
setState({ step: "payment" });
};
const handlePaymentSelect = async (provider: "stripe" | "coinbase" | "transak") => {
if (!account) return;
setState({ step: "processing", selectedProvider: provider });
try {
// Start onramp process
const onrampSession = await Bridge.Onramp.prepare({
client,
onramp: provider,
chainId: sepolia.id,
tokenAddress: NATIVE_TOKEN_ADDRESS,
receiver: account.address,
amount: toWei(NFT_PRICE_ETH),
currency: "USD",
country: "US", // You should detect this based on user's location
});
console.log("Onramp session created:", onrampSession.id);
setState({
step: "processing",
selectedProvider: provider,
onrampSession
});
// Store session info for monitoring
localStorage.setItem("currentOnrampSession", JSON.stringify({
id: onrampSession.id,
provider,
timestamp: Date.now(),
}));
// Open onramp in new window/tab
window.open(onrampSession.link, "_blank");
// Start monitoring the session
monitorOnrampAndMint(onrampSession.id);
} catch (error) {
console.error("Failed to start payment:", error);
setState({
step: "error",
error: error instanceof Error ? error.message : "Payment failed"
});
}
};
const monitorOnrampAndMint = async (sessionId: string) => {
try {
const status = await Bridge.Onramp.status({
sessionId,
client,
});
switch (status.status) {
case "COMPLETED":
console.log("Payment completed! Now minting NFT...");
await mintNFT();
break;
case "PENDING":
console.log("Payment still in progress...");
// Check again in 10 seconds
setTimeout(() => monitorOnrampAndMint(sessionId), 10000);
break;
case "FAILED":
console.log("Payment failed:", status.error);
setState({
step: "error",
error: "Payment failed. Please try again."
});
break;
case "CANCELLED":
console.log("Payment was cancelled");
setState({ step: "payment" }); // Return to payment selection
break;
}
} catch (error) {
console.error("Failed to check payment status:", error);
setState({
step: "error",
error: "Failed to check payment status"
});
}
};
const mintNFT = async () => {
if (!account) return;
try {
// Prepare NFT mint transaction
const mintTransaction = mintTo({
contract: nftContract,
to: account.address,
nft: NFT_METADATA,
});
// Execute mint transaction
const result = await sendTransaction({
transaction: mintTransaction,
account,
});
console.log("NFT minted successfully:", result.transactionHash);
setState({
step: "success",
mintTxHash: result.transactionHash
});
// Clean up session storage
localStorage.removeItem("currentOnrampSession");
} catch (error) {
console.error("Minting failed:", error);
setState({
step: "error",
error: error instanceof Error ? error.message : "Minting failed"
});
}
};
return (
<div className="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
<div className="text-center mb-6">
<img
src={NFT_METADATA.image}
alt={NFT_METADATA.name}
className="w-48 h-48 mx-auto rounded-lg object-cover mb-4"
/>
<h2 className="text-2xl font-bold text-gray-800">{NFT_METADATA.name}</h2>
<p className="text-gray-600 text-sm mt-2">{NFT_METADATA.description}</p>
<div className="text-3xl font-bold text-blue-600 mt-4">
{NFT_PRICE_ETH} ETH (~$30 USD)
</div>
</div>
{/* Step Progress Indicator */}
<div className="flex justify-between mb-6">
{["connect", "payment", "processing", "success"].map((step, index) => (
<div
key={step}
className={`flex-1 h-2 rounded-full mx-1 ${
["connect", "payment", "processing", "success"].indexOf(state.step) >= index
? "bg-blue-500"
: "bg-gray-200"
}`}
/>
))}
</div>
{/* Step Content */}
{state.step === "connect" && (
<WalletAuth onConnect={handleWalletConnect} />
)}
{state.step === "payment" && (
<PaymentOptions onSelect={handlePaymentSelect} />
)}
{state.step === "processing" && (
<ProcessingStep provider={state.selectedProvider!} />
)}
{state.step === "success" && (
<SuccessStep
txHash={state.mintTxHash!}
nftMetadata={NFT_METADATA}
/>
)}
{state.step === "error" && (
<ErrorStep
error={state.error!}
onRetry={() => setState({ step: "payment" })}
/>
)}
</div>
);
}
```
</Step>
<Step title='Create Payment Selection'>
Build the payment options component that shows provider choices:
```typescript
interface PaymentOptionsProps {
onSelect: (provider: "stripe" | "coinbase" | "transak") => void;
}
function PaymentOptions({ onSelect }: PaymentOptionsProps) {
const paymentMethods = [
{
provider: "stripe" as const,
name: "Stripe",
description: "Credit Card, Apple Pay, Google Pay",
logo: "💳",
fees: "3.5% + $0.30",
popular: true,
},
{
provider: "coinbase" as const,
name: "Coinbase Pay",
description: "Bank Transfer, Debit Card",
logo: "🟦",
fees: "1% (Bank) / 3.9% (Card)",
popular: false,
},
{
provider: "transak" as const,
name: "Transak",
description: "Multiple Payment Options",
logo: "🌐",
fees: "0.99% - 5.5%",
popular: false,
},
];
return (
<div className="space-y-4">
<div className="text-center mb-6">
<h3 className="text-lg font-semibold mb-2">Choose Payment Method</h3>
<p className="text-sm text-gray-600">
Select how you'd like to pay for your NFT
</p>
</div>
<div className="space-y-3">
{paymentMethods.map((method) => (
<button
key={method.provider}
onClick={() => onSelect(method.provider)}
className="w-full p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all group relative"
>
{method.popular && (
<div className="absolute -top-2 left-4 bg-blue-500 text-white text-xs px-2 py-1 rounded">
Most Popular
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="text-2xl">{method.logo}</div>
<div className="text-left">
<div className="font-semibold text-gray-800">{method.name}</div>
<div className="text-sm text-gray-600">{method.description}</div>
<div className="text-xs text-gray-500">Fees: {method.fees}</div>
</div>
</div>
<div className="text-blue-500 group-hover:translate-x-1 transition-transform">
→
</div>
</div>
</button>
))}
</div>
<div className="bg-gray-50 rounded-lg p-4 mt-6">
<div className="flex items-center space-x-2 mb-2">
<span className="text-green-600">🔒</span>
<span className="font-medium text-sm">Secure Payment Processing</span>
</div>
<p className="text-xs text-gray-600">
All payments are processed securely through industry-leading providers.
Your payment information is never stored on our servers.
</p>
</div>
</div>
);
}
function ProcessingStep({ provider }: { provider: "stripe" | "coinbase" | "transak" }) {
const [currentStep, setCurrentStep] = useState<"payment" | "confirmation" | "minting">("payment");
// Auto-progress through steps for demo purposes
useEffect(() => {
const timer1 = setTimeout(() => setCurrentStep("confirmation"), 5000);
const timer2 = setTimeout(() => setCurrentStep("minting"), 10000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, []);
const providerInfo = {
stripe: { name: "Stripe", logo: "💳", color: "blue" },
coinbase: { name: "Coinbase Pay", logo: "🟦", color: "blue" },
transak: { name: "Transak", logo: "🌐", color: "green" },
};
const info = providerInfo[provider];
return (
<div className="text-center space-y-6">
<div className="mb-6">
<div className="text-4xl mb-2">{info.logo}</div>
<h3 className="text-lg font-semibold">Processing with {info.name}</h3>
</div>
{/* Progress Steps */}
<div className="space-y-4">
<div className={`flex items-center space-x-3 p-3 rounded-lg ${
currentStep === "payment" ? "bg-blue-50 border border-blue-200" : "bg-gray-50"
}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold ${
currentStep === "payment" ? "bg-blue-500 text-white animate-pulse" :
currentStep === "confirmation" || currentStep === "minting" ? "bg-green-500 text-white" : "bg-gray-300"
}`}>
{currentStep === "payment" ? "1" : "✓"}
</div>
<div className="text-left">
<div className="font-medium">Complete Payment</div>
<div className="text-sm text-gray-600">
{currentStep === "payment" ? "Finish your purchase in the payment window" : "Payment completed successfully"}
</div>
</div>
</div>
<div className={`flex items-center space-x-3 p-3 rounded-lg ${
currentStep === "confirmation" ? "bg-blue-50 border border-blue-200" : "bg-gray-50"
}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold ${
currentStep === "confirmation" ? "bg-blue-500 text-white animate-pulse" :
currentStep === "minting" ? "bg-green-500 text-white" : "bg-gray-300"
}`}>
{currentStep === "confirmation" ? "2" : currentStep === "minting" ? "✓" : "2"}
</div>
<div className="text-left">
<div className="font-medium">Confirming Transaction</div>
<div className="text-sm text-gray-600">
{currentStep === "confirmation" ? "Verifying your payment on the blockchain" :
currentStep === "minting" ? "Transaction confirmed" : "Waiting for payment completion"}
</div>
</div>
</div>
<div className={`flex items-center space-x-3 p-3 rounded-lg ${
currentStep === "minting" ? "bg-blue-50 border border-blue-200" : "bg-gray-50"
}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold ${
currentStep === "minting" ? "bg-blue-500 text-white animate-pulse" : "bg-gray-300"
}`}>
3
</div>
<div className="text-left">
<div className="font-medium">Minting Your NFT</div>
<div className="text-sm text-gray-600">
{currentStep === "minting" ? "Creating your unique NFT on the blockchain" : "Preparing to mint your NFT"}
</div>
</div>
</div>
</div>
{currentStep === "payment" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Complete your payment</strong> in the {info.name} window to continue.
This page will automatically update when payment is confirmed.
</p>
</div>
)}
{currentStep === "minting" && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-blue-800">
<strong>Almost done!</strong> Your NFT is being created on the blockchain.
This usually takes 30-60 seconds.
</p>
</div>
)}
</div>
);
}
```
</Step>
<Step title='Add Success and Error Handling'>
Implement the final steps of the user journey:
```typescript
import { sepolia } from "thirdweb/chains";
function SuccessStep({
txHash,
nftMetadata
}: {
txHash: string;
nftMetadata: typeof NFT_METADATA;
}) {
const explorerUrl = `${sepolia.blockExplorers?.[0]?.url}/tx/${txHash}`;
return (
<div className="text-center space-y-4">
<div className="text-6xl">🎉</div>
<div>
<h3 className="text-xl font-bold text-green-600">NFT Minted Successfully!</h3>
<p className="text-gray-600 mt-2">
Congratulations! Your NFT has been minted and is now in your wallet.
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<p className="font-semibold">{nftMetadata.name}</p>
<p className="text-sm text-gray-600">{nftMetadata.description}</p>
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-blue-500 underline text-sm"
>
View on Block Explorer
</a>
</div>
<div className="space-y-2">
<button
onClick={() => window.location.reload()}
className="w-full bg-green-500 text-white py-2 px-4 rounded-lg font-semibold"
>
Buy Another NFT
</button>
<button
onClick={() => {
// Share functionality
navigator.share?.({
title: "I just minted an NFT!",
text: `Check out my new ${nftMetadata.name} NFT`,
url: explorerUrl,
});
}}
className="w-full border border-gray-300 text-gray-700 py-2 px-4 rounded-lg"
>
Share Your NFT
</button>
</div>
</div>
);
}
function ErrorStep({
error,
onRetry
}: {
error: string;
onRetry: () => void;
}) {
return (
<div className="text-center space-y-4">
<div className="text-red-500 text-4xl">❌</div>
<div>
<h3 className="text-xl font-bold text-red-600">Something went wrong</h3>
<p className="text-gray-600 mt-2">
We couldn't complete your NFT purchase. Please try again.
</p>
<details className="mt-2">
<summary className="text-sm text-gray-500 cursor-pointer">
Error details
</summary>
<p className="text-xs text-red-500 mt-1 font-mono bg-red-50 p-2 rounded">
{error}
</p>
</details>
</div>
<div className="space-y-2">
<button
onClick={onRetry}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg font-semibold"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="w-full border border-gray-300 text-gray-700 py-2 px-4 rounded-lg"
>
Start Over
</button>
</div>
</div>
);
}
```
</Step>
<Step title='Complete App Integration'>
Put it all together in your main application:
```typescript
import { ThirdwebProvider } from "thirdweb/react";
// Import all the components we defined earlier
import { NFTCheckout, WalletAuth, PaymentOptions, ProcessingStep, SuccessStep, ErrorStep } from "./nft-checkout-components";
function App() {
return (
<ThirdwebProvider>
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50 py-8">
<div className="container mx-auto px-4">
<header className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
NFT Marketplace
</h1>
<p className="text-gray-600">
Buy NFTs seamlessly with fiat currency
</p>
</header>
<NFTCheckout />
<footer className="text-center mt-8 text-sm text-gray-500">
<p>Powered by thirdweb Universal Bridge</p>
</footer>
</div>
</div>
</ThirdwebProvider>
);
}
export default App;
```
</Step>
</Steps>
@@ -0,0 +1,199 @@
import {
createMetadata,
Callout,
DocImage,
InstallTabs,
Steps,
Step,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@doc";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Onramp Integration",
icon: "thirdweb",
},
title: "thirdweb Universal Bridge - Onramp Integration | thirdweb",
description:
"Learn how to integrate fiat-to-crypto onramps using Stripe, Coinbase, and Transak with Universal Bridge.",
});
# Fiat-to-Crypto Onramp Integration
Learn how to integrate seamless fiat-to-crypto onramps using Universal Bridge. This guide covers integration with Stripe, Coinbase, and Transak providers, enabling your users to purchase crypto directly with fiat currency.
Universal Bridge's onramp functionality provides a unified interface across multiple providers, automatic routing to the best rates, and comprehensive status tracking.
---
<Steps>
<Step title='Install the SDK'>
<InstallTabs
npm="npm i thirdweb"
yarn="yarn add thirdweb"
pnpm="pnpm i thirdweb"
/>
</Step>
<Step title='Setup and Configuration'>
Configure your client and understand the available onramp providers:
```typescript
import { createThirdwebClient } from "thirdweb";
import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
const client = createThirdwebClient({
clientId: "your_client_id"
});
```
</Step>
<Step title='Basic Onramp Integration'>
Create a basic onramp experience for your users:
<Tabs defaultValue="stripe">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="stripe">Stripe</TabsTrigger>
<TabsTrigger value="coinbase">Coinbase</TabsTrigger>
<TabsTrigger value="transak">Transak</TabsTrigger>
</TabsList>
<TabsContent value="stripe">
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
const onrampSession = await Bridge.Onramp.prepare({
client,
onramp: "stripe",
chainId: 1, // Ethereum
tokenAddress: NATIVE_TOKEN_ADDRESS, // ETH
receiver: userAddress,
amount: toWei("0.1"), // 0.1 ETH
currency: "USD",
country: "US", // User's country - important for compliance
});
window.open(onrampSession.link);
```
</TabsContent>
<TabsContent value="coinbase">
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
async function createCoinbaseOnramp(userAddress: string) {
try {
const onrampSession = await Bridge.Onramp.prepare({
client,
onramp: "coinbase",
chainId: 8453, // Base (Coinbase's L2)
tokenAddress: NATIVE_TOKEN_ADDRESS, // ETH on Base
receiver: userAddress,
amount: toWei("0.05"), // 0.05 ETH
currency: "USD",
country: "US",
});
console.log("Coinbase onramp session:", onrampSession.id);
console.log("Estimated cost:", `$${onrampSession.currencyAmount}`);
// Redirect to Coinbase onramp
window.location.href = onrampSession.link;
return onrampSession;
} catch (error) {
console.error("Failed to create Coinbase onramp:", error);
throw error;
}
}
```
</TabsContent>
<TabsContent value="transak">
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
async function createTransakOnramp(userAddress: string) {
try {
const onrampSession = await Bridge.Onramp.prepare({
client,
onramp: "transak",
chainId: 137, // Polygon
tokenAddress: NATIVE_TOKEN_ADDRESS, // MATIC
receiver: userAddress,
amount: toWei("10"), // 10 MATIC
currency: "USD",
country: "US",
});
// Redirect to Transak
window.open(onrampSession.link);
return onrampSession;
} catch (error) {
console.error("Failed to create Transak onramp:", error);
throw error;
}
}
```
</TabsContent>
</Tabs>
</Step>
<Step title='Status Monitoring'>
Monitor onramp transactions and handle completion:
```typescript
import { Bridge } from "thirdweb";
// Monitor onramp status
const status = await Bridge.Onramp.status({
sessionId,
client,
});
switch (status.status) {
case "COMPLETED":
console.log("Onramp completed successfully!");
console.log("Transaction hash:", status.transactionHash);
console.log("Amount received:", status.destinationAmount);
// Update your UI to show success
break;
case "PENDING":
console.log("Onramp in progress...");
// Show loading state to user
setTimeout(() => monitorOnrampStatus(sessionId), 10000); // Check again in 10s
break;
case "FAILED":
console.log("Onramp failed:", status.error);
// Show error message to user
break;
case "CANCELLED":
console.log("Onramp was cancelled by user");
// Handle cancellation
break;
default:
console.log("Unknown status:", status.status);
}
```
</Step>
</Steps>
## Next Steps
- **[Onramp Providers](/pay/onramp-providers)** - Detailed provider comparison and features
- **[Webhooks](/pay/webhooks)** - Set up real-time onramp status notifications
- **[Testing](/pay/testing-pay)** - Test onramp flows in development mode
- **[Universal Bridge API](https://bridge.thirdweb.com/reference)** - Complete API reference
@@ -0,0 +1,154 @@
import {
createMetadata,
Callout,
DocImage,
InstallTabs,
Steps,
Step,
} from "@doc";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Smart Accounts",
icon: "thirdweb",
},
title: "thirdweb Universal Bridge - Smart Accounts | thirdweb",
description:
"Learn how to use EIP7702 smart accounts with batched transactions for seamless Universal Bridge experiences.",
});
# Smart Accounts with Universal Bridge
Learn how to leverage EIP7702 smart accounts to batch Universal Bridge transactions for optimal user experience. This approach enables gasless transactions, batched execution, and seamless cross-chain operations.
Smart accounts with EIP7702 allow you to upgrade any EOA (Externally Owned Account) into a smart account without code changes, enabling advanced features like transaction batching and gas sponsorship.
---
<Steps>
<Step title='Install the SDK'>
<InstallTabs
npm="npm i thirdweb"
yarn="yarn add thirdweb"
pnpm="pnpm i thirdweb"
/>
</Step>
<Step title='Set up EIP7702 In-App Wallet'>
Configure an in-app wallet with EIP7702 execution mode to enable smart account features:
```typescript
import { createThirdwebClient } from "thirdweb";
import { inAppWallet } from "thirdweb/wallets/in-app";
const client = createThirdwebClient({
clientId: "your_client_id"
});
// Create in-app wallet with EIP7702 smart account features
const wallet = inAppWallet({
executionMode: {
mode: "EIP7702",
sponsorGas: true, // Enable gas sponsorship
},
});
// Connect the wallet
const account = await wallet.connect({
client,
strategy: "google", // or other auth strategies
});
```
<Callout variant="info">
EIP7702 execution mode upgrades your EOA into a smart account, enabling batched transactions, gas sponsorship, and other advanced features without requiring contract deployment.
</Callout>
</Step>
<Step title='Prepare Batched Bridge Transactions'>
Use Universal Bridge to prepare transactions, then batch them per step (since each step may be on a different chain):
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
import { sendBatchTransaction } from "thirdweb";
const bridgePreparation = await Bridge.Buy.prepare({
originChainId: 1, // Ethereum
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 137, // Polygon
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.1"), // 0.1 MATIC
sender: account.address,
receiver: account.address,
client,
});
```
<Callout variant="important">
**Key Rule**: You can only batch transactions that are on the same chain. Since Universal Bridge steps may span multiple chains, you must batch transactions within each step separately, not across steps.
</Callout>
</Step>
<Step title='Execute Batched Transactions'>
Execute transactions in batches per step (same chain), proceeding through steps sequentially:
```typescript
import { sendBatchTransaction, waitForReceipt } from "thirdweb";
// Execute each step's transactions as a batch (same chain)
for (let i = 0; i < bridgePreparation.steps.length; i++) {
const step = bridgePreparation.steps[i];
// Batch all transactions within this step (they're all on the same chain)
const stepBatchResult = await sendBatchTransaction({
transactions: step.transactions,
account,
});
console.log(`Step ${i + 1} batch sent:`, stepBatchResult.transactionHash);
const receipt = await waitForReceipt({
client,
chain: step.transactions[0].chain,
transactionHash: stepBatchResult.transactionHash,
});
console.log(`Step ${i + 1} completed:`, receipt.status);
// Monitor bridge completion status for this step
let bridgeStatus;
do {
bridgeStatus = await Bridge.status({
transactionHash: stepBatchResult.transactionHash,
chainId: step.transactions[0].chainId,
client,
});
if (bridgeStatus.status === "PENDING") {
console.log(`Bridge step ${i + 1} still pending...`);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
} while (bridgeStatus.status === "PENDING");
if (bridgeStatus.status === "COMPLETED") {
console.log(`Bridge step ${i + 1} completed!`);
} else if (bridgeStatus.status === "FAILED") {
throw new Error(`Bridge step ${i + 1} failed`);
}
}
```
<Callout variant="info">
With EIP7702 smart accounts, transactions within each step execute atomically. Steps execute sequentially, ensuring proper cross-chain coordination. Gas sponsorship applies to all batched transactions.
</Callout>
</Step>
</Steps>
## Next Steps
- **[EIP7702 Documentation](/connect/account-abstraction/overview)** - Learn more about smart account features
- **[Batching Transactions](/typescript/v5/account-abstraction/batching-transactions)** - Explore advanced batching patterns
- **[Gas Sponsorship](/connect/account-abstraction/sponsoring-gas)** - Set up custom gas sponsorship rules
- **[Universal Bridge API](/pay/webhooks)** - Monitor cross-chain operations with webhooks
+105 -40
View File
@@ -1,7 +1,7 @@
import { createMetadata, DocImage, Grid, SDKCard, FeatureCard } from "@doc";
import PayOverviewImage from "./assets/pay-overview.png";
import SupportedChains from "../_images/supported-chains.png";
import {RocketIcon, ArrowLeftRightIcon, WalletIcon, PencilIcon, ShieldCheckIcon, PiggyBankIcon, GlobeIcon, ComponentIcon} from "lucide-react";
import {RocketIcon, ArrowLeftRightIcon, WalletIcon, PencilIcon, ShieldCheckIcon, PiggyBankIcon, GlobeIcon, ComponentIcon, CodeIcon, ApiIcon, WebhookIcon} from "lucide-react";
export const metadata = createMetadata({
image: {
@@ -10,7 +10,7 @@ export const metadata = createMetadata({
},
title: "thirdweb Universal Bridge Docs: Web3 Payments, On-ramping, bridging & swapping",
description:
"Learn everything about thirdweb’s web3 payments solution, Universal Bridge. Technical docs on onramping, bridging + swapping.",
"Learn everything about thirdweb's web3 payments solution, Universal Bridge. Technical docs on onramping, bridging + swapping.",
});
# Universal Bridge
@@ -25,50 +25,56 @@ Universal Bridge allows you to create both simple and advanced payment flows for
className="my-4 grid gap-2 md:grid-cols-2 lg:grid-cols-2 "
>
<FeatureCard
title="Avoid chargeback & liability"
description="Automatic screening for sanctioned wallets and regions to ensure application safety."
iconUrl={<RocketIcon />}
title="Cross-Chain Asset Routing"
description="Intelligent multi-hop routing to find optimal paths between any supported tokens and chains."
iconUrl={<ArrowLeftRightIcon />}
/>
<FeatureCard
title="Global Coverage"
description="Bridging support in over 160+ countries to reach a truly global audience."
iconUrl={<GlobeIcon />}
title="Fiat-to-Crypto Onramps"
description="Direct integration with Stripe, Coinbase, and Transak for seamless fiat onramps."
iconUrl={<PiggyBankIcon />}
/>
<FeatureCard
title="Earn revenue"
description="Monetize your application and earn swap fees on each transaction."
iconUrl={<PiggyBankIcon />}
/>
<FeatureCard
title="Transact Securely"
description="Strict one-time approvals ensuring funds are not at risk of exploits."
title="Real-time Status Tracking"
description="Monitor transaction progress across chains with comprehensive status APIs."
iconUrl={<ShieldCheckIcon />}
/>
<FeatureCard
title="Easy-to-integrate Components"
description="Easily integrate components for buying funds, e-commerce checkouts, and more."
iconUrl={<ComponentIcon />}
<FeatureCard
title="Global Coverage"
description="Support for 160+ countries and regions for fiat onramps and cross-chain transactions."
iconUrl={<GlobeIcon />}
/>
<FeatureCard
title="Webhook Integration"
description="Real-time notifications for transaction completion and status updates."
iconUrl={<WebhookIcon />}
/>
<FeatureCard
title="Customizable Flows"
description="Create your own customized headless flows using the Universal Bridge SDK or API."
iconUrl={<PencilIcon />}
/>
<FeatureCard
title="Flexible Wallet Payments"
description="Enable payments with any wallet that has funds not just the one connected."
iconUrl={<WalletIcon />}
/>
<FeatureCard
title="Multi-hop Routing"
description="Handle multiple swaps at once within one session for a seamless swap experience."
iconUrl={<ArrowLeftRightIcon />}
title="Revenue Generation"
description="Earn revenue by charging fees on every transaction that flows through your application."
iconUrl={<WalletIcon />}
/>
</div>
## SDK Modules
The Universal Bridge SDK is organized into several modules, each handling specific functionality:
| Module | Purpose | Key Functions |
|--------|---------|---------------|
| **Buy** | Cross-chain token purchasing | `quote()`, `prepare()` |
| **Sell** | Token selling and swapping | `quote()`, `prepare()` |
| **Transfer** | Same-chain and cross-chain transfers | `prepare()` |
| **Onramp** | Fiat-to-crypto conversion | `prepare()`, `status()` |
| **Routes** | Route discovery and filtering | `routes()` |
| **Status** | Transaction status tracking | `status()` |
## Supported Chains
Universal Bridge is supported on select EVM compatible chains. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist?service=pay).
@@ -83,12 +89,71 @@ Universal Bridge is supported on select EVM compatible chains. To view the full
| Stripe | https://docs.stripe.com/crypto/onramp |
| Coinbase | https://docs.cdp.coinbase.com/onramp/docs/payment-methods/ |
## Integration Options
## Implementation Approaches
| OPTION | BEST FOR |
| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [Buy Crypto](https://playground.thirdweb.com/connect/pay/) | Developers who want onramp and crypto purchase experiences directly in their application. |
| [Transactions](https://playground.thirdweb.com/connect/pay/transactions) | Developers who want users to onramp or purchase crypto directly into a transaction. Great for minting and NFT purchase flows. |
| [In-App Purchases](https://playground.thirdweb.com/connect/pay/commerce) | Developers who want to take payments from Fiat or Crypto directly to a seller wallet |
| [SDK](https://portal.thirdweb.com/typescript/v5/buy/quote) | Build your own UI with the SDK. |
| [API](https://playground.thirdweb.com/connect/pay/backend) | Control the full experience with the API. |
| APPROACH | DESCRIPTION | BEST FOR |
| -------- | ----------- | -------- |
| **[TypeScript SDK](https://portal.thirdweb.com/typescript/v5/buy/quote)** | Full-featured SDK with quote, prepare, and status functions | Developers building custom applications with complete control |
| **[REST API](https://bridge.thirdweb.com/reference)** | Direct API access for any programming language | Backend services, mobile apps, or non-JavaScript environments |
| **[Webhooks](https://portal.thirdweb.com/pay/webhooks)** | Real-time transaction status notifications | Applications requiring instant completion updates |
| **[Playground](https://playground.thirdweb.com/connect/pay)** | Interactive testing environment | Testing and prototyping bridge functionality |
## Quick Start Examples
### Basic Cross-Chain Purchase
```typescript
import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
// Get a quote for purchasing ETH on Optimism using ETH on Mainnet
const quote = await Bridge.Buy.quote({
originChainId: 1, // Ethereum Mainnet
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 10, // Optimism
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.01"),
client: thirdwebClient,
});
// Prepare finalized transactions
const prepared = await Bridge.Buy.prepare({
originChainId: 1,
originTokenAddress: NATIVE_TOKEN_ADDRESS,
destinationChainId: 10,
destinationTokenAddress: NATIVE_TOKEN_ADDRESS,
amount: toWei("0.01"),
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
client: thirdwebClient,
});
```
### Fiat Onramp
```typescript
// Prepare a Stripe onramp to purchase ETH
const onramp = await Bridge.Onramp.prepare({
client: thirdwebClient,
onramp: "stripe",
chainId: 1,
tokenAddress: NATIVE_TOKEN_ADDRESS,
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
amount: toWei("10"),
country: "US",
});
// Redirect user to onramp.link to complete purchase
```
### Status Tracking
```typescript
// Check transaction status
const status = await Bridge.status({
transactionHash: "0x5959b9321ec581640db531b80bac53cbd968f3d34fc6cb1d5f4ea75f26df2ad7",
chainId: 137,
client: thirdwebClient,
});
if (status.status === "COMPLETED") {
console.log("Bridge completed!");
console.log("Final amount:", status.destinationAmount);
}
```
+16 -22
View File
@@ -5,7 +5,6 @@ import {
BracesIcon,
CircleDollarSignIcon,
CodeIcon,
FlaskConicalIcon,
MessageCircleQuestionIcon,
PaletteIcon,
RocketIcon,
@@ -45,16 +44,12 @@ export const sidebar: SideBar = {
icon: <RocketIcon />,
links: [
{
name: "ConnectButton",
href: `${paySlug}/get-started#option-1-connectbutton`,
name: "Installation",
href: `${paySlug}/get-started#installation`,
},
{
name: "Embed",
href: `${paySlug}/get-started#option-2-embed-pay`,
},
{
name: "Send a Transaction",
href: `${paySlug}/get-started#option-3-send-a-transaction-with-pay`,
name: "Recipes",
href: `${paySlug}/get-started#recipes`,
},
],
},
@@ -63,18 +58,22 @@ export const sidebar: SideBar = {
isCollapsible: true,
icon: <AlbumIcon />,
links: [
{
name: "Accept Direct Payments",
href: `${paySlug}/guides/accept-direct-payments`,
},
{
name: "Build a Custom Onramp Experience",
href: `${paySlug}/guides/build-a-custom-experience`,
},
{
name: "Cross-Chain Swapping",
href: `${paySlug}/guides/cross-chain-swapping`,
},
{
name: "Swap with Smart Accounts",
href: `${paySlug}/guides/smart-accounts`,
},
{
name: "Fiat Onramp",
href: `${paySlug}/guides/onramp-integration`,
},
{
name: "NFT Checkout",
href: `${paySlug}/guides/nft-checkout`,
},
],
},
{
@@ -117,11 +116,6 @@ export const sidebar: SideBar = {
href: `${paySlug}/webhooks`,
icon: <WebhookIcon />,
},
{
name: "Developer Mode",
href: `${paySlug}/testing-pay`,
icon: <FlaskConicalIcon />,
},
{
name: "FAQs",
href: `${paySlug}/faqs`,
@@ -1,151 +0,0 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { createMetadata, Callout } from "@doc";
export const metadata = createMetadata({
image: {
title: "thirdweb Universal Bridge - Test Mode",
icon: "thirdweb",
},
title: "Enable Test Mode for Universal bridge — thirdweb docs",
description: "Learn how to enable test mode for web3 payments with the thirdweb Universal bridge feature in our technical docs",
});
# Enable Test Mode
Developers can turn on Test Mode to test both fiat-to-crypto transactions and crypto-to-crypto transactions on supported testnet chains. It is possible to test both at the same time or separately.
## Buy With Fiat
By setting `testMode` to `true` for Buy With Fiat, you can enable test experiences for our underlying providers (Coinbase, Stripe, and Transak).
<Tabs defaultValue="connectbutton">
<TabsList>
<TabsTrigger value="connectbutton">ConnectButton</TabsTrigger>
<TabsTrigger value="payembed">PayEmbed</TabsTrigger>
<TabsTrigger value="sendtxn">sendTransaction</TabsTrigger>
</TabsList>
<TabsContent value='connectbutton'>
```tsx
<ConnectButton
client={client}
detailsModal={{
payOptions: {
buyWithFiat: {
testMode: true, // defaults to false
},
},
}}
/>
```
</TabsContent>
<TabsContent value="payembed">
```tsx
<PayEmbed
client={client}
payOptions={{
buyWithFiat: {
testMode: true, // defaults to false
},
}}
/>
```
</TabsContent>
<TabsContent value="sendtxn">
```tsx
const { mutate: sendTransaction } = useSendTransaction({
payModal: {
buyWithFiat: {
testMode: true, // defaults to false
},
},
});
```
</TabsContent>
</Tabs>
For Coinbase onramp, you can turn on "Mocked mode" when launching the Onramp experience.
At the bottom of the Onramp modal where it says "Secured by Coinbase", click 10 times on the word "Secured." This will navigate you to Onramp's Debug Menu. There, navigate to the Actions tab click on the toggle for "Enable Mocked Buy and Send." You can now exit the Debug Menu and proceed with mock buys and sends. Remember to click on the "Enable Mocked Buy and Send" toggle once you are done to enable actual buys and sends.
For other providers, you can use the following information when using any provider in test mode:
| Provider | Card Type | Name | Number | Expiration Date | CVV | Other Info |
| -------- | ------------ | ----------- | ------------------- | --------------- | ------------ | ---------------------------------------------------------------- |
| Stripe | Visa | Any Name | 4242 4242 4242 4242 | Any Future Date | Any 3 Digits | SSN: 000000000, Address line 1: address_full_match, OTP : 000000 |
| Stripe | Visa (Debit) | Any Name | 4000 0566 5566 5556 | Any Future Date | Any 3 Digits | SSN: 000000000, Address line 1: address_full_match, OTP : 000000 |
| Transak | Visa | Any Name | 4111 1111 1111 1111 | 10/33 | 123 | Payment Authorization 3Ds screen password: **Checkout1!** |
For Stripe testing, you can view more test cards [here](https://docs.stripe.com/testing).
For Transak testing, you can view detailed information [here](https://docs.transak.com/docs/test-credentials#card-payments).
## Buy With Crypto
Buy With Crypto test mode will show only Testnets and native test currencies. If you have a custom that you would like supported in test mode, [please reach out to us](https://thirdweb.com/contact-us). You can see a list of supported testnets on the [thirdweb Chainlist page](https://thirdweb.com/chainlist?service=pay&type=testnet).
<Tabs defaultValue="connectbutton">
<TabsList>
<TabsTrigger value="connectbutton">ConnectButton</TabsTrigger>
<TabsTrigger value="payembed">PayEmbed</TabsTrigger>
<TabsTrigger value="sendtxn">sendTransaction</TabsTrigger>
</TabsList>
<TabsContent value='connectbutton'>
```tsx
<ConnectButton
client={client}
detailsModal={{
payOptions: {
buyWithCrypto: {
testMode: true, // defaults to false
},
},
}}
/>
```
</TabsContent>
<TabsContent value="payembed">
```tsx
<PayEmbed
client={client}
payOptions={{
buyWithCrypto: {
testMode: true, // defaults to false
},
}}
/>
```
</TabsContent>
<TabsContent value="sendtxn">
```tsx
const { mutate: sendTransaction } = useSendTransaction({
payModal: {
buyWithCrypto: {
testMode: true, // defaults to false
},
},
});
```
</TabsContent>
</Tabs>
@@ -1,5 +1,6 @@
import {
ArticleIconCard,
Callout
} from "@doc";
import {
ReactIcon,
@@ -7,6 +8,10 @@ import {
# Migration from TypeScript SDK v4
<Callout type="warning">
The TypeScript SDK v4 will be deprecated on June 23, 2025. We highly recommend you to migrate to the latest version of the SDK v5 as soon as possible.
</Callout>
<ArticleIconCard
title="Looking to migrate to React SDK v5?"
icon={ReactIcon}
+22 -1
View File
@@ -22,4 +22,25 @@ a “rotation-code” only allows the “service account rotate” operation, wh
There is no way for thirdweb to “silently” access your vault without your knowledge with only the recovery code.
Rotating your engine’s vault account through a thirdweb-stored rotation code requires a signature from your wallet. You will also be able to see rotation history, the thirdweb account which initiated this rotation, and their wallet signature.
</Details>
</Details>
<Details summary="Which key would I use to create additional server wallets?">
You need either:
- The admin key (which can perform any action, including creating Vault EOAs, aka server wallets).
- Or an access token that has explicit permissions to create Vault EOAs.
For Engine Cloud by default, we generate an access token for you during onboarding. This token can create Vault EOAs.
However, if you want to restrict access to creating Vault EOAs, you can create a custom access token with the required scopes.
</Details>
<Details summary="How is my admin key used in the dashboard?">
The admin key is temporarily stored in dashboard when performing actions that require it, such as creating or managing server wallets:
- It is only stored locally in memory for the duration of your session.
- It is only sent over the network when end-to-end encrypted communication with Vault is happening.
- It is not persisted or stored after your session ends.
Please note, you should never share your admin key with third parties.
</Details>
+8
View File
@@ -1,5 +1,13 @@
# thirdweb
## 5.102.4
### Patch Changes
- [#7243](https://github.com/thirdweb-dev/js/pull/7243) [`283dc28`](https://github.com/thirdweb-dev/js/commit/283dc289fca1ad16a9296e610d293b73a7123709) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Include native tokens in Insight.getOwnedTokens
- [#7268](https://github.com/thirdweb-dev/js/pull/7268) [`b237318`](https://github.com/thirdweb-dev/js/commit/b237318b7dcfdb9e04ac6c003c215683050e2dd3) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Support EIP7702 execution for ecosystem wallets
## 5.102.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "thirdweb",
"version": "5.102.3",
"version": "5.102.4",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { getErrorDetails, isInsufficientFundsError } from "./helpers.js";
describe("isInsufficientFundsError", () => {
it("should detect basic insufficient funds error message", () => {
const error = new Error("insufficient funds");
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect insufficient funds for gas error", () => {
const error = new Error("Insufficient funds for gas * price + value");
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect insufficient funds for intrinsic transaction cost", () => {
const error = new Error(
"insufficient funds for intrinsic transaction cost",
);
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect insufficient balance error", () => {
const error = new Error("insufficient balance");
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect insufficient native funds error", () => {
const error = new Error("Insufficient Native Funds");
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect INSUFFICIENT_FUNDS error code", () => {
const error = { code: "INSUFFICIENT_FUNDS", message: "Transaction failed" };
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect reason field", () => {
const error = {
reason: "insufficient funds",
message: "Transaction failed",
};
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should detect error in nested data.message", () => {
const error = { data: { message: "insufficient funds for gas" } };
expect(isInsufficientFundsError(error)).toBe(true);
});
it("should handle string errors", () => {
expect(isInsufficientFundsError("insufficient funds")).toBe(true);
});
it("should return false for non-insufficient funds errors", () => {
const error = new Error("User rejected transaction");
expect(isInsufficientFundsError(error)).toBe(false);
});
it("should return false for null/undefined", () => {
expect(isInsufficientFundsError(null)).toBe(false);
expect(isInsufficientFundsError(undefined)).toBe(false);
});
it("should be case insensitive", () => {
const error = new Error("INSUFFICIENT FUNDS FOR GAS");
expect(isInsufficientFundsError(error)).toBe(true);
});
});
describe("getErrorDetails", () => {
it("should extract message and code from Error object", () => {
const error = new Error("Test error message");
const details = getErrorDetails(error);
expect(details.message).toBe("Test error message");
expect(details.code).toBeUndefined();
});
it("should extract message and code from error object", () => {
const error = { message: "Test message", code: "TEST_CODE" };
const details = getErrorDetails(error);
expect(details.message).toBe("Test message");
expect(details.code).toBe("TEST_CODE");
});
it("should extract message from nested data", () => {
const error = { data: { message: "Nested error message" } };
const details = getErrorDetails(error);
expect(details.message).toBe("Nested error message");
});
it("should handle string errors", () => {
const details = getErrorDetails("String error");
expect(details.message).toBe("String error");
expect(details.code).toBeUndefined();
});
it("should handle null/undefined", () => {
const details = getErrorDetails(null);
expect(details.message).toBe("Unknown error");
expect(details.code).toBeUndefined();
});
it("should extract reason as code", () => {
const error = { message: "Test message", reason: "test_reason" };
const details = getErrorDetails(error);
expect(details.message).toBe("Test message");
expect(details.code).toBe("test_reason");
});
});
@@ -0,0 +1,49 @@
/**
* @internal
*/
export function isInsufficientFundsError(error: Error | unknown): boolean {
if (!error) return false;
const errorMessage =
typeof error === "string"
? error
: (error as Error)?.message ||
(error as { data?: { message?: string } })?.data?.message ||
"";
const message = errorMessage.toLowerCase();
// Common patterns for insufficient funds errors
return (
message.includes("insufficient funds") ||
message.includes("insufficient balance") ||
(message.includes("insufficient") &&
(message.includes("native") || message.includes("gas"))) ||
// Common error codes from various wallets/providers
(error as { code?: string | number })?.code === "INSUFFICIENT_FUNDS" ||
(error as { reason?: string })?.reason === "insufficient funds"
);
}
/**
* @internal
*/
export function getErrorDetails(error: Error | unknown): {
message: string;
code?: string | number;
} {
if (!error) return { message: "Unknown error" };
const message =
typeof error === "string"
? error
: (error as Error)?.message ||
(error as { data?: { message?: string } })?.data?.message ||
String(error);
const code =
(error as { code?: string | number })?.code ||
(error as { reason?: string })?.reason;
return { message, code };
}
@@ -2,7 +2,10 @@ import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import type { ThirdwebClient } from "../../client/client.js";
import { trackTransaction } from "./transaction.js";
import {
trackInsufficientFundsError,
trackTransaction,
} from "./transaction.js";
const server = setupServer(
http.post("https://c.thirdweb.com/event", () => {
@@ -127,4 +130,110 @@ describe("transaction tracking", () => {
"test-partner-id",
);
});
it("should track insufficient funds error with correct data", async () => {
const mockClient: ThirdwebClient = {
clientId: "test-client-id",
secretKey: undefined,
};
let requestBody: unknown;
server.use(
http.post("https://c.thirdweb.com/event", async (handler) => {
requestBody = await handler.request.json();
return HttpResponse.json({});
}),
);
const mockError = new Error("Insufficient funds for gas * price + value");
await trackInsufficientFundsError({
client: mockClient,
error: mockError,
walletAddress: "0x1234567890123456789012345678901234567890",
chainId: 1,
contractAddress: "0xcontract",
transactionValue: 1000000000000000000n,
});
expect(requestBody).toEqual({
source: "sdk",
action: "transaction:insufficient_funds",
clientId: "test-client-id",
chainId: 1,
walletAddress: "0x1234567890123456789012345678901234567890",
contractAddress: "0xcontract",
transactionValue: "1000000000000000000",
requiredAmount: undefined,
userBalance: undefined,
errorMessage: "Insufficient funds for gas * price + value",
errorCode: undefined,
});
});
it("should not throw an error if insufficient funds tracking request fails", async () => {
const mockClient: ThirdwebClient = {
clientId: "test-client-id",
secretKey: undefined,
};
server.use(
http.post("https://c.thirdweb.com/event", () => {
return HttpResponse.error();
}),
);
const mockError = new Error("insufficient funds");
expect(() =>
trackInsufficientFundsError({
client: mockClient,
error: mockError,
walletAddress: "0x1234567890123456789012345678901234567890",
chainId: 137,
}),
).not.toThrowError();
// Wait for the asynchronous POST request to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
it("should track insufficient funds error during transaction preparation", async () => {
const mockClient: ThirdwebClient = {
clientId: "test-client-id",
secretKey: undefined,
};
let requestBody: unknown;
server.use(
http.post("https://c.thirdweb.com/event", async (handler) => {
requestBody = await handler.request.json();
return HttpResponse.json({});
}),
);
const mockError = new Error("insufficient funds for gas");
await trackInsufficientFundsError({
client: mockClient,
error: mockError,
walletAddress: "0xabcdef1234567890abcdef1234567890abcdef12",
chainId: 42,
contractAddress: "0x0987654321098765432109876543210987654321",
});
expect(requestBody).toEqual({
source: "sdk",
action: "transaction:insufficient_funds",
clientId: "test-client-id",
chainId: 42,
walletAddress: "0xabcdef1234567890abcdef1234567890abcdef12",
contractAddress: "0x0987654321098765432109876543210987654321",
transactionValue: undefined,
requiredAmount: undefined,
userBalance: undefined,
errorMessage: "insufficient funds for gas",
errorCode: undefined,
});
});
});
@@ -2,6 +2,7 @@ import type { ThirdwebClient } from "../../client/client.js";
import { stringify } from "../../utils/json.js";
import type { Ecosystem } from "../../wallets/in-app/core/wallet/types.js";
import type { WalletId } from "../../wallets/wallet-types.js";
import { getErrorDetails } from "./helpers.js";
import { track } from "./index.js";
type TransactionEvent = {
@@ -55,3 +56,39 @@ function trackTransactionEvent(
},
});
}
/**
* @internal
*/
export async function trackInsufficientFundsError(args: {
client: ThirdwebClient;
ecosystem?: Ecosystem;
error: Error | unknown;
walletAddress?: string;
chainId?: number;
contractAddress?: string;
functionName?: string;
transactionValue?: bigint;
requiredAmount?: bigint;
userBalance?: bigint;
}) {
const errorDetails = getErrorDetails(args.error);
return track({
client: args.client,
ecosystem: args.ecosystem,
data: {
action: "transaction:insufficient_funds",
clientId: args.client.clientId,
chainId: args.chainId,
walletAddress: args.walletAddress,
contractAddress: args.contractAddress,
functionName: args.functionName,
transactionValue: args.transactionValue?.toString(),
requiredAmount: args.requiredAmount?.toString(),
userBalance: args.userBalance?.toString(),
errorMessage: errorDetails.message,
errorCode: errorDetails.code ? stringify(errorDetails.code) : undefined,
},
});
}
+1
View File
@@ -46,6 +46,7 @@ const FORCE_GAS_PRICE_CHAIN_IDS = [
5464, // Saga Mainnet
2020, // Ronin Mainnet
2021, // Ronin Testnet (Saigon)
98866, // Plume mainnet
];
/**
+13 -11
View File
@@ -1,12 +1,12 @@
import type {
GetV1TokensErc20ByOwnerAddressData,
GetV1TokensErc20ByOwnerAddressResponse,
GetV1TokensData,
GetV1TokensResponse,
} from "@thirdweb-dev/insight";
import type { Chain } from "../chains/types.js";
import type { ThirdwebClient } from "../client/client.js";
import type { GetWalletBalanceResult } from "../wallets/utils/getWalletBalance.js";
type OwnedToken = GetV1TokensErc20ByOwnerAddressResponse["data"][number];
type OwnedToken = GetV1TokensResponse["data"][number];
/**
* Get ERC20 tokens owned by an address
@@ -26,10 +26,13 @@ export async function getOwnedTokens(args: {
client: ThirdwebClient;
chains: Chain[];
ownerAddress: string;
queryOptions?: GetV1TokensErc20ByOwnerAddressData["query"];
queryOptions?: Omit<
GetV1TokensData["query"],
"owner_address" | "chain_id" | "chain"
>;
}): Promise<GetWalletBalanceResult[]> {
const [
{ getV1TokensErc20ByOwnerAddress },
{ getV1Tokens },
{ getThirdwebDomains },
{ getClientFetch },
{ assertInsightEnabled },
@@ -46,19 +49,18 @@ export async function getOwnedTokens(args: {
await assertInsightEnabled(chains);
const defaultQueryOptions: GetV1TokensErc20ByOwnerAddressData["query"] = {
chain: chains.map((chain) => chain.id),
const defaultQueryOptions: GetV1TokensData["query"] = {
owner_address: ownerAddress,
chain_id: chains.map((chain) => chain.id),
include_native: "true",
include_spam: "false",
metadata: "true",
limit: 50,
};
const result = await getV1TokensErc20ByOwnerAddress({
const result = await getV1Tokens({
baseUrl: `https://${getThirdwebDomains().insight}`,
fetch: getClientFetch(client),
path: {
ownerAddress: ownerAddress,
},
query: {
...defaultQueryOptions,
...queryOptions,
@@ -1,5 +1,7 @@
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
import { isInsufficientFundsError } from "../../../../analytics/track/helpers.js";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import { trackInsufficientFundsError } from "../../../../analytics/track/transaction.js";
import * as Bridge from "../../../../bridge/index.js";
import type { Chain } from "../../../../chains/types.js";
import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js";
@@ -174,6 +176,18 @@ export function useSendTransactionCore(args: {
resolve(res);
} catch (e) {
// Track insufficient funds errors specifically
if (isInsufficientFundsError(e)) {
trackInsufficientFundsError({
client: tx.client,
error: e,
walletAddress: account.address,
chainId: tx.chain.id,
contractAddress: await resolvePromisedValue(tx.to ?? undefined),
transactionValue: await resolvePromisedValue(tx.value),
});
}
reject(e);
}
};
@@ -141,17 +141,37 @@ async function fetchBalancesForWallet({
const insightChunks = chunkChains(insightEnabledChains);
await Promise.all(
insightChunks.map(async (chunk) => {
const owned = await getOwnedTokens({
ownerAddress: account.address,
chains: chunk,
client,
});
let owned: GetWalletBalanceResult[] = [];
let page = 0;
const limit = 100;
while (true) {
const batch = await getOwnedTokens({
ownerAddress: account.address,
chains: chunk,
client,
queryOptions: {
limit,
page,
},
}).catch((err) => {
console.error("error fetching balances from insight", err);
return [];
});
if (batch.length === 0) {
break;
}
owned = [...owned, ...batch];
page += 1;
}
for (const b of owned) {
const matching = sourceSupportedTokens[b.chainId]?.find(
(t) => t.address.toLowerCase() === b.tokenAddress.toLowerCase(),
);
if (matching) {
if (matching && b.value > 0n) {
balances.push({
balance: b,
chain: getCachedChain(b.chainId),
@@ -187,6 +207,10 @@ async function fetchBalancesForWallet({
const chainId = Number(chainIdStr);
const chain = getCachedChain(chainId);
if (insightEnabledChains.some((c) => c.id === chainId)) {
continue;
}
for (const token of tokens) {
const isNative = isNativeToken(token);
const isAlreadyFetched = balances.some(
@@ -194,7 +218,7 @@ async function fetchBalancesForWallet({
b.chain.id === chainId &&
b.token.address.toLowerCase() === token.address.toLowerCase(),
);
if (isAlreadyFetched && !isNative) {
if (isAlreadyFetched) {
// ERC20 on insight-enabled chain already handled by insight call
continue;
}
@@ -222,7 +246,7 @@ async function fetchBalancesForWallet({
}
} catch (err) {
console.warn(
`Failed to fetch balance for ${token.symbol} on chain ${chainId}`,
`Failed to fetch RPC balance for ${token.symbol} on chain ${chainId}`,
err,
);
}
@@ -94,6 +94,7 @@ export async function estimateGas(
throw await extractError({
error,
contract: options.transaction.__contract,
fromAddress,
});
}
}
@@ -150,6 +151,7 @@ export async function estimateGas(
throw await extractError({
error,
contract: options.transaction.__contract,
fromAddress,
});
}
})();
@@ -89,6 +89,7 @@ export async function simulateTransaction<
throw await extractError({
error,
contract: options.transaction.__contract,
fromAddress: from,
});
}
}
@@ -1,5 +1,7 @@
import type { Abi } from "abitype";
import { type Hex, decodeErrorResult, stringify } from "viem";
import { isInsufficientFundsError } from "../analytics/track/helpers.js";
import { trackInsufficientFundsError } from "../analytics/track/transaction.js";
import { resolveContractAbi } from "../contract/actions/resolve-abi.js";
import type { ThirdwebContract } from "../contract/contract.js";
import { isHex } from "../utils/encoding/hex.js";
@@ -11,8 +13,21 @@ import { IS_DEV } from "../utils/process.js";
export async function extractError<abi extends Abi>(args: {
error: unknown;
contract?: ThirdwebContract<abi>;
fromAddress?: string;
}) {
const { error, contract } = args;
const { error, contract, fromAddress } = args;
// Track insufficient funds errors during transaction preparation
if (isInsufficientFundsError(error) && contract) {
trackInsufficientFundsError({
client: contract.client,
error,
walletAddress: fromAddress,
chainId: contract.chain?.id,
contractAddress: contract.address,
});
}
const result = await extractErrorResult({ error, contract });
if (result) {
return new TransactionError(result, contract);
@@ -15,7 +15,8 @@ type EcosystemOptions = {
type SmartAccountOptions = {
defaultChainId: number;
sponsorGas: boolean;
accountFactoryAddress: string;
accountFactoryAddress?: string;
executionMode?: "EIP4337" | "EIP7702";
};
/**
@@ -57,6 +57,47 @@ export function createInAppWallet(args: {
let client: ThirdwebClient | undefined;
let authToken: string | null = null;
const resolveSmartAccountOptionsFromEcosystem = async (options: {
chain?: Chain;
}) => {
if (ecosystem) {
const ecosystemOptions = await getEcosystemInfo(ecosystem.id);
const smartAccountOptions = ecosystemOptions?.smartAccountOptions;
if (smartAccountOptions) {
const executionMode =
ecosystemOptions.smartAccountOptions.executionMode;
if (executionMode === "EIP7702") {
createOptions = {
...createOptions,
executionMode: {
mode: "EIP7702",
sponsorGas: smartAccountOptions.sponsorGas,
},
};
} else {
// default to 4337
const { defaultChainId } = ecosystemOptions.smartAccountOptions;
const preferredChain =
options.chain ??
(defaultChainId ? getCachedChain(defaultChainId) : undefined);
if (!preferredChain) {
throw new Error(
`A chain must be provided either via 'chain' in connect options or 'defaultChainId' in ecosystem configuration. Please pass it via connect() or update the ecosystem configuration.`,
);
}
createOptions = {
...createOptions,
smartAccount: {
chain: preferredChain,
sponsorGas: smartAccountOptions.sponsorGas,
factoryAddress: smartAccountOptions.accountFactoryAddress,
},
};
}
}
}
};
return {
id: walletId,
getAuthToken: () => authToken,
@@ -80,30 +121,7 @@ export function createInAppWallet(args: {
ecosystem,
);
if (ecosystem) {
const ecosystemOptions = await getEcosystemInfo(ecosystem.id);
const smartAccountOptions = ecosystemOptions?.smartAccountOptions;
if (smartAccountOptions) {
const { defaultChainId } = ecosystemOptions.smartAccountOptions;
const preferredChain =
options.chain ??
(defaultChainId ? getCachedChain(defaultChainId) : undefined);
if (!preferredChain) {
throw new Error(
`A chain must be provided either via 'chain' in connect options or 'defaultChainId' in ecosystem configuration. Please pass it via connect() or update the ecosystem configuration.`,
);
}
createOptions = {
...createOptions,
smartAccount: {
chain: preferredChain,
sponsorGas: smartAccountOptions.sponsorGas,
factoryAddress: smartAccountOptions.accountFactoryAddress,
},
};
}
}
await resolveSmartAccountOptionsFromEcosystem(options);
const {
account: connectedAccount,
@@ -140,30 +158,7 @@ export function createInAppWallet(args: {
ecosystem,
);
if (ecosystem) {
const ecosystemOptions = await getEcosystemInfo(ecosystem.id);
const smartAccountOptions = ecosystemOptions?.smartAccountOptions;
if (smartAccountOptions) {
const { defaultChainId } = ecosystemOptions.smartAccountOptions;
const preferredChain =
options.chain ??
(defaultChainId ? getCachedChain(defaultChainId) : undefined);
if (!preferredChain) {
throw new Error(
`A chain must be provided either via 'chain' in connect options or 'defaultChainId' in ecosystem configuration. Please pass it via connect() or update the ecosystem configuration.`,
);
}
createOptions = {
...createOptions,
smartAccount: {
chain: preferredChain,
sponsorGas: smartAccountOptions.sponsorGas,
factoryAddress: smartAccountOptions.accountFactoryAddress,
},
};
}
}
await resolveSmartAccountOptionsFromEcosystem(options);
const {
account: connectedAccount,
@@ -212,7 +207,12 @@ export function createInAppWallet(args: {
emitter.emit("disconnect", undefined);
},
switchChain: async (newChain) => {
if (createOptions?.smartAccount && client && account) {
if (
(createOptions?.smartAccount ||
createOptions?.executionMode?.mode === "EIP4337") &&
client &&
account
) {
// if account abstraction is enabled, reconnect to smart account on the new chain
const { autoConnectInAppWallet } = await import("./index.js");
const connector = await getOrCreateInAppWalletConnector(
@@ -221,20 +221,7 @@ export function createInAppWallet(args: {
ecosystem,
);
if (ecosystem) {
const ecosystemOptions = await getEcosystemInfo(ecosystem.id);
const smartAccountOptions = ecosystemOptions?.smartAccountOptions;
if (smartAccountOptions) {
createOptions = {
...createOptions,
smartAccount: {
chain: newChain,
sponsorGas: smartAccountOptions.sponsorGas,
factoryAddress: smartAccountOptions.accountFactoryAddress,
},
};
}
}
await resolveSmartAccountOptionsFromEcosystem({ chain: newChain });
const {
account: connectedAccount,
+35 -17
View File
@@ -5,7 +5,9 @@ import {
serializeTypedData,
validateTypedData,
} from "viem";
import { isInsufficientFundsError } from "../../analytics/track/helpers.js";
import { trackTransaction } from "../../analytics/track/transaction.js";
import { trackInsufficientFundsError } from "../../analytics/track/transaction.js";
import type { Chain } from "../../chains/types.js";
import { getCachedChain, getChainMetadata } from "../../chains/utils.js";
import type { ThirdwebClient } from "../../client/client.js";
@@ -200,25 +202,41 @@ function createAccount({
},
];
const transactionHash = (await provider.request({
method: "eth_sendTransaction",
// @ts-expect-error - overriding types here
params,
})) as Hex;
try {
const transactionHash = (await provider.request({
method: "eth_sendTransaction",
// @ts-expect-error - overriding types here
params,
})) as Hex;
trackTransaction({
client,
chainId: tx.chainId,
walletAddress: getAddress(address),
walletType: id,
transactionHash,
contractAddress: tx.to ?? undefined,
gasPrice: tx.gasPrice,
});
trackTransaction({
client,
chainId: tx.chainId,
walletAddress: getAddress(address),
walletType: id,
transactionHash,
contractAddress: tx.to ?? undefined,
gasPrice: tx.gasPrice,
});
return {
transactionHash,
};
return {
transactionHash,
};
} catch (error) {
// Track insufficient funds errors
if (isInsufficientFundsError(error)) {
trackInsufficientFundsError({
client,
error,
walletAddress: getAddress(address),
chainId: tx.chainId,
contractAddress: tx.to || undefined,
transactionValue: tx.value,
});
}
throw error;
}
},
async signMessage({ message }) {
if (!account.address) {
@@ -1,5 +1,7 @@
import type * as ox__TypedData from "ox/TypedData";
import { isInsufficientFundsError } from "../../analytics/track/helpers.js";
import { trackTransaction } from "../../analytics/track/transaction.js";
import { trackInsufficientFundsError } from "../../analytics/track/transaction.js";
import type { Chain } from "../../chains/types.js";
import { getCachedChain } from "../../chains/utils.js";
import type { ThirdwebClient } from "../../client/client.js";
@@ -559,6 +561,20 @@ async function _sendUserOp(args: {
chain: options.chain,
transactionHash: receipt.transactionHash,
};
} catch (error) {
// Track insufficient funds errors
if (isInsufficientFundsError(error)) {
trackInsufficientFundsError({
client: options.client,
error,
walletAddress: options.accountContract.address,
chainId: options.chain.id,
contractAddress: await resolvePromisedValue(executeTx.to ?? undefined),
transactionValue: await resolvePromisedValue(executeTx.value),
});
}
throw error;
} finally {
// reset the isDeploying flag after every transaction or error
clearAccountDeploying(options.accountContract);
+2
View File
@@ -1,5 +1,7 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.90
## 0.2.89
## 0.2.88
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/wagmi-adapter",
"version": "0.2.89",
"version": "0.2.90",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"