Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3838032a | ||
|
|
60021b3a44 | ||
|
|
a81899d60a | ||
|
|
b366d56039 | ||
|
|
e568bcea60 | ||
|
|
a94f22928a | ||
|
|
5249cb7409 | ||
|
|
b37ed57365 | ||
|
|
c328aa13a6 | ||
|
|
a85ef0b222 | ||
|
|
51177fbe17 | ||
|
|
1338a0cf2c | ||
|
|
96ebf378ff | ||
|
|
b7732d3a75 | ||
|
|
6f88d38ad2 | ||
|
|
d53f8a25b4 | ||
|
|
dd693169bc | ||
|
|
93f913c614 | ||
|
|
02502159b6 |
@@ -474,6 +474,11 @@ async function redirects() {
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/routes",
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ type AssetContractType =
|
||||
*/
|
||||
export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
@@ -243,9 +243,51 @@ type TokenSwapParams = {
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
};
|
||||
|
||||
type TokenBuyParams = {
|
||||
buyTokenChainId: number | undefined;
|
||||
buyTokenAddress: string | undefined;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
};
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful token swaps from the token page
|
||||
* - To track number of successful token buys
|
||||
* - To track which tokens are being bought the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuySuccessful(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy successful", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed token buys
|
||||
* - To track which token buys are failing
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuyFailed(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of cancelled token buys
|
||||
* - To track which token buys are being cancelled
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportTokenBuyCancelled(properties: TokenBuyParams) {
|
||||
posthog.capture("token buy cancelled", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
@@ -271,7 +313,7 @@ export function reportSwapWidgetShown(properties: {
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed token swaps from the token page
|
||||
* - To track number of failed token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
@@ -287,7 +329,7 @@ export function reportTokenSwapFailed(
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of cancelled token swaps from the token page
|
||||
* - To track number of cancelled token swaps
|
||||
* - To track which tokens are being swapped the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
@@ -299,7 +341,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset purchases from the token page
|
||||
* - To track number of failed asset purchases
|
||||
* - To track the errors that users encounter when trying to purchase an asset
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
@@ -307,7 +349,7 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
@@ -329,7 +371,7 @@ export function reportAssetBuyFailed(properties: {
|
||||
*/
|
||||
export function reportAssetBuyCancelled(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy cancelled", {
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
reportSwapWidgetShown,
|
||||
reportTokenBuyCancelled,
|
||||
reportTokenBuyFailed,
|
||||
reportTokenBuySuccessful,
|
||||
reportTokenSwapCancelled,
|
||||
reportTokenSwapFailed,
|
||||
reportTokenSwapSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
|
||||
} from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
|
||||
type PageType = "asset" | "bridge" | "chain";
|
||||
|
||||
export function BuyAndSwapEmbed(props: {
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
tokenAddress: string | undefined;
|
||||
buyAmount: string | undefined;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: PageType;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
@@ -41,8 +51,23 @@ export function BuyAndSwapEmbed(props: {
|
||||
});
|
||||
}, [props.pageType]);
|
||||
|
||||
const client = useMemo(() => {
|
||||
return getConfiguredThirdwebClient({
|
||||
clientId:
|
||||
props.pageType === "asset"
|
||||
? NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID
|
||||
: props.pageType === "bridge"
|
||||
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
|
||||
: props.pageType === "chain"
|
||||
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
|
||||
: undefined,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
});
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col">
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
@@ -62,36 +87,85 @@ export function BuyAndSwapEmbed(props: {
|
||||
chain={props.chain}
|
||||
className="!rounded-2xl !w-full !border-none"
|
||||
title=""
|
||||
client={props.client}
|
||||
client={client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
}}
|
||||
onError={(e) => {
|
||||
onError={(e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel={(quote) => {
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
contractType: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={() => {
|
||||
onSuccess={(quote) => {
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId:
|
||||
quote.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenAddress:
|
||||
quote.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
contractType: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -103,16 +177,17 @@ export function BuyAndSwapEmbed(props: {
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
client={props.client}
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none !w-full"
|
||||
prefill={{
|
||||
sellToken: {
|
||||
// buy this token by default
|
||||
buyToken: {
|
||||
chainId: props.chain.id,
|
||||
tokenAddress: props.tokenAddress,
|
||||
},
|
||||
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
|
||||
buyToken: props.tokenAddress
|
||||
// sell the native token by default (but if buytoken is a native token, don't set)
|
||||
sellToken: props.tokenAddress
|
||||
? {
|
||||
chainId: props.chain.id,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Bridge, ThirdwebClient } from "thirdweb";
|
||||
import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -296,3 +296,89 @@ export function SingleNetworkSelector(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type BridgeNetworkSelectorProps = {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
align?: "center" | "start" | "end";
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
chains: Bridge.chains.Result;
|
||||
};
|
||||
|
||||
export function BridgeNetworkSelector(props: BridgeNetworkSelectorProps) {
|
||||
const options = useMemo(() => {
|
||||
return props.chains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [props.chains]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[props.chains],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[props.chains, props.client],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
align={props.align}
|
||||
className={props.className}
|
||||
closeOnSelect={true}
|
||||
onValueChange={(chainId) => {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={props.placeholder || "Select Chain"}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
searchPlaceholder="Search by Name or Chain ID"
|
||||
showCheck={false}
|
||||
side={props.side}
|
||||
value={props.chainId?.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -70,6 +71,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
{props.header.icon}
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
|
||||
@@ -4,7 +4,7 @@ export function GridPatternEmbedContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className=" sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-xl overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { CircleQuestionMarkIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
@@ -16,7 +16,6 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string;
|
||||
team: Team;
|
||||
@@ -39,21 +38,25 @@ export function CustomChatButton(props: {
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
<Button
|
||||
className="gap-2 rounded-full shadow-lg"
|
||||
className={cn(
|
||||
"gap-2 rounded-full shadow-lg fixed right-6 bottom-6 z-50 bg-card",
|
||||
isOpen && "opacity-0",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setHasBeenOpened(true);
|
||||
}}
|
||||
variant="default"
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
{props.label}
|
||||
<CircleQuestionMarkIcon className="size-3.5 text-muted-foreground" />
|
||||
Need help?
|
||||
</Button>
|
||||
|
||||
{/* Popup/Modal */}
|
||||
<div
|
||||
className={cn(
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-2xl",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -61,8 +64,7 @@ export function CustomChatButton(props: {
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
Need help?
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
|
||||
@@ -44,11 +44,12 @@ function AnnouncementBannerUI(props: {
|
||||
}
|
||||
|
||||
export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
// return (
|
||||
// <AnnouncementBannerUI
|
||||
// href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
// label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
// trackingLabel="product-rebrand"
|
||||
// />
|
||||
// );
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,12 @@ export const NEXT_PUBLIC_DEMO_ENGINE_URL =
|
||||
|
||||
export const NEXT_PUBLIC_THIRDWEB_AI_HOST =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com";
|
||||
|
||||
export const NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
|
||||
|
||||
@@ -92,23 +92,7 @@ if (REDIS_URL) {
|
||||
);
|
||||
}
|
||||
|
||||
// DO NOT TAINT THESE VALUES (for now)
|
||||
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
|
||||
|
||||
if (GROWTH_PLAN_SKU) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass GROWTH_PLAN_SKU to the client",
|
||||
process,
|
||||
GROWTH_PLAN_SKU,
|
||||
);
|
||||
}
|
||||
|
||||
export const PAYMENT_METHOD_CONFIGURATION =
|
||||
process.env.PAYMENT_METHOD_CONFIGURATION || "";
|
||||
|
||||
if (PAYMENT_METHOD_CONFIGURATION) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
|
||||
process,
|
||||
PAYMENT_METHOD_CONFIGURATION,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getVercelEnv } from "@/utils/vercel";
|
||||
export function getConfiguredThirdwebClient(options: {
|
||||
secretKey: string | undefined;
|
||||
teamId: string | undefined;
|
||||
clientId?: string;
|
||||
}): ThirdwebClient {
|
||||
if (getVercelEnv() !== "production") {
|
||||
// if not on production: run this when creating a client to set the domains
|
||||
@@ -77,7 +78,8 @@ export function getConfiguredThirdwebClient(options: {
|
||||
}
|
||||
|
||||
// During build time, provide fallbacks if credentials are missing
|
||||
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const clientId =
|
||||
options.clientId || NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const secretKey = options.secretKey || undefined;
|
||||
|
||||
return createThirdwebClient({
|
||||
|
||||
@@ -31,9 +31,9 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
selectedTextBg: "hsl(var(--inverted))",
|
||||
selectedTextColor: "hsl(var(--inverted-foreground))",
|
||||
separatorLine: "hsl(var(--border))",
|
||||
skeletonBg: "hsl(var(--muted))",
|
||||
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
|
||||
success: "hsl(var(--success-text))",
|
||||
tertiaryBg: "hsl(var(--muted)/50%)",
|
||||
tertiaryBg: "hsl(var(--muted)/30%)",
|
||||
tooltipBg: "hsl(var(--popover))",
|
||||
tooltipText: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB |
-37
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
totalPages: number;
|
||||
activePage: number;
|
||||
};
|
||||
|
||||
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
activePage,
|
||||
totalPages,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationButtons
|
||||
activePage={activePage}
|
||||
onPageClick={(page) => router.push(createPageURL(page))}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
function cleanUrl(url: string) {
|
||||
if (url.endsWith("?")) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// reset the input if the query param is removed
|
||||
if (inputRef.current?.value && !searchParams?.get("query")) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
if (term) {
|
||||
params.set("query", term);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
// always delete the page number when searching
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search by token name or symbol"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{searchParams?.has("query") && (
|
||||
<Button
|
||||
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.delete("query");
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<XCircleIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type QueryTypeProps = {
|
||||
activeType: "origin" | "destination";
|
||||
};
|
||||
|
||||
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(type: "origin" | "destination") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("type", type);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ToolTipLabel contentClassName="w-full" label="Origin">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("origin"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "origin" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowUpRightIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<ToolTipLabel contentClassName="w-full" label="Destination">
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("destination"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "destination" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowDownLeftIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type RouteListViewProps = {
|
||||
activeView: "grid" | "table";
|
||||
};
|
||||
|
||||
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("view", view);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("table"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "table" ? "default" : "outline"}
|
||||
>
|
||||
<ListIcon strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("grid"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "grid" ? "default" : "outline"}
|
||||
>
|
||||
<Grid2X2Icon strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol: string;
|
||||
originTokenName: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol: string;
|
||||
destinationTokenName: string;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenName,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenName,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full border border-muted-foreground"
|
||||
src={resolvedOriginTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenName={originTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenName={destinationTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenName(props: {
|
||||
tokenAddress: string;
|
||||
tokenName: string;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
if (isERC20) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenName}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left font-normal text-base">
|
||||
{props.chainMetadata.nativeCurrency.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol?: string;
|
||||
originTokenName?: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol?: string;
|
||||
destinationTokenName?: string;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenSymbol,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenSymbol,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenIconUri={resolvedOriginTokenIconUri}
|
||||
tokenSymbol={originTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{originChain.name}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenIconUri={resolvedDestinationTokenIconUri}
|
||||
tokenSymbol={destinationTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{destinationChain.name}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenInfo(props: {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string | undefined;
|
||||
chainMetadata: ChainMetadata;
|
||||
tokenIconUri: string | undefined;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{props.tokenIconUri ? (
|
||||
<Img
|
||||
alt={props.tokenAddress}
|
||||
className="size-7 rounded-full border border-border/50"
|
||||
src={props.tokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{isERC20 ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-auto w-auto py-1 px-1.5 gap-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-medium text-sm px-1.5 py-1">
|
||||
{props.chainMetadata.nativeCurrency.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "./routelist-card";
|
||||
import { RouteListRow } from "./routelist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
type: "origin" | "destination";
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationQuery = params.query;
|
||||
}
|
||||
}
|
||||
const routes = await getRoutes({
|
||||
destinationQuery: filters.destinationQuery,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
|
||||
originQuery: filters.originQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
filteredCount: routes.meta.filteredCount,
|
||||
routesToRender: routes.data,
|
||||
totalCount: routes.meta.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function RoutesData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{routesToRender.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{routesToRender.map((route) => (
|
||||
<RouteListRow
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{routesToRender.map((route) => (
|
||||
<li
|
||||
className="h-full"
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
>
|
||||
<RouteListCard
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination
|
||||
activePage={Number(props.searchParams.page) || 1}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{filteredCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
import {
|
||||
RoutesData,
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function RoutesPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const activeType = searchParams.type ?? "origin";
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col">
|
||||
<div className="flex w-full flex-row items-center gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/payments"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
activeView={activeView}
|
||||
isLoggedIn={!!authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
|
||||
export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originQuery) {
|
||||
url.searchParams.set("originQuery", originQuery);
|
||||
}
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: {
|
||||
data: Route[];
|
||||
meta: { totalCount: number; filteredCount: number };
|
||||
} = await routesResponse.json();
|
||||
|
||||
return routes;
|
||||
}
|
||||
+1
-6
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyAndSwapEmbed
|
||||
client={props.client}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineDashboardChain(props.chain.chainId, props.chain)}
|
||||
buyAmount={undefined}
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
@@ -18,8 +17,8 @@ export const size = {
|
||||
export const contentType = "image/png";
|
||||
|
||||
const TWLogo: React.FC = () => {
|
||||
const clipPathId = useId();
|
||||
const linearGradientId = useId();
|
||||
const clipPathId = "og-tw-logo-clip-path";
|
||||
const linearGradientId = "og-tw-logo-linear-gradient";
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
<svg
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function Page(props: {
|
||||
{chain.testnet ? (
|
||||
<FaucetSection chain={chain} client={client} isLoggedIn={!!account} />
|
||||
) : chain.services.find((c) => c.service === "pay" && c.enabled) ? (
|
||||
<BuyFundsSection chain={chain} client={client} />
|
||||
<BuyFundsSection chain={chain} />
|
||||
) : null}
|
||||
|
||||
{/* Chain Overview */}
|
||||
|
||||
-1
@@ -67,7 +67,6 @@ export async function getContractUniqueWalletAnalytics(params: {
|
||||
}
|
||||
|
||||
const json = (await res.json()) as InsightResponse;
|
||||
console.log("wallet analytics json", json);
|
||||
const aggregations = Object.values(json.aggregations[0]);
|
||||
|
||||
const returnValue: TransactionAnalyticsEntry[] = [];
|
||||
|
||||
+2
-2
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ImageResponse } from "@vercel/og";
|
||||
import { useId } from "react";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { API_ROUTES_CLIENT_ID } from "@/constants/server-envs";
|
||||
@@ -62,8 +61,9 @@ function shortenString(str: string) {
|
||||
return `${str.substring(0, 7)}...${str.substring(str.length - 5)}`;
|
||||
}
|
||||
|
||||
const gradientId = "og-brand-icon-gradient";
|
||||
|
||||
const OgBrandIcon: React.FC = () => {
|
||||
const gradientId = useId();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
<svg
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b bg-card">
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+3
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
|
||||
connectButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
connectModal={{
|
||||
privacyPolicyUrl: "/privacy-policy",
|
||||
showThirdwebBranding: false,
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export function ContractHeaderUI(props: {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-8 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
+9
-3
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -73,8 +74,13 @@ function RecentTransfersUI(props: {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-xl">
|
||||
<div className="flex mb-3">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-1">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -194,7 +200,7 @@ function RecentTransfersUI(props: {
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-xl border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
|
||||
+9
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import {
|
||||
@@ -35,10 +36,18 @@ export function ContractAnalyticsOverview(props: {
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
className="rounded-xl"
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
icon: (
|
||||
<div className="flex mb-3">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<BarChart3Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
export const mapChainIdToDexScreenerChainSlug = {
|
||||
8453: "base",
|
||||
1: "ethereum",
|
||||
56: "bsc",
|
||||
369: "pulsechain",
|
||||
137: "polygon",
|
||||
2741: "abstract",
|
||||
43114: "avalanche",
|
||||
999: "hyperliquid",
|
||||
480: "worldchain",
|
||||
42161: "arbitrum",
|
||||
388: "cronos",
|
||||
59144: "linea",
|
||||
1514: "story",
|
||||
397: "near",
|
||||
295: "hedera",
|
||||
146: "sonic",
|
||||
10: "optimism",
|
||||
80094: "berachain",
|
||||
57073: "ink",
|
||||
130: "unichain",
|
||||
5000: "mantle",
|
||||
324: "zksync",
|
||||
466: "apechain",
|
||||
1116: "core",
|
||||
250: "fantom",
|
||||
1868: "soneium",
|
||||
81457: "blast",
|
||||
2000: "dogechain",
|
||||
14: "flare",
|
||||
2040: "vana",
|
||||
4337: "beam",
|
||||
109: "shibarium",
|
||||
747: "flowevm",
|
||||
1088: "metis",
|
||||
1030: "conflux",
|
||||
43113: "avalanchedfk",
|
||||
534352: "scroll",
|
||||
747474: "katana",
|
||||
42220: "celo",
|
||||
1284: "moonbeam",
|
||||
4200: "merlinchain",
|
||||
2222: "kava",
|
||||
39797: "energi",
|
||||
34443: "mode",
|
||||
252: "fraxtal",
|
||||
48900: "zircuit",
|
||||
20: "elastos",
|
||||
100: "gnosischain",
|
||||
204: "opbnb",
|
||||
169: "manta",
|
||||
1313161554: "aurora",
|
||||
3073: "movement",
|
||||
4689: "iotex",
|
||||
23294: "oasissapphire",
|
||||
6001: "bouncebit",
|
||||
42170: "arbitrumnova",
|
||||
1101: "polygonzkevm",
|
||||
40: "telos",
|
||||
592: "astar",
|
||||
42262: "oasisemerald",
|
||||
1285: "moonriver",
|
||||
245022934: "neonevm",
|
||||
7777777: "zora",
|
||||
122: "fuse",
|
||||
321: "kcc",
|
||||
1234: "stepnetwork",
|
||||
106: "velas",
|
||||
167000: "taiko",
|
||||
288: "boba",
|
||||
42766: "zkfair",
|
||||
32520: "bitgert",
|
||||
82: "meter",
|
||||
} as const;
|
||||
|
||||
export type DexScreenerChainSlug =
|
||||
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo } from "react";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import type { DexScreenerChainSlug } from "./dex-screener-chains";
|
||||
|
||||
function DexScreenerIframe(props: {
|
||||
chain: DexScreenerChainSlug;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const iframeUrl = useMemo(() => {
|
||||
const resolvedTheme = theme === "light" ? "light" : "dark";
|
||||
const url = new URL("https://dexscreener.com");
|
||||
url.pathname = `${props.chain}/${props.contractAddress}`;
|
||||
url.searchParams.set("embed", "1");
|
||||
url.searchParams.set("loadChartSettings", "0");
|
||||
url.searchParams.set("chartTheme", resolvedTheme);
|
||||
url.searchParams.set("theme", resolvedTheme);
|
||||
url.searchParams.set("trades", "1");
|
||||
url.searchParams.set("chartStyle", "1");
|
||||
url.searchParams.set("chartLeftToolbar", "0");
|
||||
url.searchParams.set("chartType", "usd");
|
||||
url.searchParams.set("interval", "15");
|
||||
url.searchParams.set("chartDefaultOnMobile", "1");
|
||||
return url.toString();
|
||||
}, [theme, props.chain, props.contractAddress]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
|
||||
src={iframeUrl}
|
||||
title="DexScreener"
|
||||
></iframe>
|
||||
);
|
||||
}
|
||||
|
||||
export function DexScreener(props: {
|
||||
chain: DexScreenerChainSlug;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return (
|
||||
<ClientOnly
|
||||
ssr={
|
||||
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DexScreenerIframe
|
||||
chain={props.chain}
|
||||
contractAddress={props.contractAddress}
|
||||
/>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
+11
-32
@@ -1,42 +1,21 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { Bridge, type ThirdwebClient } from "thirdweb";
|
||||
|
||||
export async function fetchTokenInfoFromBridge(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
clientId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
|
||||
{
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`Failed to fetch token info from bridge: ${await res.text()}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data: Array<{
|
||||
iconUri: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
priceUsd: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
return data.data[0];
|
||||
const res = await Bridge.tokens({
|
||||
client: params.client,
|
||||
chainId: params.chainId,
|
||||
tokenAddress: params.tokenAddress,
|
||||
includePrices: true,
|
||||
limit: 1,
|
||||
});
|
||||
return res[0];
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+99
-19
@@ -1,3 +1,4 @@
|
||||
import { BarChart3Icon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
@@ -13,8 +14,6 @@ import { PageHeader } from "../_components/PageHeader";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
import { DexScreener } from "./_components/dex-screener";
|
||||
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
|
||||
import { RecentTransfers } from "./_components/RecentTransfers";
|
||||
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
|
||||
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
|
||||
@@ -40,7 +39,7 @@ export async function ERC20PublicPage(props: {
|
||||
}),
|
||||
fetchTokenInfoFromBridge({
|
||||
chainId: props.serverContract.chain.id,
|
||||
clientId: props.clientContract.client.clientId,
|
||||
client: props.serverContract.client,
|
||||
tokenAddress: props.serverContract.address,
|
||||
}),
|
||||
resolveFunctionSelectors(props.serverContract),
|
||||
@@ -80,7 +79,7 @@ export async function ERC20PublicPage(props: {
|
||||
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
|
||||
<PageHeader containerClassName="max-w-7xl" />
|
||||
|
||||
<div className="border-b">
|
||||
<div className="border-b border-dashed">
|
||||
<div className="container max-w-7xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
@@ -102,6 +101,44 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
{tokenInfoFromUB && (
|
||||
<div className="container max-w-7xl mb-8">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 p-6 border rounded-xl bg-card relative">
|
||||
<TokenInfoSection
|
||||
icon={DollarSignIcon}
|
||||
label="Price"
|
||||
value={
|
||||
tokenInfoFromUB.prices.USD
|
||||
? formatPrice(tokenInfoFromUB.prices.USD)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
|
||||
<TokenInfoSection
|
||||
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
|
||||
icon={TrendingUpIcon}
|
||||
label="Market Cap"
|
||||
value={
|
||||
tokenInfoFromUB.marketCapUsd
|
||||
? formatCompactUSD(tokenInfoFromUB.marketCapUsd)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
|
||||
<TokenInfoSection
|
||||
className="border-t pt-6 lg:border-l lg:border-t-0 border-dashed lg:pl-8 lg:pt-0"
|
||||
icon={BarChart3Icon}
|
||||
label="Volume (24h)"
|
||||
value={
|
||||
tokenInfoFromUB.volume24hUsd
|
||||
? formatCompactUSD(tokenInfoFromUB.volume24hUsd)
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<GridPatternEmbedContainer>
|
||||
@@ -118,20 +155,6 @@ export async function ERC20PublicPage(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<DexScreener
|
||||
chain={
|
||||
mapChainIdToDexScreenerChainSlug[
|
||||
props.chainMetadata
|
||||
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
|
||||
]
|
||||
}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container flex max-w-7xl grow flex-col pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<ContractAnalyticsOverview
|
||||
@@ -173,7 +196,6 @@ function BuyEmbed(props: {
|
||||
return (
|
||||
<BuyAndSwapEmbed
|
||||
chain={props.clientContract.chain}
|
||||
client={props.clientContract.client}
|
||||
tokenAddress={props.clientContract.address}
|
||||
buyAmount={undefined}
|
||||
pageType="asset"
|
||||
@@ -206,3 +228,61 @@ async function getActiveClaimConditionWithErrorHandler(
|
||||
}
|
||||
|
||||
type ActiveClaimCondition = Awaited<ReturnType<typeof getActiveClaimCondition>>;
|
||||
|
||||
function TokenInfoSection(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="flex mb-5">
|
||||
<div className="border rounded-full bg-background p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<dl className="space-y-1.5">
|
||||
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
|
||||
{props.label}
|
||||
</dt>
|
||||
<dd className="text-3xl font-bold text-foreground tracking-tight">
|
||||
{props.value}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
if (value < 100) {
|
||||
return smallValueUSDFormatter.format(value);
|
||||
}
|
||||
return largeValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
function formatCompactUSD(value: number): string {
|
||||
return compactValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TokensTable } from "./tokens-table";
|
||||
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainId, setChainId] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: [
|
||||
"tokens",
|
||||
{
|
||||
page,
|
||||
chainId,
|
||||
sortBy,
|
||||
search,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return Bridge.tokens({
|
||||
client: client,
|
||||
chainId: chainId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sortBy: search ? undefined : sortBy,
|
||||
query: search ? search : undefined,
|
||||
});
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-20 pt-8">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="mb-4 flex gap-4 lg:gap-3 flex-col lg:flex-row">
|
||||
<BridgeNetworkSelector
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
className="rounded-xl bg-card lg:w-fit min-w-[280px]"
|
||||
popoverContentClassName="!w-[300px] rounded-xl overflow-hidden"
|
||||
chains={props.chains}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<SortButton
|
||||
label="Popular"
|
||||
onClick={() => {
|
||||
setSortBy("market_cap");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "market_cap" && !search}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<SortButton
|
||||
label="Trending"
|
||||
onClick={() => {
|
||||
setSortBy("volume");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "volume" && !search}
|
||||
icon={TrendingUpIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:w-[420px] ml-auto">
|
||||
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search Tokens"
|
||||
value={search}
|
||||
className="rounded-xl bg-card flex-1 pl-9"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokensTable
|
||||
pageSize={pageSize}
|
||||
tokens={tokensQuery.data ?? []}
|
||||
isFetching={tokensQuery.isFetching}
|
||||
pagination={{
|
||||
onNext: () => setPage(page + 1),
|
||||
onPrevious: () => setPage(page - 1),
|
||||
nextDisabled: !!(
|
||||
tokensQuery.data && tokensQuery.data.length < pageSize
|
||||
),
|
||||
previousDisabled: page === 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onClick}
|
||||
className={cn(
|
||||
"rounded-xl gap-2 bg-card",
|
||||
props.isSelected && "bg-accent border-active-border",
|
||||
)}
|
||||
>
|
||||
<props.icon className="size-3.5" />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export function TokensTable(props: {
|
||||
tokens: Bridge.TokenWithPrices[];
|
||||
pageSize: number;
|
||||
isFetching: boolean;
|
||||
pagination: {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
nextDisabled: boolean;
|
||||
previousDisabled: boolean;
|
||||
};
|
||||
}) {
|
||||
const { tokens, isFetching } = props;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<TableContainer className="border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Market cap</TableHead>
|
||||
<TableHead>Volume (24h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching
|
||||
? new Array(props.pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: tokens.length > 0
|
||||
? tokens.map((token) => {
|
||||
const price = token.prices.USD;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${token.chainId}-${token.address}`}
|
||||
className="hover:bg-accent/50"
|
||||
linkBox
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={token.iconUri ?? undefined}
|
||||
alt={token.symbol}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{token.symbol?.slice(0, 2)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-medium leading-none text-base">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{price ? formatPrice(price) : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.marketCapUsd
|
||||
? formatUsdCompact(token.marketCapUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.volume24hUsd
|
||||
? formatUsdCompact(token.volume24hUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{tokens.length === 0 && !isFetching && (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground w-full text-sm ">
|
||||
No tokens found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end p-4 gap-3 border-t bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onPrevious()}
|
||||
disabled={props.pagination.previousDisabled || isFetching}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onNext()}
|
||||
disabled={props.pagination.nextDisabled || isFetching}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
if (value < 100) {
|
||||
return smallValueUSDFormatter.format(value);
|
||||
}
|
||||
return largeValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
function formatUsdCompact(value: number): string {
|
||||
return compactValueUSDFormatter.format(value);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
const title = "Tokens | thirdweb";
|
||||
const description = "Discover and swap any tokens on any chain, instantly";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const chains = await getBridgeSupportedChains();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
<div className="border-b py-10 ">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="flex mb-4">
|
||||
<div className="rounded-full bg-card p-2.5 border">
|
||||
<BringToFrontIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter mb-1">
|
||||
Discover and swap any tokens on any chain, instantly
|
||||
</h1>
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://thirdweb.com/monetize/bridge"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by thirdweb bridge
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<TokenPage chains={chains} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getBridgeSupportedChains = unstable_cache(
|
||||
async () => {
|
||||
const chains = await Bridge.chains({ client: serverThirdwebClient });
|
||||
return chains;
|
||||
},
|
||||
["bridge-supported-chains"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
@@ -19,8 +18,8 @@ export const size = {
|
||||
export const contentType = "image/png";
|
||||
|
||||
const TWLogo: React.FC = () => {
|
||||
const cipId = useId();
|
||||
const linearGradientId = useId();
|
||||
const cipId = "og-tw-logo-clip-path";
|
||||
const linearGradientId = "og-tw-logo-linear-gradient";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noSvgWithoutTitle: not needed
|
||||
|
||||
@@ -103,15 +103,12 @@ export default async function TeamLayout(props: {
|
||||
>
|
||||
{props.children}
|
||||
</TeamSidebarLayout>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Get Help"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, InfoIcon } from "lucide-react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
@@ -24,6 +24,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden py-10 lg:py-16">
|
||||
<AIUsageBanner />
|
||||
{props.showAurora && (
|
||||
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--primary-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--primary-foreground)/10%)]" />
|
||||
)}
|
||||
@@ -165,3 +166,35 @@ const Aurora: React.FC<AuroraProps> = ({ className }) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function AIUsageBanner() {
|
||||
return (
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-center px-4 pt-4">
|
||||
<div className="max-w-4xl">
|
||||
<div className="relative overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-blue-50/50 p-3 shadow-sm dark:border-blue-800 dark:from-blue-950/50 dark:to-blue-950/20">
|
||||
<div className="relative flex items-center gap-3 px-2">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<InfoIcon className="size-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
thirdweb AI usage is billed based on number of tokens used. See
|
||||
the{" "}
|
||||
<a
|
||||
href="https://thirdweb.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-700 underline decoration-blue-300 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-500 dark:text-blue-300 dark:decoration-blue-600 dark:hover:text-blue-200 dark:hover:decoration-blue-400"
|
||||
>
|
||||
pricing page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,15 +77,12 @@ export default async function ProjectLayout(props: {
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Ask AI Assistant"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { TokenInfo } from "thirdweb/react";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { bridgeAppThirdwebClient } from "../../constants";
|
||||
|
||||
export function UniversalBridgeEmbed({
|
||||
chainId,
|
||||
@@ -17,9 +16,8 @@ export function UniversalBridgeEmbed({
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<div className="lg:w-[400px]">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<BuyAndSwapEmbed
|
||||
client={bridgeAppThirdwebClient}
|
||||
chain={chain}
|
||||
buyAmount={amount}
|
||||
tokenAddress={token?.address}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function BridgeLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"h-screen w-screen bg-background font-sans antialiased",
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { bridgeAppThirdwebClient } from "./constants";
|
||||
|
||||
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
|
||||
@@ -49,8 +51,11 @@ export default async function BridgePage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex h-dvh w-full flex-col items-center justify-center overflow-hidden border py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<PageHeader />
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
@@ -64,42 +69,28 @@ export default async function BridgePage({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0 opacity-20"
|
||||
src="/assets/login/background.svg"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-8 z-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-base tracking-tight">
|
||||
Get Started with thirdweb Bridge
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
thirdweb Bridge allows developers to easily bridge and swap
|
||||
tokens between chains and wallets.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background relative z-10">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function EmptyStateChatPageContent(props: {
|
||||
onLoginClick: undefined | (() => void);
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden py-10 lg:py-16">
|
||||
<div className="relative overflow-hidden py-10 lg:py-16">
|
||||
{props.showAurora && (
|
||||
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--nebula-pink-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--nebula-pink-foreground)/10%)]" />
|
||||
)}
|
||||
|
||||
@@ -148,12 +148,12 @@ const nextConfig = {
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/insight",
|
||||
source: "/insight/:path*",
|
||||
destination: "https://insight.thirdweb.com/reference",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/payments/backend",
|
||||
source: "/payments/backend/:path*",
|
||||
destination: "/reference#tag/payments",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"thirdweb": "workspace:*",
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"x402-next": "^0.6.1",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -59,7 +59,9 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
prefill={props.options.prefill}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify(props.options)}
|
||||
key={JSON.stringify({
|
||||
prefill: props.options.prefill,
|
||||
})}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { CodeIcon, LockIcon } from "lucide-react";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
import { arbitrumSepolia } from "thirdweb/chains";
|
||||
import {
|
||||
ConnectButton,
|
||||
getDefaultToken,
|
||||
@@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { THIRDWEB_CLIENT } from "../../../../lib/client";
|
||||
|
||||
const chain = baseSepolia;
|
||||
const chain = arbitrumSepolia;
|
||||
const token = getDefaultToken(chain, "USDC");
|
||||
|
||||
export function X402ClientPreview() {
|
||||
|
||||
@@ -46,8 +46,8 @@ function ServerCodeExample() {
|
||||
Next.js Server Code Example
|
||||
</h2>
|
||||
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
|
||||
Use any x402 middleware + the thirdweb facilitator to settle
|
||||
transactions with our server wallet.
|
||||
Create middleware with the thirdweb facilitator to settle transactions
|
||||
with your server wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
@@ -57,28 +57,41 @@ function ServerCodeExample() {
|
||||
className="h-full rounded-none border-none"
|
||||
code={`// src/middleware.ts
|
||||
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { facilitator, settlePayment } from "thirdweb/x402";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
|
||||
const client = createThirdwebClient({ secretKey: "your-secret-key" });
|
||||
const thirdwebX402Facilitator = facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0xYourWalletAddress",
|
||||
});
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xYourWalletAddress",
|
||||
{
|
||||
"/api/paid-endpoint": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0xYourServerWalletAddress",
|
||||
}),
|
||||
);
|
||||
export async function middleware(request: NextRequest) {
|
||||
const method = request.method.toUpperCase();
|
||||
const resourceUrl = request.nextUrl.toString();
|
||||
const paymentData = request.headers.get("X-PAYMENT");
|
||||
|
||||
const result = await settlePayment({
|
||||
resourceUrl,
|
||||
method,
|
||||
paymentData,
|
||||
payTo: "0xYourWalletAddress",
|
||||
network: "eip155:11155111", // or any other chain id
|
||||
price: "$0.01", // can also be a ERC20 token amount
|
||||
facilitator: thirdwebX402Facilitator,
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
// payment successful, execute the request
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// otherwise, request payment
|
||||
return NextResponse.json(result.responseBody, {
|
||||
status: result.status,
|
||||
headers: result.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
import { arbitrumSepolia } from "thirdweb/chains";
|
||||
import { facilitator, settlePayment } from "thirdweb/x402";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
|
||||
});
|
||||
|
||||
const chain = arbitrumSepolia;
|
||||
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
|
||||
const ENGINE_VAULT_ACCESS_TOKEN = process.env
|
||||
.ENGINE_VAULT_ACCESS_TOKEN as string;
|
||||
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
{
|
||||
"/api/paywall": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
const twFacilitator = facilitator({
|
||||
baseUrl: `${API_URL}/v1/payments/x402`,
|
||||
client,
|
||||
serverWalletAddress: BACKEND_WALLET_ADDRESS,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const method = request.method.toUpperCase();
|
||||
const resourceUrl = `${request.nextUrl.protocol}//${request.nextUrl.host}${pathname}`;
|
||||
const paymentData = request.headers.get("X-PAYMENT");
|
||||
|
||||
const result = await settlePayment({
|
||||
resourceUrl,
|
||||
method,
|
||||
paymentData,
|
||||
payTo: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
network: `eip155:${chain.id}`,
|
||||
price: "$0.01",
|
||||
routeConfig: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
baseUrl: `${API_URL}/v1/payments/x402`,
|
||||
client,
|
||||
serverWalletAddress: BACKEND_WALLET_ADDRESS,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
}),
|
||||
);
|
||||
facilitator: twFacilitator,
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
// payment successful, execute the request
|
||||
const response = NextResponse.next();
|
||||
for (const [key, value] of Object.entries(result.responseHeaders)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// otherwise, request payment
|
||||
return NextResponse.json(result.responseBody, {
|
||||
status: result.status,
|
||||
headers: result.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TypeScriptIcon,
|
||||
} from "@/icons";
|
||||
import SwapWidgetImage from "./swap-dark.png";
|
||||
import SwapWidgetImageLight from "./swap-light.png";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
image: {
|
||||
@@ -55,7 +56,12 @@ function Example() {
|
||||
}
|
||||
```
|
||||
|
||||
<div className='dark-only'>
|
||||
<DocImage src={SwapWidgetImage} />
|
||||
</div>
|
||||
<div className='light-only'>
|
||||
<DocImage src={SwapWidgetImageLight} />
|
||||
</div>
|
||||
|
||||
## Live Playground
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -41,40 +41,61 @@ const response = await fetchWithPay('https://api.example.com/paid-endpoint');
|
||||
|
||||
## Server Side
|
||||
|
||||
To make your API calls payable, you can use any x402 middleware library like x402-hono, x402-next, x402-express, etc.
|
||||
To make your API calls payable, you can use the `settlePayment` function in a middleware or in your endpoint directly.
|
||||
|
||||
Then, use the `facilitator` configuratino function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
|
||||
Use the `facilitator` configuration function settle transactions with your thirdweb server wallet gaslessly and pass it to the `settlePayment` function.
|
||||
|
||||
Here's an example with Next.js:
|
||||
Here's an example with a Next.js middleware:
|
||||
|
||||
```typescript
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
import { facilitator, settlePayment } from "thirdweb/x402";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
|
||||
const client = createThirdwebClient({ secretKey: "your-secret-key" });
|
||||
const thirdwebX402Facilitator = facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0xYourWalletAddress",
|
||||
});
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
{
|
||||
"/api/paid-endpoint": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
export async function middleware(request: NextRequest) {
|
||||
const method = request.method.toUpperCase();
|
||||
const resourceUrl = request.nextUrl.toString();
|
||||
const paymentData = request.headers.get("X-PAYMENT");
|
||||
|
||||
const result = await settlePayment({
|
||||
resourceUrl,
|
||||
method,
|
||||
paymentData,
|
||||
payTo: "0xYourWalletAddress",
|
||||
network: "eip155:1", // or any other chain id in CAIP2 format: "eip155:<chain_id>"
|
||||
price: "$0.01", // can also be a ERC20 token amount
|
||||
routeConfig: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
}),
|
||||
);
|
||||
facilitator: thirdwebX402Facilitator,
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
// payment successful, execute the request
|
||||
const response = NextResponse.next();
|
||||
// optionally set the response headers back to the client
|
||||
for (const [key, value] of Object.entries(result.responseHeaders)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// otherwise, request payment
|
||||
return NextResponse.json(result.responseBody, {
|
||||
status: result.status,
|
||||
headers: result.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ["/api/paid-endpoint"],
|
||||
};
|
||||
```
|
||||
|
||||
You can also use the `verifyPayment` function to verify the payment before settling it. This lets you do the work that requires payment first and then settle the payment.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @thirdweb-dev/nebula
|
||||
|
||||
## 0.2.53
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a94f229`](https://github.com/thirdweb-dev/js/commit/a94f22928a662a5aff7a203fc2d383d9fa0907ec), [`5249cb7`](https://github.com/thirdweb-dev/js/commit/5249cb7409a8486346fe428f824c81dd90845555)]:
|
||||
- thirdweb@5.108.0
|
||||
|
||||
## 0.2.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2), [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa)]:
|
||||
- thirdweb@5.107.1
|
||||
|
||||
## 0.2.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.51"
|
||||
"version": "0.2.53"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# thirdweb
|
||||
|
||||
## 5.108.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8091](https://github.com/thirdweb-dev/js/pull/8091) [`5249cb7`](https://github.com/thirdweb-dev/js/commit/5249cb7409a8486346fe428f824c81dd90845555) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Accept arbitrary chain ids for x402 payments with new settlePayment() and verifyPayment() backend utility functions
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8100](https://github.com/thirdweb-dev/js/pull/8100) [`a94f229`](https://github.com/thirdweb-dev/js/commit/a94f22928a662a5aff7a203fc2d383d9fa0907ec) Thanks [@MananTank](https://github.com/MananTank)! - Update the `onSuccess`, `onError`, and `onCancel` callback props of the `BuyWidget` to be called with the `quote` object
|
||||
|
||||
```tsx
|
||||
<BuyWidget
|
||||
onSuccess={(quote) => console.log("Swap completed:", quote)}
|
||||
onError={(error, quote) => console.error("Swap failed:", error, quote)}
|
||||
onCancel={(quote) => console.log("Swap cancelled:", quote)}
|
||||
/>
|
||||
```
|
||||
|
||||
## 5.107.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8080](https://github.com/thirdweb-dev/js/pull/8080) [`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2) Thanks [@MananTank](https://github.com/MananTank)! - SwapWidget UI improvements
|
||||
|
||||
- [#8092](https://github.com/thirdweb-dev/js/pull/8092) [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa) Thanks [@0xFirekeeper](https://github.com/0xFirekeeper)! - add zephyr testnet to pre-1559 chains
|
||||
|
||||
## 5.107.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -427,5 +427,5 @@
|
||||
}
|
||||
},
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "5.107.0"
|
||||
"version": "5.108.0"
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ export async function tokens<
|
||||
limit,
|
||||
offset,
|
||||
includePrices,
|
||||
sortBy,
|
||||
query,
|
||||
} = options;
|
||||
|
||||
const clientFetch = getClientFetch(client);
|
||||
@@ -167,6 +169,13 @@ export async function tokens<
|
||||
if (includePrices !== undefined) {
|
||||
url.searchParams.set("includePrices", includePrices.toString());
|
||||
}
|
||||
if (sortBy !== undefined) {
|
||||
url.searchParams.set("sortBy", sortBy);
|
||||
}
|
||||
|
||||
if (query !== undefined) {
|
||||
url.searchParams.set("query", query);
|
||||
}
|
||||
|
||||
const response = await clientFetch(url.toString());
|
||||
if (!response.ok) {
|
||||
@@ -204,6 +213,10 @@ export declare namespace tokens {
|
||||
offset?: number | null;
|
||||
/** Whether or not to include prices for the tokens. Setting this to false will speed up the request. */
|
||||
includePrices?: IncludePrices;
|
||||
/** Sort by a specific field. */
|
||||
sortBy?: "newest" | "oldest" | "volume" | "market_cap";
|
||||
/** search for tokens by token name or symbol */
|
||||
query?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,8 @@ export type Token = {
|
||||
symbol: string;
|
||||
name: string;
|
||||
iconUri?: string;
|
||||
marketCapUsd?: number;
|
||||
volume24hUsd?: number;
|
||||
};
|
||||
|
||||
export type TokenWithPrices = Token & {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export { decodePayment, encodePayment } from "../x402/encode.js";
|
||||
export {
|
||||
facilitator,
|
||||
type ThirdwebX402FacilitatorConfig,
|
||||
} from "../x402/facilitator.js";
|
||||
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
|
||||
export { settlePayment } from "../x402/settle-payment.js";
|
||||
export type {
|
||||
PaymentArgs,
|
||||
SettlePaymentResult,
|
||||
VerifyPaymentResult,
|
||||
} from "../x402/types.js";
|
||||
export { verifyPayment } from "../x402/verify-payment.js";
|
||||
|
||||
@@ -47,6 +47,7 @@ const FORCE_GAS_PRICE_CHAIN_IDS = [
|
||||
2020, // Ronin Mainnet
|
||||
2021, // Ronin Testnet (Saigon)
|
||||
98866, // Plume mainnet
|
||||
1417429182, // Wilderworld Zephyr Testnet
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,6 +200,7 @@ export const iconSize = {
|
||||
"4xl": "128",
|
||||
lg: "32",
|
||||
md: "24",
|
||||
"sm+": "20",
|
||||
sm: "16",
|
||||
xl: "48",
|
||||
xs: "12",
|
||||
|
||||
@@ -37,7 +37,7 @@ type UseContractEventsOptions<
|
||||
*
|
||||
* ### Using event extensions
|
||||
*
|
||||
* The `thirdweb/extesions` export contains event definitions for many popular contracts.
|
||||
* The `thirdweb/extensions` export contains event definitions for many popular contracts.
|
||||
* You can use these event definitions to watch for specific events with a type-safe API.
|
||||
*
|
||||
* ```jsx
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useWaitForReceipt(
|
||||
},
|
||||
queryKey: [
|
||||
"waitForReceipt",
|
||||
// TODO: here chain can be undfined so we go to a `-1` chain but this feels wrong
|
||||
// TODO: here chain can be undefined so we go to a `-1` chain but this feels wrong
|
||||
options?.chain.id || -1,
|
||||
options?.transactionHash,
|
||||
] as const,
|
||||
|
||||
@@ -289,15 +289,9 @@ const DEFAULT_TOKENS = {
|
||||
symbol: "USDC",
|
||||
},
|
||||
],
|
||||
"421613": [
|
||||
"421614": [
|
||||
{
|
||||
address: "0xe39Ab88f8A4777030A534146A9Ca3B52bd5D43A3",
|
||||
icon: wrappedEthIcon,
|
||||
name: "Wrapped Ether",
|
||||
symbol: "WETH",
|
||||
},
|
||||
{
|
||||
address: "0xfd064A18f3BF249cf1f87FC203E90D8f650f2d63",
|
||||
address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
|
||||
icon: usdcIcon,
|
||||
name: "USD Coin",
|
||||
symbol: "USDC",
|
||||
|
||||
@@ -82,17 +82,17 @@ export interface BridgeOrchestratorProps {
|
||||
/**
|
||||
* Called when the flow is completed successfully
|
||||
*/
|
||||
onComplete: () => void;
|
||||
onComplete: (quote: BridgePrepareResult) => void;
|
||||
|
||||
/**
|
||||
* Called when the flow encounters an error
|
||||
*/
|
||||
onError: (error: Error) => void;
|
||||
onError: (error: Error, quote: BridgePrepareResult | undefined) => void;
|
||||
|
||||
/**
|
||||
* Called when the user cancels the flow
|
||||
*/
|
||||
onCancel: () => void;
|
||||
onCancel: (quote: BridgePrepareResult | undefined) => void;
|
||||
|
||||
/**
|
||||
* Connect options for wallet connection
|
||||
@@ -189,19 +189,22 @@ export function BridgeOrchestrator({
|
||||
}, [send, uiOptions.mode]);
|
||||
|
||||
// Handle post-buy transaction completion
|
||||
const handlePostBuyTransactionComplete = useCallback(() => {
|
||||
onComplete?.();
|
||||
send({ type: "RESET" });
|
||||
}, [onComplete, send]);
|
||||
const handlePostBuyTransactionComplete = useCallback(
|
||||
(quote: BridgePrepareResult) => {
|
||||
onComplete?.(quote);
|
||||
send({ type: "RESET" });
|
||||
},
|
||||
[onComplete, send],
|
||||
);
|
||||
|
||||
// Handle errors
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error(error);
|
||||
onError?.(error);
|
||||
onError?.(error, state.context.quote);
|
||||
send({ error, type: "ERROR_OCCURRED" });
|
||||
},
|
||||
[onError, send],
|
||||
[onError, send, state.context.quote],
|
||||
);
|
||||
|
||||
// Handle payment method selection
|
||||
@@ -227,10 +230,13 @@ export function BridgeOrchestrator({
|
||||
|
||||
// Handle execution complete
|
||||
const handleExecutionComplete = useCallback(
|
||||
(completedStatuses: CompletedStatusResult[]) => {
|
||||
(
|
||||
completedStatuses: CompletedStatusResult[],
|
||||
quote: BridgePrepareResult,
|
||||
) => {
|
||||
send({ completedStatuses, type: "EXECUTION_COMPLETE" });
|
||||
if (uiOptions.mode !== "transaction") {
|
||||
onComplete?.();
|
||||
onComplete?.(quote);
|
||||
}
|
||||
},
|
||||
[send, onComplete, uiOptions.mode],
|
||||
@@ -241,6 +247,8 @@ export function BridgeOrchestrator({
|
||||
send({ type: "RETRY" });
|
||||
}, [send]);
|
||||
|
||||
const quote = state.context.quote;
|
||||
|
||||
// Handle requirements resolved from FundWallet and DirectPayment
|
||||
const handleRequirementsResolved = useCallback(
|
||||
(amount: string, token: TokenWithPrices, receiverAddress: Address) => {
|
||||
@@ -263,7 +271,7 @@ export function BridgeOrchestrator({
|
||||
error={state.context.currentError}
|
||||
onCancel={() => {
|
||||
send({ type: "RESET" });
|
||||
onCancel?.();
|
||||
onCancel?.(quote);
|
||||
}}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
@@ -369,31 +377,33 @@ export function BridgeOrchestrator({
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.value === "execute" &&
|
||||
state.context.quote &&
|
||||
state.context.request && (
|
||||
<StepRunner
|
||||
autoStart={true}
|
||||
client={client}
|
||||
onBack={() => {
|
||||
send({ type: "BACK" });
|
||||
}}
|
||||
onCancel={onCancel}
|
||||
onComplete={handleExecutionComplete}
|
||||
request={state.context.request}
|
||||
wallet={state.context.selectedPaymentMethod?.payerWallet}
|
||||
windowAdapter={webWindowAdapter}
|
||||
/>
|
||||
)}
|
||||
{state.value === "execute" && quote && state.context.request && (
|
||||
<StepRunner
|
||||
autoStart={true}
|
||||
client={client}
|
||||
onBack={() => {
|
||||
send({ type: "BACK" });
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel(quote);
|
||||
}}
|
||||
onComplete={(completedStatuses) => {
|
||||
handleExecutionComplete(completedStatuses, quote);
|
||||
}}
|
||||
request={state.context.request}
|
||||
wallet={state.context.selectedPaymentMethod?.payerWallet}
|
||||
windowAdapter={webWindowAdapter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.value === "success" &&
|
||||
state.context.quote &&
|
||||
quote &&
|
||||
state.context.completedStatuses && (
|
||||
<SuccessScreen
|
||||
client={client}
|
||||
completedStatuses={state.context.completedStatuses}
|
||||
onDone={handleDoneOrContinueClick}
|
||||
preparedQuote={state.context.quote}
|
||||
preparedQuote={quote}
|
||||
uiOptions={uiOptions}
|
||||
windowAdapter={webWindowAdapter}
|
||||
hasPaymentId={!!paymentLinkId}
|
||||
@@ -402,9 +412,10 @@ export function BridgeOrchestrator({
|
||||
|
||||
{state.value === "post-buy-transaction" &&
|
||||
uiOptions.mode === "transaction" &&
|
||||
quote &&
|
||||
uiOptions.transaction && (
|
||||
<ExecutingTxScreen
|
||||
closeModal={handlePostBuyTransactionComplete}
|
||||
closeModal={() => handlePostBuyTransactionComplete(quote)}
|
||||
onTxSent={() => {
|
||||
// Do nothing
|
||||
}}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProv
|
||||
import type { Theme } from "../../../core/design-system/index.js";
|
||||
import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js";
|
||||
import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js";
|
||||
import type { BridgePrepareResult } from "../../../core/hooks/useBridgePrepare.js";
|
||||
import type { SupportedTokens } from "../../../core/utils/defaultTokens.js";
|
||||
import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js";
|
||||
import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js";
|
||||
@@ -32,6 +33,11 @@ import type { LocaleId } from "../types.js";
|
||||
import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
|
||||
import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";
|
||||
|
||||
type BuyOrOnrampPrepareResult = Extract<
|
||||
BridgePrepareResult,
|
||||
{ type: "buy" | "onramp" }
|
||||
>;
|
||||
|
||||
export type BuyWidgetProps = {
|
||||
/**
|
||||
* Customize the supported tokens that users can pay with.
|
||||
@@ -155,17 +161,17 @@ export type BuyWidgetProps = {
|
||||
/**
|
||||
* Callback triggered when the purchase is successful.
|
||||
*/
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: (quote: BuyOrOnrampPrepareResult) => void;
|
||||
|
||||
/**
|
||||
* Callback triggered when the purchase encounters an error.
|
||||
*/
|
||||
onError?: (error: Error) => void;
|
||||
onError?: (error: Error, quote: BuyOrOnrampPrepareResult | undefined) => void;
|
||||
|
||||
/**
|
||||
* Callback triggered when the user cancels the purchase.
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
onCancel?: (quote: BuyOrOnrampPrepareResult | undefined) => void;
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
@@ -447,14 +453,23 @@ export function BuyWidget(props: BuyWidgetProps) {
|
||||
client={props.client}
|
||||
connectLocale={localeQuery.data}
|
||||
connectOptions={props.connectOptions}
|
||||
onCancel={() => {
|
||||
props.onCancel?.();
|
||||
onCancel={(quote) => {
|
||||
// type guard
|
||||
if (quote?.type === "buy" || quote?.type === "onramp") {
|
||||
props.onCancel?.(quote);
|
||||
}
|
||||
}}
|
||||
onComplete={() => {
|
||||
props.onSuccess?.();
|
||||
onComplete={(quote) => {
|
||||
// type guard
|
||||
if (quote?.type === "buy" || quote?.type === "onramp") {
|
||||
props.onSuccess?.(quote);
|
||||
}
|
||||
}}
|
||||
onError={(err: Error) => {
|
||||
props.onError?.(err);
|
||||
onError={(err: Error, quote) => {
|
||||
// type guard
|
||||
if (quote?.type === "buy" || quote?.type === "onramp") {
|
||||
props.onError?.(err, quote);
|
||||
}
|
||||
}}
|
||||
paymentLinkId={props.paymentLinkId}
|
||||
paymentMethods={props.paymentMethods}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function SearchInput(props: {
|
||||
variant="outline"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
sm
|
||||
style={{
|
||||
paddingLeft: "44px",
|
||||
}}
|
||||
|
||||
@@ -162,6 +162,10 @@ export type SwapWidgetProps = {
|
||||
* @default true
|
||||
*/
|
||||
persistTokenSelections?: boolean;
|
||||
/**
|
||||
* Called when the user disconnects the active wallet
|
||||
*/
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -325,46 +329,11 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
});
|
||||
|
||||
const [buyToken, setBuyToken] = useState<TokenSelection | undefined>(() => {
|
||||
if (props.prefill?.buyToken) {
|
||||
return {
|
||||
tokenAddress:
|
||||
props.prefill.buyToken.tokenAddress ||
|
||||
getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: props.prefill.buyToken.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPersistEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastUsedBuyToken = getLastUsedTokens()?.buyToken;
|
||||
|
||||
// the token that will be set as initial value of sell token
|
||||
const sellToken = getInitialSellToken(
|
||||
props.prefill,
|
||||
getLastUsedTokens()?.sellToken,
|
||||
);
|
||||
|
||||
// if both tokens are same, ignore "buyToken", keep "sellToken"
|
||||
if (
|
||||
lastUsedBuyToken &&
|
||||
sellToken &&
|
||||
lastUsedBuyToken.tokenAddress.toLowerCase() ===
|
||||
sellToken.tokenAddress.toLowerCase() &&
|
||||
lastUsedBuyToken.chainId === sellToken.chainId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return lastUsedBuyToken;
|
||||
return getInitialTokens(props.prefill, isPersistEnabled).buyToken;
|
||||
});
|
||||
|
||||
const [sellToken, setSellToken] = useState<TokenSelection | undefined>(() => {
|
||||
return getInitialSellToken(
|
||||
props.prefill,
|
||||
isPersistEnabled ? getLastUsedTokens()?.sellToken : undefined,
|
||||
);
|
||||
return getInitialTokens(props.prefill, isPersistEnabled).sellToken;
|
||||
});
|
||||
|
||||
// persist selections to localStorage whenever they change
|
||||
@@ -394,6 +363,7 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
|
||||
return (
|
||||
<SwapUI
|
||||
onDisconnect={props.onDisconnect}
|
||||
showThirdwebBranding={
|
||||
props.showThirdwebBranding === undefined
|
||||
? true
|
||||
@@ -533,17 +503,63 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialSellToken(
|
||||
function getInitialTokens(
|
||||
prefill: SwapWidgetProps["prefill"],
|
||||
lastUsedSellToken: TokenSelection | undefined,
|
||||
) {
|
||||
if (prefill?.sellToken) {
|
||||
isPersistEnabled: boolean,
|
||||
): {
|
||||
buyToken: TokenSelection | undefined;
|
||||
sellToken: TokenSelection | undefined;
|
||||
} {
|
||||
const lastUsedTokens = isPersistEnabled ? getLastUsedTokens() : undefined;
|
||||
const buyToken = prefill?.buyToken
|
||||
? {
|
||||
tokenAddress:
|
||||
prefill.buyToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.buyToken.chainId,
|
||||
}
|
||||
: lastUsedTokens?.buyToken;
|
||||
|
||||
const sellToken = prefill?.sellToken
|
||||
? {
|
||||
tokenAddress:
|
||||
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.sellToken.chainId,
|
||||
}
|
||||
: lastUsedTokens?.sellToken;
|
||||
|
||||
// if both tokens are same
|
||||
if (
|
||||
buyToken &&
|
||||
sellToken &&
|
||||
buyToken.tokenAddress?.toLowerCase() ===
|
||||
sellToken.tokenAddress?.toLowerCase() &&
|
||||
buyToken.chainId === sellToken.chainId
|
||||
) {
|
||||
// if sell token prefill is specified, ignore buy token
|
||||
if (prefill?.sellToken) {
|
||||
return {
|
||||
buyToken: undefined,
|
||||
sellToken: sellToken,
|
||||
};
|
||||
}
|
||||
|
||||
// if buy token prefill is specified, ignore sell token
|
||||
if (prefill?.buyToken) {
|
||||
return {
|
||||
buyToken: buyToken,
|
||||
sellToken: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// if none of the two are specified via prefill, keep buy token
|
||||
return {
|
||||
tokenAddress:
|
||||
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.sellToken.chainId,
|
||||
buyToken: buyToken,
|
||||
sellToken: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return lastUsedSellToken;
|
||||
return {
|
||||
buyToken: buyToken,
|
||||
sellToken: sellToken,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Theme } from "../../../../core/design-system/index.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
|
||||
export function DecimalRenderer(props: {
|
||||
value: string;
|
||||
color: keyof Theme["colors"];
|
||||
weight: 400 | 500 | 600 | 700;
|
||||
integerSize: "md" | "sm";
|
||||
fractionSize: "sm" | "xs";
|
||||
}) {
|
||||
if (Number(props.value) > 1000) {
|
||||
return (
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{compactFormatter.format(Number(props.value))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const [integerPart, fractionPart] = props.value.split(".");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{integerPart}
|
||||
</Text>
|
||||
<Text size={props.fractionSize} color={props.color} weight={props.weight}>
|
||||
.{fractionPart || "00"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
@@ -21,6 +22,7 @@ type SelectBuyTokenProps = {
|
||||
client: ThirdwebClient;
|
||||
onSelectChain: (chain: BridgeChain) => void;
|
||||
selectedChain: BridgeChain | undefined;
|
||||
isMobile: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,11 +58,15 @@ export function SelectBridgeChainUI(
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Chain" />
|
||||
</Container>
|
||||
<Line />
|
||||
<Container fullHeight flex="column">
|
||||
{props.isMobile && (
|
||||
<>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Chain" />
|
||||
</Container>
|
||||
<Line />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer y="md" />
|
||||
|
||||
@@ -79,10 +85,12 @@ export function SelectBridgeChainUI(
|
||||
<Spacer y="sm" />
|
||||
|
||||
<Container
|
||||
expand
|
||||
px="md"
|
||||
gap={props.isMobile ? undefined : "xxs"}
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
maxHeight: props.isMobile ? "400px" : "none",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
@@ -95,6 +103,7 @@ export function SelectBridgeChainUI(
|
||||
client={props.client}
|
||||
onClick={() => props.onSelectChain(chain)}
|
||||
isSelected={chain.chainId === props.selectedChain?.chainId}
|
||||
isMobile={props.isMobile}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -119,7 +128,7 @@ export function SelectBridgeChainUI(
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +153,7 @@ function ChainButton(props: {
|
||||
client: ThirdwebClient;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
@@ -152,18 +162,21 @@ function ChainButton(props: {
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
fontSize: props.isMobile ? fontSize.md : fontSize.sm,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
padding: !props.isMobile ? `${spacing.xs} ${spacing.xs}` : undefined,
|
||||
}}
|
||||
gap="sm"
|
||||
onClick={props.onClick}
|
||||
gap="sm"
|
||||
>
|
||||
<Img
|
||||
src={props.chain.icon}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
width={props.isMobile ? iconSize.lg : iconSize.md}
|
||||
height={props.isMobile ? iconSize.lg : iconSize.md}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DiscIcon } from "@radix-ui/react-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Token } from "../../../../../bridge/index.js";
|
||||
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { toTokens } from "../../../../../utils/units.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
@@ -12,15 +12,15 @@ import {
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
import { Container, noScrollBar } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Spinner } from "../../components/Spinner.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { StyledDiv } from "../../design-system/elements.js";
|
||||
import { useIsMobile } from "../../hooks/useisMobile.js";
|
||||
import { SearchInput } from "./SearchInput.js";
|
||||
import { SelectChainButton } from "./SelectChainButton.js";
|
||||
import { SelectBridgeChain } from "./select-chain.js";
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useTokenBalances,
|
||||
useTokens,
|
||||
} from "./use-tokens.js";
|
||||
import { tokenAmountFormatter } from "./utils.js";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -138,6 +139,7 @@ function SelectTokenUI(
|
||||
showMore: (() => void) | undefined;
|
||||
},
|
||||
) {
|
||||
const isMobile = useIsMobile();
|
||||
const [screen, setScreen] = useState<"select-chain" | "select-token">(
|
||||
"select-token",
|
||||
);
|
||||
@@ -180,184 +182,70 @@ function SelectTokenUI(
|
||||
});
|
||||
}, [otherTokens]);
|
||||
|
||||
const noTokensFound =
|
||||
!props.isFetching &&
|
||||
sortedOtherTokens.length === 0 &&
|
||||
props.ownedTokens.length === 0;
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<Container
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "300px 1fr",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<LeftContainer>
|
||||
<SelectBridgeChain
|
||||
onBack={() => setScreen("select-token")}
|
||||
client={props.client}
|
||||
isMobile={false}
|
||||
onSelectChain={(chain) => {
|
||||
props.setSelectedChain(chain);
|
||||
setScreen("select-token");
|
||||
}}
|
||||
selectedChain={props.selectedChain}
|
||||
/>
|
||||
</LeftContainer>
|
||||
<Container flex="column" relative scrollY>
|
||||
<TokenSelectionScreen
|
||||
onSelectToken={props.setSelectedToken}
|
||||
isMobile={false}
|
||||
selectedToken={props.selectedToken}
|
||||
isFetching={props.isFetching}
|
||||
ownedTokens={props.ownedTokens}
|
||||
otherTokens={sortedOtherTokens}
|
||||
showMore={props.showMore}
|
||||
selectedChain={props.selectedChain}
|
||||
onSelectChain={() => setScreen("select-chain")}
|
||||
client={props.client}
|
||||
search={props.search}
|
||||
setSearch={props.setSearch}
|
||||
/>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "select-token") {
|
||||
return (
|
||||
<Container>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Token" />
|
||||
</Container>
|
||||
<Line />
|
||||
|
||||
{!props.selectedChain && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "400px",
|
||||
}}
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedChain && (
|
||||
<>
|
||||
<Container p="md">
|
||||
<SelectChainButton
|
||||
onClick={() => setScreen("select-chain")}
|
||||
selectedChain={props.selectedChain}
|
||||
client={props.client}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{/* search */}
|
||||
<Container px="md">
|
||||
<SearchInput
|
||||
value={props.search}
|
||||
onChange={props.setSearch}
|
||||
placeholder="Search by Token or Address"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Spacer y="sm" />
|
||||
<Container px="md">
|
||||
<Container
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{props.isFetching &&
|
||||
new Array(20).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TokenButtonSkeleton key={i} />
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
>
|
||||
<WalletDotIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Your Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOwnedTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.token_address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
!!props.selectedToken &&
|
||||
props.selectedToken.tokenAddress.toLowerCase() ===
|
||||
token.token_address.toLowerCase() &&
|
||||
token.chain_id === props.selectedToken.chainId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<CoinsIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Other Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOtherTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
props.selectedToken?.tokenAddress.toLowerCase() ===
|
||||
token.address.toLowerCase()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
props.showMore?.();
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noTokensFound && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText">
|
||||
No Tokens Found
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
<TokenSelectionScreen
|
||||
onSelectToken={props.setSelectedToken}
|
||||
selectedToken={props.selectedToken}
|
||||
isFetching={props.isFetching}
|
||||
ownedTokens={props.ownedTokens}
|
||||
otherTokens={sortedOtherTokens}
|
||||
showMore={props.showMore}
|
||||
selectedChain={props.selectedChain}
|
||||
isMobile={true}
|
||||
onSelectChain={() => setScreen("select-chain")}
|
||||
client={props.client}
|
||||
search={props.search}
|
||||
setSearch={props.setSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "select-chain") {
|
||||
return (
|
||||
<SelectBridgeChain
|
||||
isMobile={true}
|
||||
onBack={() => setScreen("select-token")}
|
||||
client={props.client}
|
||||
onSelectChain={(chain) => {
|
||||
@@ -379,7 +267,7 @@ function TokenButtonSkeleton() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
padding: `${spacing.xs} ${spacing.xs}`,
|
||||
height: "70px",
|
||||
}}
|
||||
>
|
||||
@@ -398,6 +286,7 @@ function TokenButton(props: {
|
||||
onSelect: (tokenWithPrices: TokenSelection) => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
const tokenBalanceInUnits =
|
||||
"balance" in props.token
|
||||
? toTokens(BigInt(props.token.balance), props.token.decimals)
|
||||
@@ -416,7 +305,7 @@ function TokenButton(props: {
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.xs}`,
|
||||
padding: `${spacing.xs} ${spacing.xs}`,
|
||||
textAlign: "left",
|
||||
lineHeight: "1.5",
|
||||
borderRadius: radius.lg,
|
||||
@@ -451,7 +340,14 @@ function TokenButton(props: {
|
||||
}}
|
||||
fallback={
|
||||
<Container color="secondaryText">
|
||||
<DiscIcon width={iconSize.lg} height={iconSize.lg} />
|
||||
<Container
|
||||
style={{
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: `${iconSize.lg}px`,
|
||||
height: `${iconSize.lg}px`,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
}
|
||||
/>
|
||||
@@ -473,18 +369,15 @@ function TokenButton(props: {
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.token.symbol}
|
||||
</Text>
|
||||
|
||||
{"balance" in props.token && (
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={formatTokenAmount(
|
||||
BigInt(props.token.balance),
|
||||
props.token.decimals,
|
||||
3,
|
||||
<Text size="md" color="primaryText">
|
||||
{tokenAmountFormatter.format(
|
||||
Number(
|
||||
toTokens(BigInt(props.token.balance), props.token.decimals),
|
||||
),
|
||||
)}
|
||||
color="primaryText"
|
||||
weight={500}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Container>
|
||||
<Container
|
||||
@@ -495,9 +388,8 @@ function TokenButton(props: {
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
weight={400}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -509,16 +401,9 @@ function TokenButton(props: {
|
||||
</Text>
|
||||
{usdValue && (
|
||||
<Container flex="row">
|
||||
<Text size="sm" color="secondaryText" weight={400}>
|
||||
$
|
||||
<Text size="xs" color="secondaryText" weight={400}>
|
||||
${usdValue.toFixed(2)}
|
||||
</Text>
|
||||
<DecimalRenderer
|
||||
value={usdValue.toFixed(2)}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
integerSize="sm"
|
||||
fractionSize="xs"
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
@@ -526,3 +411,223 @@ function TokenButton(props: {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSelectionScreen(props: {
|
||||
selectedChain: BridgeChain | undefined;
|
||||
isMobile: boolean;
|
||||
onSelectChain: () => void;
|
||||
client: ThirdwebClient;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
isFetching: boolean;
|
||||
ownedTokens: TokenBalance[];
|
||||
otherTokens: Token[];
|
||||
showMore: (() => void) | undefined;
|
||||
selectedToken: TokenSelection | undefined;
|
||||
onSelectToken: (token: TokenSelection) => void;
|
||||
}) {
|
||||
const noTokensFound =
|
||||
!props.isFetching &&
|
||||
props.otherTokens.length === 0 &&
|
||||
props.ownedTokens.length === 0;
|
||||
|
||||
return (
|
||||
<Container fullHeight flex="column">
|
||||
<Container px="md" pt="md+">
|
||||
<Text size="lg" weight={600} color="primaryText" trackingTight>
|
||||
Select Token
|
||||
</Text>
|
||||
<Spacer y="3xs" />
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
multiline
|
||||
style={{
|
||||
textWrap: "pretty",
|
||||
maxWidth: "70%",
|
||||
}}
|
||||
>
|
||||
Select a token from the list or search for a token by symbol or
|
||||
address
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
{!props.selectedChain && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "400px",
|
||||
}}
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedChain && (
|
||||
<>
|
||||
{props.isMobile ? (
|
||||
<Container p="md">
|
||||
<SelectChainButton
|
||||
onClick={props.onSelectChain}
|
||||
selectedChain={props.selectedChain}
|
||||
client={props.client}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<Spacer y="md" />
|
||||
)}
|
||||
|
||||
{/* search */}
|
||||
<Container px="md">
|
||||
<SearchInput
|
||||
value={props.search}
|
||||
onChange={props.setSearch}
|
||||
placeholder="Search by token or address"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Spacer y="xs" />
|
||||
|
||||
<Container
|
||||
pb="md"
|
||||
px="md"
|
||||
expand
|
||||
gap="xxs"
|
||||
flex="column"
|
||||
style={{
|
||||
minHeight: "400px",
|
||||
maxHeight: props.isMobile ? "450px" : "none",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{props.isFetching &&
|
||||
new Array(20).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TokenButtonSkeleton key={i} />
|
||||
))}
|
||||
|
||||
{!props.isFetching && props.ownedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
>
|
||||
<WalletDotIcon size={iconSize.xs} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Your Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
props.ownedTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.token_address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.onSelectToken}
|
||||
isSelected={
|
||||
!!props.selectedToken &&
|
||||
props.selectedToken.tokenAddress.toLowerCase() ===
|
||||
token.token_address.toLowerCase() &&
|
||||
token.chain_id === props.selectedToken.chainId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!props.isFetching && props.ownedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<CoinsIcon size={iconSize.xs} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Other Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
props.otherTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.onSelectToken}
|
||||
isSelected={
|
||||
props.selectedToken?.tokenAddress.toLowerCase() ===
|
||||
token.address.toLowerCase()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
props.showMore?.();
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noTokensFound && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText">
|
||||
No Tokens Found
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const LeftContainer = /* @__PURE__ */ StyledDiv((_) => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflowY: "auto",
|
||||
...noScrollBar,
|
||||
borderRight: `1px solid ${theme.colors.separatorLine}`,
|
||||
position: "relative",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import styled from "@emotion/styled";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DiscIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js";
|
||||
@@ -15,12 +11,10 @@ import { defineChain } from "../../../../../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
|
||||
import { getToken } from "../../../../../pay/convert/get-token.js";
|
||||
import {
|
||||
getFiatSymbol,
|
||||
type SupportedFiatCurrency,
|
||||
} from "../../../../../pay/convert/type.js";
|
||||
import { getAddress } from "../../../../../utils/address.js";
|
||||
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
|
||||
import { getAddress, shortenAddress } from "../../../../../utils/address.js";
|
||||
import { toTokens, toUnits } from "../../../../../utils/units.js";
|
||||
import { AccountProvider } from "../../../../core/account/provider.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
@@ -31,11 +25,16 @@ import {
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js";
|
||||
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
import { WalletProvider } from "../../../../core/wallet/provider.js";
|
||||
import { ConnectButton } from "../../ConnectWallet/ConnectButton.js";
|
||||
import { DetailsModal } from "../../ConnectWallet/Details.js";
|
||||
import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import connectLocaleEn from "../../ConnectWallet/locale/en.js";
|
||||
import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import {
|
||||
formatCurrencyAmount,
|
||||
formatTokenAmount,
|
||||
} from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Input } from "../../components/formElements.js";
|
||||
@@ -43,8 +42,13 @@ import { Img } from "../../components/Img.js";
|
||||
import { Modal } from "../../components/Modal.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Spinner } from "../../components/Spinner.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { useIsMobile } from "../../hooks/useisMobile.js";
|
||||
import { AccountAvatar } from "../../prebuilt/Account/avatar.js";
|
||||
import { AccountBlobbie } from "../../prebuilt/Account/blobbie.js";
|
||||
import { AccountName } from "../../prebuilt/Account/name.js";
|
||||
import { WalletIcon } from "../../prebuilt/Wallet/icon.js";
|
||||
import { SelectToken } from "./select-token-ui.js";
|
||||
import type {
|
||||
ActiveWalletInfo,
|
||||
@@ -82,6 +86,7 @@ type SwapUIProps = {
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
}) => void;
|
||||
onDisconnect: (() => void) | undefined;
|
||||
};
|
||||
|
||||
function useTokenPrice(options: {
|
||||
@@ -110,9 +115,16 @@ function useTokenPrice(options: {
|
||||
* @internal
|
||||
*/
|
||||
export function SwapUI(props: SwapUIProps) {
|
||||
const [modalState, setModalState] = useState<
|
||||
"select-buy-token" | "select-sell-token" | undefined
|
||||
>(undefined);
|
||||
const [modalState, setModalState] = useState<{
|
||||
screen: "select-buy-token" | "select-sell-token";
|
||||
isOpen: boolean;
|
||||
}>({
|
||||
screen: "select-buy-token",
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Token Prices ----------------------------------------------------------------------------
|
||||
const buyTokenQuery = useTokenPrice({
|
||||
@@ -193,14 +205,19 @@ export function SwapUI(props: SwapUIProps) {
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
const disableContinue =
|
||||
!preparedResultQuery.data ||
|
||||
preparedResultQuery.isFetching ||
|
||||
notEnoughBalance;
|
||||
|
||||
return (
|
||||
<Container p="md">
|
||||
<Modal
|
||||
hide={false}
|
||||
className="tw-modal__swap-widget"
|
||||
size="compact"
|
||||
size={isMobile ? "compact" : "wide"}
|
||||
title="Select Token"
|
||||
open={!!modalState}
|
||||
open={modalState.isOpen}
|
||||
crossContainerStyles={{
|
||||
right: spacing.md,
|
||||
top: spacing["md+"],
|
||||
@@ -208,19 +225,30 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}}
|
||||
setOpen={(v) => {
|
||||
if (!v) {
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "select-buy-token" && (
|
||||
{modalState.screen === "select-buy-token" && (
|
||||
<SelectToken
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
onBack={() => setModalState(undefined)}
|
||||
onBack={() =>
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}))
|
||||
}
|
||||
client={props.client}
|
||||
selectedToken={props.buyToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setBuyToken(token);
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
// if buy token is same as sell token, unset sell token
|
||||
if (
|
||||
props.sellToken &&
|
||||
@@ -234,14 +262,22 @@ export function SwapUI(props: SwapUIProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalState === "select-sell-token" && (
|
||||
{modalState.screen === "select-sell-token" && (
|
||||
<SelectToken
|
||||
onBack={() => setModalState(undefined)}
|
||||
onBack={() =>
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}))
|
||||
}
|
||||
client={props.client}
|
||||
selectedToken={props.sellToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setSellToken(token);
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
// if sell token is same as buy token, unset buy token
|
||||
if (
|
||||
props.buyToken &&
|
||||
@@ -257,10 +293,35 @@ export function SwapUI(props: SwapUIProps) {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{detailsModalOpen && (
|
||||
<DetailsModal
|
||||
client={props.client}
|
||||
locale={connectLocaleEn}
|
||||
detailsModal={undefined}
|
||||
theme={props.theme}
|
||||
closeModal={() => {
|
||||
setDetailsModalOpen(false);
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
props.onDisconnect?.();
|
||||
}}
|
||||
chains={[]}
|
||||
connectOptions={props.connectOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sell */}
|
||||
<TokenSection
|
||||
onMaxClick={() => {
|
||||
if (sellTokenBalanceQuery.data) {
|
||||
props.setAmountSelection({
|
||||
type: "sell",
|
||||
amount: sellTokenBalanceQuery.data.displayValue,
|
||||
});
|
||||
}
|
||||
}}
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={notEnoughBalance}
|
||||
balance={{
|
||||
data: sellTokenBalanceQuery.data?.value,
|
||||
isFetching: sellTokenBalanceQuery.isFetching,
|
||||
@@ -269,7 +330,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
data: sellTokenAmount,
|
||||
isFetching: isSellAmountFetching,
|
||||
}}
|
||||
label="Sell"
|
||||
type="sell"
|
||||
setAmount={(value) => {
|
||||
props.setAmountSelection({ type: "sell", amount: value });
|
||||
}}
|
||||
@@ -283,7 +344,15 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-sell-token")}
|
||||
onSelectToken={() =>
|
||||
setModalState({
|
||||
screen: "select-sell-token",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
onWalletClick={() => {
|
||||
setDetailsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Switch */}
|
||||
@@ -302,8 +371,12 @@ export function SwapUI(props: SwapUIProps) {
|
||||
|
||||
{/* Buy */}
|
||||
<TokenSection
|
||||
onMaxClick={undefined}
|
||||
onWalletClick={() => {
|
||||
setDetailsModalOpen(true);
|
||||
}}
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={false}
|
||||
balance={{
|
||||
data: buyTokenBalanceQuery.data?.value,
|
||||
isFetching: buyTokenBalanceQuery.isFetching,
|
||||
@@ -312,7 +385,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
data: buyTokenAmount,
|
||||
isFetching: isBuyAmountFetching,
|
||||
}}
|
||||
label="Buy"
|
||||
type="buy"
|
||||
selectedToken={
|
||||
props.buyToken
|
||||
? {
|
||||
@@ -326,7 +399,12 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-buy-token")}
|
||||
onSelectToken={() =>
|
||||
setModalState({
|
||||
screen: "select-buy-token",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* error message */}
|
||||
@@ -342,7 +420,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
Failed to get a quote
|
||||
</Text>
|
||||
) : (
|
||||
<Spacer y="lg" />
|
||||
<Spacer y="md" />
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
@@ -361,11 +439,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={
|
||||
!preparedResultQuery.data ||
|
||||
preparedResultQuery.isFetching ||
|
||||
notEnoughBalance
|
||||
}
|
||||
disabled={disableContinue}
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (
|
||||
@@ -385,14 +459,20 @@ export function SwapUI(props: SwapUIProps) {
|
||||
});
|
||||
}
|
||||
}}
|
||||
gap="xs"
|
||||
style={{
|
||||
fontSize: fontSize.md,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
borderRadius: radius.lg,
|
||||
borderRadius: radius.full,
|
||||
opacity: disableContinue ? 0.5 : 1,
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Swap
|
||||
{preparedResultQuery.isFetching && <Spinner size="sm" />}
|
||||
{preparedResultQuery.isFetching
|
||||
? "Fetching Quote"
|
||||
: notEnoughBalance
|
||||
? "Insufficient Balance"
|
||||
: "Swap"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -599,10 +679,12 @@ function DecimalInput(props: {
|
||||
style={{
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
fontSize: fontSize.xxl,
|
||||
fontSize: fontSize.xl,
|
||||
fontWeight: 500,
|
||||
paddingInline: 0,
|
||||
paddingBlock: 0,
|
||||
letterSpacing: "-0.025em",
|
||||
height: "30px",
|
||||
}}
|
||||
type="text"
|
||||
value={props.value}
|
||||
@@ -612,13 +694,13 @@ function DecimalInput(props: {
|
||||
}
|
||||
|
||||
function TokenSection(props: {
|
||||
label: string;
|
||||
notEnoughBalance: boolean;
|
||||
type: "buy" | "sell";
|
||||
amount: {
|
||||
data: string;
|
||||
isFetching: boolean;
|
||||
};
|
||||
setAmount: (amount: string) => void;
|
||||
activeWalletInfo: ActiveWalletInfo | undefined;
|
||||
selectedToken:
|
||||
| {
|
||||
data: TokenWithPrices | undefined;
|
||||
@@ -633,7 +715,10 @@ function TokenSection(props: {
|
||||
data: bigint | undefined;
|
||||
isFetching: boolean;
|
||||
};
|
||||
onWalletClick: () => void;
|
||||
onMaxClick: (() => void) | undefined;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
const chainQuery = useBridgeChains(props.client);
|
||||
const chain = chainQuery.data?.find(
|
||||
(chain) => chain.chainId === props.selectedToken?.data?.chainId,
|
||||
@@ -648,140 +733,191 @@ function TokenSection(props: {
|
||||
|
||||
return (
|
||||
<Container
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
borderColor="borderColor"
|
||||
bg="tertiaryBg"
|
||||
style={{ borderRadius: radius.lg, borderWidth: 1, borderStyle: "solid" }}
|
||||
>
|
||||
{/* row1 : label */}
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
{/* row2 : amount and select token */}
|
||||
<div
|
||||
{/* make the background semi-transparent */}
|
||||
<Container
|
||||
bg="tertiaryBg"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.5,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Container
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton
|
||||
height={fontSize.xxl}
|
||||
width="140px"
|
||||
style={{
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DecimalInput value={props.amount.data} setValue={props.setAmount} />
|
||||
)}
|
||||
{/* row1 : label */}
|
||||
<Container
|
||||
px="md"
|
||||
py="sm"
|
||||
relative
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Container flex="row" center="y" gap="3xs" color="secondaryText">
|
||||
<Text
|
||||
size="xs"
|
||||
color="primaryText"
|
||||
style={{
|
||||
letterSpacing: "0.07em",
|
||||
}}
|
||||
>
|
||||
{props.type === "buy" ? "BUY" : "SELL"}
|
||||
</Text>
|
||||
</Container>
|
||||
{props.activeWalletInfo && (
|
||||
<WalletButton
|
||||
variant="ghost-solid"
|
||||
style={{
|
||||
paddingInline: spacing.xxs,
|
||||
paddingBlock: "2px",
|
||||
}}
|
||||
onClick={props.onWalletClick}
|
||||
>
|
||||
<ActiveWalletDetails
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
client={props.client}
|
||||
/>
|
||||
</WalletButton>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{!props.selectedToken ? (
|
||||
<Button
|
||||
variant="accent"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xxs,
|
||||
paddingBlock: spacing.sm,
|
||||
paddingInline: spacing.sm,
|
||||
}}
|
||||
onClick={props.onSelectToken}
|
||||
>
|
||||
{" "}
|
||||
Select Token
|
||||
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
|
||||
</Button>
|
||||
) : (
|
||||
<Container
|
||||
bg="tertiaryBg"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: radius.xl,
|
||||
borderTop: `1px solid ${theme.colors.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<SelectedTokenButton
|
||||
selectedToken={props.selectedToken}
|
||||
client={props.client}
|
||||
onSelectToken={props.onSelectToken}
|
||||
chain={chain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* row3 : fiat value/error and balance */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* Exceeds Balance / Fiat Value */}
|
||||
{props.notEnoughBalance ? (
|
||||
<Text size="md" color="danger" weight={500}>
|
||||
{" "}
|
||||
Exceeds Balance{" "}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText" weight={500}>
|
||||
{getFiatSymbol(props.currency)}
|
||||
</Text>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<div>
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={totalFiatValue?.toFixed(5) || "0.00"}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
<Container px="md" py="md">
|
||||
<Container
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
style={{
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
{props.amount.isFetching ? (
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<Skeleton
|
||||
height="30px"
|
||||
width="140px"
|
||||
style={{
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DecimalInput
|
||||
value={props.amount.data}
|
||||
setValue={props.setAmount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Balance */}
|
||||
{props.isConnected && props.selectedToken && (
|
||||
<div>
|
||||
{props.balance.data === undefined ||
|
||||
props.selectedToken.data === undefined ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
{props.activeWalletInfo &&
|
||||
props.onMaxClick &&
|
||||
props.selectedToken && (
|
||||
<Button
|
||||
variant="outline"
|
||||
style={{
|
||||
paddingInline: spacing.xs,
|
||||
paddingBlock: spacing.xxs,
|
||||
borderRadius: radius.full,
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
onClick={props.onMaxClick}
|
||||
>
|
||||
Max
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
<Spacer y="sm" />
|
||||
|
||||
{/* row3 : fiat value and balance */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.xxs,
|
||||
gap: "3px",
|
||||
}}
|
||||
>
|
||||
<WalletDotIcon size={fontSize.xs} />
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
value={formatTokenAmount(
|
||||
props.balance.data,
|
||||
props.selectedToken.data.decimals,
|
||||
5,
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton height={fontSize.xs} width="50px" />
|
||||
) : (
|
||||
<Text size="xs" color="secondaryText">
|
||||
{formatCurrencyAmount(props.currency, totalFiatValue || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
{props.isConnected && props.selectedToken && (
|
||||
<div>
|
||||
{props.balance.data === undefined ||
|
||||
props.selectedToken.data === undefined ? (
|
||||
<Skeleton height={fontSize.xs} width="100px" />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" color="secondaryText">
|
||||
Balance:
|
||||
</Text>
|
||||
<Text size="xs" color="primaryText">
|
||||
{formatTokenAmount(
|
||||
props.balance.data,
|
||||
props.selectedToken.data.decimals,
|
||||
5,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -797,92 +933,141 @@ function SelectedTokenButton(props: {
|
||||
onSelectToken: () => void;
|
||||
chain: BridgeChain | undefined;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
bg="secondaryButtonBg"
|
||||
variant="ghost-solid"
|
||||
hoverBg="secondaryButtonBg"
|
||||
fullWidth
|
||||
onClick={props.onSelectToken}
|
||||
gap="xs"
|
||||
gap="sm"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
paddingBlock: spacing.xxs,
|
||||
paddingInline: spacing.xs,
|
||||
borderBottom: `1px dashed ${theme.colors.borderColor}`,
|
||||
justifyContent: "space-between",
|
||||
paddingInline: spacing.md,
|
||||
paddingBlock: spacing.md,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{/* icons */}
|
||||
<Container relative color="secondaryText">
|
||||
{/* token icon */}
|
||||
<Img
|
||||
key={props.selectedToken?.data?.iconUri}
|
||||
src={
|
||||
props.selectedToken?.data === undefined
|
||||
? undefined
|
||||
: props.selectedToken.data.iconUri || ""
|
||||
}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
skeletonColor="modalBg"
|
||||
fallback={<DiscIcon width={iconSize.lg} height={iconSize.lg} />}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
<Container gap="sm" flex="row" center="y">
|
||||
{/* icons */}
|
||||
<Container relative color="secondaryText">
|
||||
{/* token icon */}
|
||||
{props.selectedToken ? (
|
||||
<Img
|
||||
key={props.selectedToken?.data?.iconUri}
|
||||
src={
|
||||
props.selectedToken?.data === undefined
|
||||
? undefined
|
||||
: props.selectedToken.data.iconUri || ""
|
||||
}
|
||||
client={props.client}
|
||||
width="40"
|
||||
height="40"
|
||||
fallback={
|
||||
<Container
|
||||
style={{
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Container
|
||||
style={{
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* chain icon */}
|
||||
<Container
|
||||
bg="modalBg"
|
||||
style={{
|
||||
padding: "2px",
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
display: "flex",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={props.chain?.icon}
|
||||
client={props.client}
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
{/* chain icon */}
|
||||
{props.chain && (
|
||||
<Container
|
||||
bg="modalBg"
|
||||
style={{
|
||||
padding: "2px",
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
display: "flex",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={props.chain?.icon}
|
||||
client={props.client}
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
{/* token symbol and chain name */}
|
||||
<Container flex="column" style={{ gap: "2px" }}>
|
||||
{props.selectedToken?.isFetching ? (
|
||||
<Skeleton width="40px" height={fontSize.sm} color="modalBg" />
|
||||
) : (
|
||||
<Text size="sm" color="primaryText" weight={500}>
|
||||
{props.selectedToken?.data?.symbol}
|
||||
</Text>
|
||||
)}
|
||||
{/* token symbol and chain name */}
|
||||
{props.selectedToken ? (
|
||||
<Container flex="column" style={{ gap: "3px" }}>
|
||||
{props.selectedToken?.isFetching ? (
|
||||
<Skeleton width="60px" height={fontSize.md} />
|
||||
) : (
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.selectedToken?.data?.symbol}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{props.chain ? (
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Text>
|
||||
{props.chain ? (
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton width="140px" height={fontSize.sm} />
|
||||
)}
|
||||
</Container>
|
||||
) : (
|
||||
<Skeleton width="70px" height={fontSize.xs} color="modalBg" />
|
||||
<Container flex="column" style={{ gap: "3px" }}>
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
Select Token
|
||||
</Text>
|
||||
<Text size="xs" color="secondaryText">
|
||||
Required
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
<Container color="secondaryText">
|
||||
<ChevronRightIcon width={iconSize.xs} height={iconSize.xs} />
|
||||
<Container
|
||||
color="secondaryText"
|
||||
flex="row"
|
||||
center="both"
|
||||
borderColor="borderColor"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
padding: spacing.xs,
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
|
||||
</Container>
|
||||
</Button>
|
||||
);
|
||||
@@ -894,11 +1079,13 @@ function SwitchButton(props: { onClick: () => void }) {
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBlock: `-14px`,
|
||||
marginBlock: `-13px`,
|
||||
zIndex: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<SwitchButtonInner
|
||||
variant="outline"
|
||||
variant="ghost-solid"
|
||||
onClick={(e) => {
|
||||
props.onClick();
|
||||
const node = e.currentTarget.querySelector("svg");
|
||||
@@ -912,7 +1099,7 @@ function SwitchButton(props: { onClick: () => void }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUpDownIcon size={iconSize.md} />
|
||||
<ArrowUpDownIcon size={iconSize["sm+"]} />
|
||||
</SwitchButtonInner>
|
||||
</div>
|
||||
);
|
||||
@@ -922,10 +1109,11 @@ const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.modalBg,
|
||||
background: theme.colors.secondaryButtonBg,
|
||||
},
|
||||
borderRadius: radius.lg,
|
||||
borderRadius: radius.full,
|
||||
padding: spacing.xs,
|
||||
color: theme.colors.primaryText,
|
||||
background: theme.colors.modalBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
};
|
||||
@@ -948,3 +1136,78 @@ function useTokenBalance(props: {
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ActiveWalletDetails(props: {
|
||||
activeWalletInfo: ActiveWalletInfo;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const wallet = props.activeWalletInfo.activeWallet;
|
||||
const account = props.activeWalletInfo.activeAccount;
|
||||
|
||||
const accountBlobbie = (
|
||||
<AccountBlobbie
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const accountAvatarFallback = (
|
||||
<WalletIcon
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
}}
|
||||
fallbackComponent={accountBlobbie}
|
||||
loadingComponent={accountBlobbie}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<WalletProvider id={props.activeWalletInfo.activeWallet.id}>
|
||||
<AccountProvider address={account.address} client={props.client}>
|
||||
<WalletProvider id={wallet.id}>
|
||||
<Container flex="row" gap="xxs" center="y">
|
||||
<AccountAvatar
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
fallbackComponent={accountAvatarFallback}
|
||||
loadingComponent={accountAvatarFallback}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: fontSize.xs,
|
||||
letterSpacing: "0.025em",
|
||||
}}
|
||||
>
|
||||
<AccountName
|
||||
fallbackComponent={
|
||||
<span>{shortenAddress(account.address)}</span>
|
||||
}
|
||||
loadingComponent={
|
||||
<span>{shortenAddress(account.address)}</span>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Container>
|
||||
</WalletProvider>
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const WalletButton = /* @__PURE__ */ styled(Button)(() => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
color: theme.colors.secondaryText,
|
||||
transition: "color 200ms ease",
|
||||
"&:hover": {
|
||||
color: theme.colors.primaryText,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export function cleanedChainName(name: string) {
|
||||
return name.replace("Mainnet", "");
|
||||
}
|
||||
|
||||
export const tokenAmountFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 5,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
@@ -3,21 +3,21 @@ import type { IconFC } from "./types.js";
|
||||
export const ArrowUpDownIcon: IconFC = (props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="presentation"
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
>
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9.599 2.4v11.2l3.2-3.476M6.4 13.6V2.4L3.2 5.875"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export function Container(props: {
|
||||
expand?: boolean;
|
||||
center?: "x" | "y" | "both";
|
||||
gap?: keyof typeof spacing;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
p?: keyof typeof spacing;
|
||||
px?: keyof typeof spacing;
|
||||
|
||||
@@ -21,6 +21,7 @@ type ButtonProps = {
|
||||
fullWidth?: boolean;
|
||||
gap?: keyof typeof spacing;
|
||||
bg?: keyof Theme["colors"];
|
||||
hoverBg?: keyof Theme["colors"];
|
||||
};
|
||||
|
||||
export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
@@ -95,6 +96,9 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
transition: "border 200ms ease",
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
width: props.fullWidth ? "100%" : undefined,
|
||||
"&:hover": {
|
||||
background: props.hoverBg ? theme.colors[props.hoverBg] : undefined,
|
||||
},
|
||||
...(() => {
|
||||
if (props.variant === "outline") {
|
||||
return {
|
||||
@@ -120,7 +124,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
if (props.variant === "ghost-solid") {
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.tertiaryBg,
|
||||
background: theme.colors[props.hoverBg || "tertiaryBg"],
|
||||
},
|
||||
border: "1px solid transparent",
|
||||
};
|
||||
@@ -137,7 +141,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
if (props.variant === "secondary") {
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.secondaryButtonHoverBg,
|
||||
background: theme.colors[props.hoverBg || "secondaryButtonHoverBg"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
media,
|
||||
radius,
|
||||
spacing,
|
||||
type Theme,
|
||||
@@ -97,11 +98,14 @@ export const Input = /* @__PURE__ */ StyledInput<InputProps>((props) => {
|
||||
color: theme.colors.primaryText,
|
||||
display: "block",
|
||||
fontFamily: "inherit",
|
||||
fontSize: fontSize.md,
|
||||
fontSize: fontSize.sm,
|
||||
outline: "none",
|
||||
padding: props.sm ? spacing.sm : fontSize.sm,
|
||||
WebkitAppearance: "none",
|
||||
width: "100%",
|
||||
[media.mobile]: {
|
||||
fontSize: fontSize.md,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 640;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export function getRpcClient(
|
||||
inflight.request.id = index;
|
||||
// also assign the jsonrpc version
|
||||
inflight.request.jsonrpc = "2.0";
|
||||
// assing the request to the requests array (so we don't have to map it again later)
|
||||
// assign the request to the requests array (so we don't have to map it again later)
|
||||
requests[index] = inflight.request;
|
||||
return inflight;
|
||||
});
|
||||
|
||||
@@ -16,13 +16,14 @@ const meta = {
|
||||
} satisfies Meta<typeof SelectBridgeChain>;
|
||||
export default meta;
|
||||
|
||||
export function WithData() {
|
||||
export function WithDataDesktop() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChain
|
||||
isMobile={false}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
@@ -32,13 +33,50 @@ export function WithData() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
export function LoadingDesktop() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChainUI
|
||||
isMobile={false}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
isPending={true}
|
||||
chains={[]}
|
||||
selectedChain={selectedChain}
|
||||
/>
|
||||
</SwapWidgetContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithDataMobile() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChain
|
||||
isMobile={true}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
selectedChain={selectedChain}
|
||||
/>
|
||||
</SwapWidgetContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingMobile() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChainUI
|
||||
isMobile={true}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import { lightTheme } from "../../../react/core/design-system/index.js";
|
||||
import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js";
|
||||
import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js";
|
||||
import { storyClient } from "../../utils.js";
|
||||
|
||||
const meta: Meta<typeof SwapWidget> = {
|
||||
@@ -14,15 +13,6 @@ const meta: Meta<typeof SwapWidget> = {
|
||||
return (
|
||||
<div>
|
||||
<Story />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
}}
|
||||
>
|
||||
<ConnectButton client={storyClient} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -31,15 +21,28 @@ const meta: Meta<typeof SwapWidget> = {
|
||||
export default meta;
|
||||
|
||||
export function BasicUsage() {
|
||||
return <SwapWidget client={storyClient} />;
|
||||
return <SwapWidget client={storyClient} persistTokenSelections={false} />;
|
||||
}
|
||||
|
||||
export function CurrencySet() {
|
||||
return <SwapWidget client={storyClient} currency="JPY" />;
|
||||
return (
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightMode() {
|
||||
return <SwapWidget client={storyClient} currency="JPY" theme="light" />;
|
||||
return (
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
theme="light"
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoThirdwebBranding() {
|
||||
@@ -48,6 +51,7 @@ export function NoThirdwebBranding() {
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
showThirdwebBranding={false}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +61,7 @@ export function CustomTheme() {
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
persistTokenSelections={false}
|
||||
theme={lightTheme({
|
||||
colors: {
|
||||
modalBg: "#FFFFF0",
|
||||
|
||||
@@ -48,7 +48,7 @@ import type { EcosystemWallet } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Connect to a restricted ecosystem wallet with your designated partner ID
|
||||
*
|
||||
* The parnter ID will be provided to you by the ecosystem with which you're integrating.
|
||||
* The partner ID will be provided to you by the ecosystem with which you're integrating.
|
||||
*
|
||||
* ```ts
|
||||
* import { ecosystemWallet } from "thirdweb/wallets";
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
type ERC20TokenAmount,
|
||||
type Money,
|
||||
moneySchema,
|
||||
type Network,
|
||||
SupportedEVMNetworks,
|
||||
} from "x402/types";
|
||||
import { getAddress } from "../utils/address.js";
|
||||
import { decodePayment } from "./encode.js";
|
||||
import type { facilitator as facilitatorType } from "./facilitator.js";
|
||||
import {
|
||||
type FacilitatorNetwork,
|
||||
networkToChainId,
|
||||
type RequestedPaymentPayload,
|
||||
type RequestedPaymentRequirements,
|
||||
} from "./schemas.js";
|
||||
import {
|
||||
type PaymentArgs,
|
||||
type PaymentRequiredResult,
|
||||
x402Version,
|
||||
} from "./types.js";
|
||||
|
||||
type GetPaymentRequirementsResult = {
|
||||
status: 200;
|
||||
paymentRequirements: RequestedPaymentRequirements[];
|
||||
selectedPaymentRequirements: RequestedPaymentRequirements;
|
||||
decodedPayment: RequestedPaymentPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a payment request and returns the payment requirements, selected payment requirements, and decoded payment
|
||||
* @param args
|
||||
* @returns The payment requirements, selected payment requirements, and decoded payment
|
||||
*/
|
||||
export async function decodePaymentRequest(
|
||||
args: PaymentArgs,
|
||||
): Promise<GetPaymentRequirementsResult | PaymentRequiredResult> {
|
||||
const {
|
||||
price,
|
||||
network,
|
||||
facilitator,
|
||||
resourceUrl,
|
||||
routeConfig = {},
|
||||
payTo,
|
||||
method,
|
||||
paymentData,
|
||||
} = args;
|
||||
const {
|
||||
description,
|
||||
mimeType,
|
||||
maxTimeoutSeconds,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
errorMessages,
|
||||
discoverable,
|
||||
} = routeConfig;
|
||||
const atomicAmountForAsset = await processPriceToAtomicAmount(
|
||||
price,
|
||||
network,
|
||||
facilitator,
|
||||
);
|
||||
if ("error" in atomicAmountForAsset) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: { "Content-Type": "application/json" },
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error: atomicAmountForAsset.error,
|
||||
accepts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
const { maxAmountRequired, asset } = atomicAmountForAsset;
|
||||
|
||||
const paymentRequirements: RequestedPaymentRequirements[] = [];
|
||||
|
||||
if (
|
||||
SupportedEVMNetworks.includes(network as Network) ||
|
||||
network.startsWith("eip155:")
|
||||
) {
|
||||
paymentRequirements.push({
|
||||
scheme: "exact",
|
||||
network,
|
||||
maxAmountRequired,
|
||||
resource: resourceUrl,
|
||||
description: description ?? "",
|
||||
mimeType: mimeType ?? "application/json",
|
||||
payTo: getAddress(payTo),
|
||||
maxTimeoutSeconds: maxTimeoutSeconds ?? 300,
|
||||
asset: getAddress(asset.address),
|
||||
// TODO: Rename outputSchema to requestStructure
|
||||
outputSchema: {
|
||||
input: {
|
||||
type: "http",
|
||||
method,
|
||||
discoverable: discoverable ?? true,
|
||||
...inputSchema,
|
||||
},
|
||||
output: outputSchema,
|
||||
},
|
||||
extra: (asset as ERC20TokenAmount["asset"]).eip712,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error: `Unsupported network: ${network}`,
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check for payment header
|
||||
if (!paymentData) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error: errorMessages?.paymentRequired || "X-PAYMENT header is required",
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify payment
|
||||
let decodedPayment: RequestedPaymentPayload;
|
||||
try {
|
||||
decodedPayment = decodePayment(paymentData);
|
||||
decodedPayment.x402Version = x402Version;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.invalidPayment ||
|
||||
(error instanceof Error ? error.message : "Invalid payment"),
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const selectedPaymentRequirements = paymentRequirements.find(
|
||||
(value) =>
|
||||
value.scheme === decodedPayment.scheme &&
|
||||
value.network === decodedPayment.network,
|
||||
);
|
||||
if (!selectedPaymentRequirements) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.noMatchingRequirements ||
|
||||
"Unable to find matching payment requirements",
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
paymentRequirements,
|
||||
decodedPayment,
|
||||
selectedPaymentRequirements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the amount from the given price
|
||||
*
|
||||
* @param price - The price to parse
|
||||
* @param network - The network to get the default asset for
|
||||
* @returns The parsed amount or an error message
|
||||
*/
|
||||
async function processPriceToAtomicAmount(
|
||||
price: Money | ERC20TokenAmount,
|
||||
network: FacilitatorNetwork,
|
||||
facilitator: ReturnType<typeof facilitatorType>,
|
||||
): Promise<
|
||||
| { maxAmountRequired: string; asset: ERC20TokenAmount["asset"] }
|
||||
| { error: string }
|
||||
> {
|
||||
// Handle USDC amount (string) or token amount (ERC20TokenAmount)
|
||||
let maxAmountRequired: string;
|
||||
let asset: ERC20TokenAmount["asset"];
|
||||
|
||||
if (typeof price === "string" || typeof price === "number") {
|
||||
// USDC amount in dollars
|
||||
const parsedAmount = moneySchema.safeParse(price);
|
||||
if (!parsedAmount.success) {
|
||||
return {
|
||||
error: `Invalid price (price: ${price}). Must be in the form "$3.10", 0.10, "0.001", ${parsedAmount.error}`,
|
||||
};
|
||||
}
|
||||
const parsedUsdAmount = parsedAmount.data;
|
||||
const defaultAsset = await getDefaultAsset(network, facilitator);
|
||||
if (!defaultAsset) {
|
||||
return {
|
||||
error: `Unable to get default asset on ${network}. Please specify an asset in the payment requirements.`,
|
||||
};
|
||||
}
|
||||
asset = defaultAsset;
|
||||
maxAmountRequired = (parsedUsdAmount * 10 ** asset.decimals).toString();
|
||||
} else {
|
||||
// Token amount in atomic units
|
||||
maxAmountRequired = price.amount;
|
||||
asset = price.asset;
|
||||
}
|
||||
|
||||
return {
|
||||
maxAmountRequired,
|
||||
asset,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDefaultAsset(
|
||||
network: FacilitatorNetwork,
|
||||
facilitator: ReturnType<typeof facilitatorType>,
|
||||
): Promise<ERC20TokenAmount["asset"] | undefined> {
|
||||
const supportedAssets = await facilitator.supported();
|
||||
const chainId = networkToChainId(network);
|
||||
const matchingAsset = supportedAssets.kinds.find(
|
||||
(supported) => supported.network === `eip155:${chainId}`,
|
||||
);
|
||||
const assetConfig = matchingAsset?.extra
|
||||
?.defaultAsset as ERC20TokenAmount["asset"];
|
||||
return assetConfig;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ExactEvmPayload } from "x402/types";
|
||||
import {
|
||||
type RequestedPaymentPayload,
|
||||
RequestedPaymentPayloadSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
/**
|
||||
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
|
||||
*
|
||||
* @param payment - The payment payload to encode
|
||||
* @returns A base64 encoded string representation of the payment payload
|
||||
*/
|
||||
export function encodePayment(payment: RequestedPaymentPayload): string {
|
||||
let safe: RequestedPaymentPayload;
|
||||
|
||||
// evm
|
||||
const evmPayload = payment.payload as ExactEvmPayload;
|
||||
safe = {
|
||||
...payment,
|
||||
payload: {
|
||||
...evmPayload,
|
||||
authorization: Object.fromEntries(
|
||||
Object.entries(evmPayload.authorization).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "bigint" ? (value as bigint).toString() : value,
|
||||
]),
|
||||
) as ExactEvmPayload["authorization"],
|
||||
},
|
||||
};
|
||||
return safeBase64Encode(JSON.stringify(safe));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 encoded payment string back into a PaymentPayload object
|
||||
*
|
||||
* @param payment - The base64 encoded payment string to decode
|
||||
* @returns The decoded and validated PaymentPayload object
|
||||
*/
|
||||
export function decodePayment(payment: string): RequestedPaymentPayload {
|
||||
const decoded = safeBase64Decode(payment);
|
||||
const parsed = JSON.parse(decoded);
|
||||
|
||||
const obj: RequestedPaymentPayload = {
|
||||
...parsed,
|
||||
payload: parsed.payload as ExactEvmPayload,
|
||||
};
|
||||
const validated = RequestedPaymentPayloadSchema.parse(obj);
|
||||
return validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string to base64 format
|
||||
*
|
||||
* @param data - The string to be encoded to base64
|
||||
* @returns The base64 encoded string
|
||||
*/
|
||||
export function safeBase64Encode(data: string): string {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.btoa === "function"
|
||||
) {
|
||||
return globalThis.btoa(data);
|
||||
}
|
||||
return Buffer.from(data).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string back to its original format
|
||||
*
|
||||
* @param data - The base64 encoded string to be decoded
|
||||
* @returns The decoded string in UTF-8 format
|
||||
*/
|
||||
function safeBase64Decode(data: string): string {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.atob === "function"
|
||||
) {
|
||||
return globalThis.atob(data);
|
||||
}
|
||||
return Buffer.from(data, "base64").toString("utf-8");
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { FacilitatorConfig } from "x402/types";
|
||||
import type { SupportedPaymentKindsResponse, VerifyResponse } from "x402/types";
|
||||
import type { ThirdwebClient } from "../client/client.js";
|
||||
import { stringify } from "../utils/json.js";
|
||||
import { withCache } from "../utils/promise/withCache.js";
|
||||
import type {
|
||||
FacilitatorSettleResponse,
|
||||
RequestedPaymentPayload,
|
||||
RequestedPaymentRequirements,
|
||||
} from "./schemas.js";
|
||||
|
||||
export type ThirdwebX402FacilitatorConfig = {
|
||||
client: ThirdwebClient;
|
||||
@@ -12,7 +19,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
|
||||
|
||||
/**
|
||||
* Creates a facilitator for the x402 payment protocol.
|
||||
* Use this with any x402 middleware to enable settling transactions with your thirdweb server wallet.
|
||||
* You can use this with `settlePayment` or with any x402 middleware to enable settling transactions with your thirdweb server wallet.
|
||||
*
|
||||
* @param config - The configuration for the facilitator
|
||||
* @returns a x402 compatible FacilitatorConfig
|
||||
@@ -48,9 +55,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
|
||||
*
|
||||
* @bridge x402
|
||||
*/
|
||||
export function facilitator(
|
||||
config: ThirdwebX402FacilitatorConfig,
|
||||
): FacilitatorConfig {
|
||||
export function facilitator(config: ThirdwebX402FacilitatorConfig) {
|
||||
const secretKey = config.client.secretKey;
|
||||
if (!secretKey) {
|
||||
throw new Error("Client secret key is required for the x402 facilitator");
|
||||
@@ -61,7 +66,7 @@ export function facilitator(
|
||||
"Server wallet address is required for the x402 facilitator",
|
||||
);
|
||||
}
|
||||
return {
|
||||
const facilitator = {
|
||||
url: (config.baseUrl ?? DEFAULT_BASE_URL) as `${string}://${string}`,
|
||||
createAuthHeaders: async () => {
|
||||
return {
|
||||
@@ -83,5 +88,108 @@ export function facilitator(
|
||||
},
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Verifies a payment payload with the facilitator service
|
||||
*
|
||||
* @param payload - The payment payload to verify
|
||||
* @param paymentRequirements - The payment requirements to verify against
|
||||
* @returns A promise that resolves to the verification response
|
||||
*/
|
||||
async verify(
|
||||
payload: RequestedPaymentPayload,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
): Promise<VerifyResponse> {
|
||||
const url = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
|
||||
let headers = { "Content-Type": "application/json" };
|
||||
const authHeaders = await facilitator.createAuthHeaders();
|
||||
headers = { ...headers, ...authHeaders.verify };
|
||||
|
||||
const res = await fetch(`${url}/verify`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: stringify({
|
||||
x402Version: payload.x402Version,
|
||||
paymentPayload: payload,
|
||||
paymentRequirements: paymentRequirements,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
const text = `${res.statusText} ${await res.text()}`;
|
||||
throw new Error(`Failed to verify payment: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data as VerifyResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Settles a payment with the facilitator service
|
||||
*
|
||||
* @param payload - The payment payload to settle
|
||||
* @param paymentRequirements - The payment requirements for the settlement
|
||||
* @returns A promise that resolves to the settlement response
|
||||
*/
|
||||
async settle(
|
||||
payload: RequestedPaymentPayload,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
): Promise<FacilitatorSettleResponse> {
|
||||
const url = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
|
||||
let headers = { "Content-Type": "application/json" };
|
||||
const authHeaders = await facilitator.createAuthHeaders();
|
||||
headers = { ...headers, ...authHeaders.settle };
|
||||
|
||||
const res = await fetch(`${url}/settle`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
x402Version: payload.x402Version,
|
||||
paymentPayload: payload,
|
||||
paymentRequirements: paymentRequirements,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
const text = `${res.statusText} ${await res.text()}`;
|
||||
throw new Error(`Failed to settle payment: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data as FacilitatorSettleResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the supported payment kinds from the facilitator service.
|
||||
*
|
||||
* @returns A promise that resolves to the supported payment kinds
|
||||
*/
|
||||
async supported(): Promise<SupportedPaymentKindsResponse> {
|
||||
const url = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
return withCache(
|
||||
async () => {
|
||||
let headers = { "Content-Type": "application/json" };
|
||||
const authHeaders = await facilitator.createAuthHeaders();
|
||||
headers = { ...headers, ...authHeaders.supported };
|
||||
const res = await fetch(`${url}/supported`, { headers });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to get supported payment kinds: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data as SupportedPaymentKindsResponse;
|
||||
},
|
||||
{
|
||||
cacheKey: `supported-payment-kinds-${url}`,
|
||||
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return facilitator;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { createPaymentHeader } from "x402/client";
|
||||
import {
|
||||
ChainIdToNetwork,
|
||||
EvmNetworkToChainId,
|
||||
type PaymentRequirements,
|
||||
PaymentRequirementsSchema,
|
||||
type Signer,
|
||||
} from "x402/types";
|
||||
import { viemAdapter } from "../adapters/viem.js";
|
||||
import { ChainIdToNetwork } from "x402/types";
|
||||
import { getCachedChain } from "../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../client/client.js";
|
||||
import type { Wallet } from "../wallets/interfaces/wallet.js";
|
||||
import {
|
||||
networkToChainId,
|
||||
type RequestedPaymentRequirements,
|
||||
RequestedPaymentRequirementsSchema,
|
||||
} from "./schemas.js";
|
||||
import { createPaymentHeader } from "./sign.js";
|
||||
|
||||
/**
|
||||
* Enables the payment of APIs using the x402 payment protocol.
|
||||
@@ -52,7 +50,7 @@ import type { Wallet } from "../wallets/interfaces/wallet.js";
|
||||
*/
|
||||
export function wrapFetchWithPayment(
|
||||
fetch: typeof globalThis.fetch,
|
||||
client: ThirdwebClient,
|
||||
_client: ThirdwebClient,
|
||||
wallet: Wallet,
|
||||
maxValue: bigint = BigInt(1 * 10 ** 6), // Default to 1 USDC
|
||||
) {
|
||||
@@ -68,7 +66,7 @@ export function wrapFetchWithPayment(
|
||||
accepts: unknown[];
|
||||
};
|
||||
const parsedPaymentRequirements = accepts
|
||||
.map((x) => PaymentRequirementsSchema.parse(x))
|
||||
.map((x) => RequestedPaymentRequirementsSchema.parse(x))
|
||||
.filter((x) => x.scheme === "exact"); // TODO (402): accept other schemes
|
||||
|
||||
const account = wallet.getAccount();
|
||||
@@ -89,16 +87,10 @@ export function wrapFetchWithPayment(
|
||||
throw new Error("Payment amount exceeds maximum allowed");
|
||||
}
|
||||
|
||||
const paymentChainId = EvmNetworkToChainId.get(
|
||||
const paymentChainId = networkToChainId(
|
||||
selectedPaymentRequirements.network,
|
||||
);
|
||||
|
||||
if (!paymentChainId) {
|
||||
throw new Error(
|
||||
`No chain found for the selected payment requirement: ${selectedPaymentRequirements.network}`,
|
||||
);
|
||||
}
|
||||
|
||||
// switch to the payment chain if it's not the current chain
|
||||
if (paymentChainId !== chain.id) {
|
||||
await wallet.switchChain(getCachedChain(paymentChainId));
|
||||
@@ -108,14 +100,8 @@ export function wrapFetchWithPayment(
|
||||
}
|
||||
}
|
||||
|
||||
const walletClient = viemAdapter.wallet.toViem({
|
||||
wallet: wallet,
|
||||
chain,
|
||||
client,
|
||||
}) as Signer;
|
||||
|
||||
const paymentHeader = await createPaymentHeader(
|
||||
walletClient,
|
||||
account,
|
||||
x402Version,
|
||||
selectedPaymentRequirements,
|
||||
);
|
||||
@@ -142,7 +128,7 @@ export function wrapFetchWithPayment(
|
||||
}
|
||||
|
||||
function defaultPaymentRequirementsSelector(
|
||||
paymentRequirements: PaymentRequirements[],
|
||||
paymentRequirements: RequestedPaymentRequirements[],
|
||||
chainId: number,
|
||||
scheme: "exact",
|
||||
) {
|
||||
@@ -151,7 +137,7 @@ function defaultPaymentRequirementsSelector(
|
||||
"No valid payment requirements found in server 402 response",
|
||||
);
|
||||
}
|
||||
const currentWalletNetwork = ChainIdToNetwork[chainId];
|
||||
const currentWalletNetwork = ChainIdToNetwork[chainId] || `eip155:${chainId}`;
|
||||
// find the payment requirements matching the connected wallet chain
|
||||
const matchingPaymentRequirements = paymentRequirements.find(
|
||||
(x) => x.network === currentWalletNetwork && x.scheme === scheme,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
EvmNetworkToChainId,
|
||||
type ExactEvmPayload,
|
||||
type Network,
|
||||
PaymentPayloadSchema,
|
||||
PaymentRequirementsSchema,
|
||||
SettleResponseSchema,
|
||||
} from "x402/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const FacilitatorNetworkSchema = z.union([
|
||||
z.literal("base-sepolia"),
|
||||
z.literal("base"),
|
||||
z.literal("avalanche-fuji"),
|
||||
z.literal("avalanche"),
|
||||
z.literal("iotex"),
|
||||
z.literal("solana-devnet"),
|
||||
z.literal("solana"),
|
||||
z.literal("sei"),
|
||||
z.literal("sei-testnet"),
|
||||
z.string().refine((value) => value.startsWith("eip155:"), {
|
||||
message: "Invalid network",
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FacilitatorNetwork = z.infer<typeof FacilitatorNetworkSchema>;
|
||||
|
||||
export const RequestedPaymentPayloadSchema = PaymentPayloadSchema.extend({
|
||||
network: FacilitatorNetworkSchema,
|
||||
});
|
||||
|
||||
export type RequestedPaymentPayload = z.infer<
|
||||
typeof RequestedPaymentPayloadSchema
|
||||
>;
|
||||
export type UnsignedPaymentPayload = Omit<
|
||||
RequestedPaymentPayload,
|
||||
"payload"
|
||||
> & {
|
||||
payload: Omit<ExactEvmPayload, "signature"> & { signature: undefined };
|
||||
};
|
||||
|
||||
export const RequestedPaymentRequirementsSchema =
|
||||
PaymentRequirementsSchema.extend({
|
||||
network: FacilitatorNetworkSchema,
|
||||
});
|
||||
|
||||
export type RequestedPaymentRequirements = z.infer<
|
||||
typeof RequestedPaymentRequirementsSchema
|
||||
>;
|
||||
|
||||
const FacilitatorSettleResponseSchema = SettleResponseSchema.extend({
|
||||
network: FacilitatorNetworkSchema,
|
||||
});
|
||||
export type FacilitatorSettleResponse = z.infer<
|
||||
typeof FacilitatorSettleResponseSchema
|
||||
>;
|
||||
|
||||
export function networkToChainId(network: string): number {
|
||||
if (network.startsWith("eip155:")) {
|
||||
const chainId = parseInt(network.split(":")[1] ?? "0");
|
||||
if (!Number.isNaN(chainId) && chainId > 0) {
|
||||
return chainId;
|
||||
} else {
|
||||
throw new Error(`Invalid network: ${network}`);
|
||||
}
|
||||
}
|
||||
const mappedChainId = EvmNetworkToChainId.get(network as Network);
|
||||
if (!mappedChainId) {
|
||||
throw new Error(`Invalid network: ${network}`);
|
||||
}
|
||||
// TODO (402): support solana networks
|
||||
if (mappedChainId === 101 || mappedChainId === 103) {
|
||||
throw new Error("Solana networks not supported yet.");
|
||||
}
|
||||
return mappedChainId;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { stringify } from "../utils/json.js";
|
||||
import { decodePaymentRequest } from "./common.js";
|
||||
import { safeBase64Encode } from "./encode.js";
|
||||
import {
|
||||
type PaymentArgs,
|
||||
type SettlePaymentResult,
|
||||
x402Version,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Verifies and processes X402 payments for protected resources.
|
||||
*
|
||||
* This function implements the X402 payment protocol, verifying payment proofs
|
||||
* and settling payments through a facilitator service. It handles the complete
|
||||
* payment flow from validation to settlement.
|
||||
*
|
||||
* @param args - Configuration object containing payment verification parameters
|
||||
* @returns A promise that resolves to either a successful payment result (200) or payment required error (402)
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ### Next.js API route example
|
||||
*
|
||||
* ```ts
|
||||
* // Usage in a Next.js API route
|
||||
* import { settlePayment, facilitator } from "thirdweb/x402";
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
*
|
||||
* const client = createThirdwebClient({
|
||||
* secretKey: process.env.THIRDWEB_SECRET_KEY,
|
||||
* });
|
||||
*
|
||||
* const thirdwebFacilitator = facilitator({
|
||||
* client,
|
||||
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
* });
|
||||
*
|
||||
* export async function GET(request: Request) {
|
||||
* const paymentData = request.headers.get("x-payment");
|
||||
*
|
||||
* // verify and process the payment
|
||||
* const result = await settlePayment({
|
||||
* resourceUrl: "https://api.example.com/premium-content",
|
||||
* method: "GET",
|
||||
* paymentData,
|
||||
* payTo: "0x1234567890123456789012345678901234567890",
|
||||
* network: "eip155:84532", // CAIP2 format: "eip155:<chain_id>"
|
||||
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
|
||||
* facilitator: thirdwebFacilitator,
|
||||
* routeConfig: {
|
||||
* description: "Access to premium API content",
|
||||
* mimeType: "application/json",
|
||||
* maxTimeoutSeconds: 300,
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* if (result.status === 200) {
|
||||
* // Payment verified and settled successfully
|
||||
* return Response.json({ data: "premium content" });
|
||||
* } else {
|
||||
* // Payment required
|
||||
* return Response.json(result.responseBody, {
|
||||
* status: result.status,
|
||||
* headers: result.responseHeaders,
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Express middleware example
|
||||
*
|
||||
* ```ts
|
||||
* // Usage in Express middleware
|
||||
* import express from "express";
|
||||
* import { settlePayment, facilitator } from "thirdweb/x402";
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
*
|
||||
* const client = createThirdwebClient({
|
||||
* secretKey: process.env.THIRDWEB_SECRET_KEY,
|
||||
* });
|
||||
*
|
||||
* const thirdwebFacilitator = facilitator({
|
||||
* client,
|
||||
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
* });
|
||||
*
|
||||
* const app = express();
|
||||
*
|
||||
* async function paymentMiddleware(req, res, next) {
|
||||
* // verify and process the payment
|
||||
* const result = await settlePayment({
|
||||
* resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
|
||||
* method: req.method,
|
||||
* paymentData: req.headers["x-payment"],
|
||||
* payTo: "0x1234567890123456789012345678901234567890",
|
||||
* network: "eip155:8453", // CAIP2 format: "eip155:<chain_id>"
|
||||
* price: "$0.05",
|
||||
* facilitator: thirdwebFacilitator,
|
||||
* });
|
||||
*
|
||||
* if (result.status === 200) {
|
||||
* // Set payment receipt headers and continue
|
||||
* Object.entries(result.responseHeaders).forEach(([key, value]) => {
|
||||
* res.setHeader(key, value);
|
||||
* });
|
||||
* next();
|
||||
* } else {
|
||||
* // Return payment required response
|
||||
* res.status(result.status)
|
||||
* .set(result.responseHeaders)
|
||||
* .json(result.responseBody);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* app.get("/api/premium", paymentMiddleware, (req, res) => {
|
||||
* res.json({ message: "This is premium content!" });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
* @beta
|
||||
* @bridge x402
|
||||
*/
|
||||
export async function settlePayment(
|
||||
args: PaymentArgs,
|
||||
): Promise<SettlePaymentResult> {
|
||||
const { routeConfig = {}, facilitator } = args;
|
||||
const { errorMessages } = routeConfig;
|
||||
|
||||
const decodePaymentResult = await decodePaymentRequest(args);
|
||||
|
||||
if (decodePaymentResult.status !== 200) {
|
||||
return decodePaymentResult;
|
||||
}
|
||||
|
||||
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
|
||||
decodePaymentResult;
|
||||
|
||||
// Settle payment
|
||||
try {
|
||||
const settlement = await facilitator.settle(
|
||||
decodedPayment,
|
||||
selectedPaymentRequirements,
|
||||
);
|
||||
|
||||
if (settlement.success) {
|
||||
return {
|
||||
status: 200,
|
||||
paymentReceipt: settlement,
|
||||
responseHeaders: {
|
||||
"Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE",
|
||||
"X-PAYMENT-RESPONSE": safeBase64Encode(stringify(settlement)),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.settlementFailed ||
|
||||
settlement.errorReason ||
|
||||
"Settlement failed",
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.settlementFailed ||
|
||||
(error instanceof Error ? error.message : "Settlement error"),
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ExactEvmPayloadAuthorization } from "x402/types";
|
||||
import { type Address, getAddress } from "../utils/address.js";
|
||||
import { type Hex, toHex } from "../utils/encoding/hex.js";
|
||||
import type { Account } from "../wallets/interfaces/wallet.js";
|
||||
import { encodePayment } from "./encode.js";
|
||||
import {
|
||||
networkToChainId,
|
||||
type RequestedPaymentPayload,
|
||||
type RequestedPaymentRequirements,
|
||||
type UnsignedPaymentPayload,
|
||||
} from "./schemas.js";
|
||||
|
||||
/**
|
||||
* Prepares an unsigned payment header with the given sender address and payment requirements.
|
||||
*
|
||||
* @param from - The sender's address from which the payment will be made
|
||||
* @param x402Version - The version of the X402 protocol to use
|
||||
* @param paymentRequirements - The payment requirements containing scheme and network information
|
||||
* @returns An unsigned payment payload containing authorization details
|
||||
*/
|
||||
function preparePaymentHeader(
|
||||
from: Address,
|
||||
x402Version: number,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
): UnsignedPaymentPayload {
|
||||
const nonce = createNonce();
|
||||
|
||||
const validAfter = BigInt(
|
||||
Math.floor(Date.now() / 1000) - 600, // 10 minutes before
|
||||
).toString();
|
||||
const validBefore = BigInt(
|
||||
Math.floor(Date.now() / 1000 + paymentRequirements.maxTimeoutSeconds),
|
||||
).toString();
|
||||
|
||||
return {
|
||||
x402Version,
|
||||
scheme: paymentRequirements.scheme,
|
||||
network: paymentRequirements.network,
|
||||
payload: {
|
||||
signature: undefined,
|
||||
authorization: {
|
||||
from,
|
||||
to: paymentRequirements.payTo as Address,
|
||||
value: paymentRequirements.maxAmountRequired,
|
||||
validAfter: validAfter.toString(),
|
||||
validBefore: validBefore.toString(),
|
||||
nonce,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a payment header using the provided client and payment requirements.
|
||||
*
|
||||
* @param client - The signer wallet instance used to sign the payment header
|
||||
* @param paymentRequirements - The payment requirements containing scheme and network information
|
||||
* @param unsignedPaymentHeader - The unsigned payment payload to be signed
|
||||
* @returns A promise that resolves to the signed payment payload
|
||||
*/
|
||||
async function signPaymentHeader(
|
||||
account: Account,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
unsignedPaymentHeader: UnsignedPaymentPayload,
|
||||
): Promise<RequestedPaymentPayload> {
|
||||
const { signature } = await signAuthorization(
|
||||
account,
|
||||
unsignedPaymentHeader.payload.authorization,
|
||||
paymentRequirements,
|
||||
);
|
||||
|
||||
return {
|
||||
...unsignedPaymentHeader,
|
||||
payload: {
|
||||
...unsignedPaymentHeader.payload,
|
||||
signature,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete payment payload by preparing and signing a payment header.
|
||||
*
|
||||
* @param client - The signer wallet instance used to create and sign the payment
|
||||
* @param x402Version - The version of the X402 protocol to use
|
||||
* @param paymentRequirements - The payment requirements containing scheme and network information
|
||||
* @returns A promise that resolves to the complete signed payment payload
|
||||
*/
|
||||
async function createPayment(
|
||||
account: Account,
|
||||
x402Version: number,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
): Promise<RequestedPaymentPayload> {
|
||||
const from = getAddress(account.address);
|
||||
const unsignedPaymentHeader = preparePaymentHeader(
|
||||
from,
|
||||
x402Version,
|
||||
paymentRequirements,
|
||||
);
|
||||
return signPaymentHeader(account, paymentRequirements, unsignedPaymentHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and encodes a payment header for the given client and payment requirements.
|
||||
*
|
||||
* @param client - The signer wallet instance used to create the payment header
|
||||
* @param x402Version - The version of the X402 protocol to use
|
||||
* @param paymentRequirements - The payment requirements containing scheme and network information
|
||||
* @returns A promise that resolves to the encoded payment header string
|
||||
*/
|
||||
export async function createPaymentHeader(
|
||||
account: Account,
|
||||
x402Version: number,
|
||||
paymentRequirements: RequestedPaymentRequirements,
|
||||
): Promise<string> {
|
||||
const payment = await createPayment(
|
||||
account,
|
||||
x402Version,
|
||||
paymentRequirements,
|
||||
);
|
||||
return encodePayment(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an EIP-3009 authorization for USDC transfer
|
||||
*
|
||||
* @param walletClient - The wallet client that will sign the authorization
|
||||
* @param params - The authorization parameters containing transfer details
|
||||
* @param params.from - The address tokens will be transferred from
|
||||
* @param params.to - The address tokens will be transferred to
|
||||
* @param params.value - The amount of USDC tokens to transfer (in base units)
|
||||
* @param params.validAfter - Unix timestamp after which the authorization becomes valid
|
||||
* @param params.validBefore - Unix timestamp before which the authorization is valid
|
||||
* @param params.nonce - Random 32-byte nonce to prevent replay attacks
|
||||
* @param paymentRequirements - The payment requirements containing asset and network information
|
||||
* @param paymentRequirements.asset - The address of the USDC contract
|
||||
* @param paymentRequirements.network - The network where the USDC contract exists
|
||||
* @param paymentRequirements.extra - The extra information containing the name and version of the ERC20 contract
|
||||
* @returns The signature for the authorization
|
||||
*/
|
||||
async function signAuthorization(
|
||||
account: Account,
|
||||
{
|
||||
from,
|
||||
to,
|
||||
value,
|
||||
validAfter,
|
||||
validBefore,
|
||||
nonce,
|
||||
}: ExactEvmPayloadAuthorization,
|
||||
{ asset, network, extra }: RequestedPaymentRequirements,
|
||||
): Promise<{ signature: Hex }> {
|
||||
const chainId = networkToChainId(network);
|
||||
const name = extra?.name;
|
||||
const version = extra?.version;
|
||||
|
||||
// TODO (402): detect permit vs transfer on asset contract
|
||||
const data = {
|
||||
types: {
|
||||
TransferWithAuthorization: [
|
||||
{ name: "from", type: "address" },
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "validAfter", type: "uint256" },
|
||||
{ name: "validBefore", type: "uint256" },
|
||||
{ name: "nonce", type: "bytes32" },
|
||||
],
|
||||
},
|
||||
domain: {
|
||||
name,
|
||||
version,
|
||||
chainId,
|
||||
verifyingContract: getAddress(asset),
|
||||
},
|
||||
primaryType: "TransferWithAuthorization" as const,
|
||||
message: {
|
||||
from: getAddress(from),
|
||||
to: getAddress(to),
|
||||
value,
|
||||
validAfter,
|
||||
validBefore,
|
||||
nonce: nonce,
|
||||
},
|
||||
};
|
||||
|
||||
const signature = await account.signTypedData(data);
|
||||
return {
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random 32-byte nonce for use in authorization signatures
|
||||
*
|
||||
* @returns A random 32-byte nonce as a hex string
|
||||
*/
|
||||
function createNonce(): Hex {
|
||||
const cryptoObj =
|
||||
typeof globalThis.crypto !== "undefined" &&
|
||||
typeof globalThis.crypto.getRandomValues === "function"
|
||||
? globalThis.crypto
|
||||
: // Dynamic require is needed to support node.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("crypto").webcrypto;
|
||||
return toHex(cryptoObj.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
ERC20TokenAmount,
|
||||
Money,
|
||||
PaymentMiddlewareConfig,
|
||||
} from "x402/types";
|
||||
import type { Address } from "../utils/address.js";
|
||||
import type { Prettify } from "../utils/type-utils.js";
|
||||
import type { facilitator as facilitatorType } from "./facilitator.js";
|
||||
import type {
|
||||
FacilitatorNetwork,
|
||||
FacilitatorSettleResponse,
|
||||
RequestedPaymentPayload,
|
||||
RequestedPaymentRequirements,
|
||||
} from "./schemas.js";
|
||||
|
||||
export const x402Version = 1;
|
||||
|
||||
/**
|
||||
* Configuration object for verifying or processing X402 payments.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PaymentArgs = {
|
||||
/** The URL of the resource being protected by the payment */
|
||||
resourceUrl: string;
|
||||
/** The HTTP method used to access the resource */
|
||||
method: "GET" | "POST" | ({} & string);
|
||||
/** The payment data/proof provided by the client, typically from the X-PAYMENT header */
|
||||
paymentData?: string | null;
|
||||
/** The wallet address that should receive the payment */
|
||||
payTo: Address;
|
||||
/** The blockchain network where the payment should be processed */
|
||||
network: FacilitatorNetwork;
|
||||
/** The price for accessing the resource - either a USD amount (e.g., "$0.10") or a specific token amount */
|
||||
price: Money | ERC20TokenAmount;
|
||||
/** The payment facilitator instance used to verify and settle payments */
|
||||
facilitator: ReturnType<typeof facilitatorType>;
|
||||
/** Optional configuration for the payment middleware route */
|
||||
routeConfig?: PaymentMiddlewareConfig;
|
||||
};
|
||||
|
||||
export type PaymentRequiredResult = {
|
||||
/** HTTP 402 - Payment Required, verification or processing failed or payment missing */
|
||||
status: 402;
|
||||
/** The error response body containing payment requirements */
|
||||
responseBody: {
|
||||
/** The X402 protocol version */
|
||||
x402Version: number;
|
||||
/** Human-readable error message */
|
||||
error: string;
|
||||
/** Array of acceptable payment methods and requirements */
|
||||
accepts: RequestedPaymentRequirements[];
|
||||
/** Optional payer address if verification partially succeeded */
|
||||
payer?: string;
|
||||
};
|
||||
/** Response headers for the error response */
|
||||
responseHeaders: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of a payment settlement operation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SettlePaymentResult = Prettify<
|
||||
| {
|
||||
/** HTTP 200 - Payment was successfully processed */
|
||||
status: 200;
|
||||
/** Response headers including payment receipt information */
|
||||
responseHeaders: Record<string, string>;
|
||||
/** The payment receipt from the payment facilitator */
|
||||
paymentReceipt: FacilitatorSettleResponse;
|
||||
}
|
||||
| PaymentRequiredResult
|
||||
>;
|
||||
|
||||
/**
|
||||
* The result of a payment verification operation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type VerifyPaymentResult = Prettify<
|
||||
| {
|
||||
/** HTTP 200 - Payment was successfully verified */
|
||||
status: 200;
|
||||
decodedPayment: RequestedPaymentPayload;
|
||||
selectedPaymentRequirements: RequestedPaymentRequirements;
|
||||
}
|
||||
| PaymentRequiredResult
|
||||
>;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { decodePaymentRequest } from "./common.js";
|
||||
import {
|
||||
type PaymentArgs,
|
||||
type VerifyPaymentResult,
|
||||
x402Version,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Verifies X402 payments for protected resources. This function only verifies the payment,
|
||||
* you should use `settlePayment` to settle the payment.
|
||||
*
|
||||
* @param args - Configuration object containing payment verification parameters
|
||||
* @returns A promise that resolves to either a successful verification result (200) or payment required error (402)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Usage in a Next.js API route
|
||||
* import { verifyPayment, facilitator } from "thirdweb/x402";
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
*
|
||||
* const client = createThirdwebClient({
|
||||
* secretKey: process.env.THIRDWEB_SECRET_KEY,
|
||||
* });
|
||||
*
|
||||
* const thirdwebFacilitator = facilitator({
|
||||
* client,
|
||||
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
* });
|
||||
*
|
||||
* export async function GET(request: Request) {
|
||||
* const paymentData = request.headers.get("x-payment");
|
||||
*
|
||||
* const paymentArgs = {
|
||||
* resourceUrl: "https://api.example.com/premium-content",
|
||||
* method: "GET",
|
||||
* paymentData,
|
||||
* payTo: "0x1234567890123456789012345678901234567890",
|
||||
* network: "eip155:84532", // CAIP2 format: "eip155:<chain_id>"
|
||||
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
|
||||
* facilitator: thirdwebFacilitator,
|
||||
* routeConfig: {
|
||||
* description: "Access to premium API content",
|
||||
* mimeType: "application/json",
|
||||
* maxTimeoutSeconds: 300,
|
||||
* },
|
||||
* };
|
||||
*
|
||||
* // verify the payment
|
||||
* const result = await verifyPayment(paymentArgs);
|
||||
*
|
||||
* if (result.status === 200) {
|
||||
* // Payment verified, but not settled yet
|
||||
* // you can do the work that requires payment first
|
||||
* const result = await doSomething();
|
||||
* // then settle the payment
|
||||
* const settleResult = await settlePayment(paymentArgs);
|
||||
*
|
||||
* // then return the result
|
||||
* return Response.json(result);
|
||||
* } else {
|
||||
* // verification failed, return payment required
|
||||
* return Response.json(result.responseBody, {
|
||||
* status: result.status,
|
||||
* headers: result.responseHeaders,
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
* @beta
|
||||
* @bridge x402
|
||||
*/
|
||||
export async function verifyPayment(
|
||||
args: PaymentArgs,
|
||||
): Promise<VerifyPaymentResult> {
|
||||
const { routeConfig = {}, facilitator } = args;
|
||||
const { errorMessages } = routeConfig;
|
||||
|
||||
const decodePaymentResult = await decodePaymentRequest(args);
|
||||
|
||||
if (decodePaymentResult.status !== 200) {
|
||||
return decodePaymentResult;
|
||||
}
|
||||
|
||||
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
|
||||
decodePaymentResult;
|
||||
|
||||
// Verify payment
|
||||
try {
|
||||
const verification = await facilitator.verify(
|
||||
decodedPayment,
|
||||
selectedPaymentRequirements,
|
||||
);
|
||||
|
||||
if (verification.isValid) {
|
||||
return {
|
||||
status: 200,
|
||||
decodedPayment,
|
||||
selectedPaymentRequirements,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.verificationFailed ||
|
||||
verification.invalidReason ||
|
||||
"Verification failed",
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 402,
|
||||
responseHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseBody: {
|
||||
x402Version,
|
||||
error:
|
||||
errorMessages?.verificationFailed ||
|
||||
(error instanceof Error ? error.message : "Verification error"),
|
||||
accepts: paymentRequirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user