Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8910f55ca2 | ||
|
|
b237318b7d | ||
|
|
082d9630f4 | ||
|
|
d4bc8e199d | ||
|
|
4edf1ebe95 | ||
|
|
b12725b231 | ||
|
|
3d1c321bd6 | ||
|
|
810ec6efa7 | ||
|
|
1c6ed9bd0d | ||
|
|
82035fd0b7 | ||
|
|
8b324597fb | ||
|
|
5ebb7549ca | ||
|
|
dfe4cf0928 | ||
|
|
6cce31d828 | ||
|
|
aaad876a6f | ||
|
|
de297e4c97 | ||
|
|
2114e5f91f | ||
|
|
ca0841745a | ||
|
|
d150ea1c91 | ||
|
|
f1479b85f0 | ||
|
|
283dc289fc |
@@ -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>
|
||||
|
||||
+5
-10
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function RootLayout({
|
||||
/>
|
||||
<PosthogHeadSetup />
|
||||
</head>
|
||||
<PHProvider>
|
||||
<PHProvider disable_session_recording={true}>
|
||||
<PostHogPageView />
|
||||
<body
|
||||
className={cn(
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+139
-71
@@ -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>
|
||||
)}
|
||||
|
||||
+9
-1
@@ -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";
|
||||
|
||||
+250
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-11
@@ -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
|
||||
|
||||
+10
-8
@@ -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 (
|
||||
|
||||
+2
@@ -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({
|
||||
|
||||
+1
-1
@@ -336,7 +336,7 @@ export function CreateTokenAssetPage(props: {
|
||||
metadata: {
|
||||
name:
|
||||
formValues.saleEnabled && salePercent > 0
|
||||
? "Token Sale phase"
|
||||
? "Coin Sale phase"
|
||||
: "Only Owner phase",
|
||||
},
|
||||
overrideList: [
|
||||
|
||||
+1
@@ -101,6 +101,7 @@ export function CreateTokenAssetPageUI(props: {
|
||||
|
||||
{step === "distribution" && (
|
||||
<TokenDistributionFieldset
|
||||
tokenSymbol={tokenInfoForm.watch("symbol")}
|
||||
client={props.client}
|
||||
form={tokenDistributionForm}
|
||||
accountAddress={props.accountAddress}
|
||||
|
||||
+182
-78
@@ -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>
|
||||
|
||||
+49
-10
@@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+19
-18
@@ -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>
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+3
-3
@@ -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")}
|
||||
/>
|
||||
|
||||
+1
-7
@@ -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>
|
||||
);
|
||||
|
||||
+1
-7
@@ -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>
|
||||
);
|
||||
|
||||
+1
-7
@@ -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>
|
||||
);
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+70
-59
@@ -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>
|
||||
|
||||
+8
-30
@@ -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,
|
||||
|
||||
+28
-25
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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`,
|
||||
|
||||
+25
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const FORCE_GAS_PRICE_CHAIN_IDS = [
|
||||
5464, // Saga Mainnet
|
||||
2020, // Ronin Mainnet
|
||||
2021, // Ronin Testnet (Saigon)
|
||||
98866, // Plume mainnet
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
+32
-8
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @thirdweb-dev/wagmi-adapter
|
||||
|
||||
## 0.2.90
|
||||
|
||||
## 0.2.89
|
||||
|
||||
## 0.2.88
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user