Compare commits

...
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
5158def993 Version Packages (#5650)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-10 15:35:33 +13:00
gregfromstl d5a68c8511 [SDK] Fix: Custom auth profile label (#5660)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving the display of linked profiles in the `LinkedProfilesScreen` component by correctly handling the "Custom Auth" profile type and enhancing unit tests for various profile types.

### Detailed summary
- Updated the label for the "Custom Auth" profile type to "Custom Profile".
- Added unit tests for various profile types including email, google, phone, wallet address, cognito, custom_auth_endpoint, and unknown types.
- Ensured guest profiles are not displayed.

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

<!-- end pr-codex -->
2024-12-10 01:53:37 +00:00
Joaquim VergesandGitHub dcd5822ee7 feat: Add LoyaltyCard contract type support for ERC721 signature minting (#5671) 2024-12-10 14:52:52 +13:00
ElasticBottle c55f02fa75 fix: should always contain gas price info even if it's 0 (#5673)
https://linear.app/thirdweb/issue/CNCT-2602/issue-signing-deploy-tx-on-saakura

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on ensuring that gas price information is always included for enclave wallets, even when it is 0. It modifies the condition for setting the `maxFeePerGas` to check for the type `bigint`.

### Detailed summary
- Updated the condition to check if `tx.maxFeePerGas` is of type `bigint`.
- Ensured that `transaction.maxFeePerGas` is set correctly when `tx.maxFeePerGas` is provided.

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

<!-- end pr-codex -->
2024-12-10 01:16:26 +00:00
arcoraven e702490f2f chore: Add modelName, sessionId, requestId usage (#5670)
https://linear.app/thirdweb/issue/DASH-574/add-nebula-usage-tracker-events

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces additional properties to the `usageEventSchema` in the `service-utils` package, enhancing the tracking of usage events by including identifiers for the model, session, and request.

### Detailed summary
- Added `modelName`, `sessionId`, and `requestId` as optional string fields to the `usageEventSchema` in `packages/service-utils/src/core/usage.ts`.

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

<!-- end pr-codex -->
2024-12-10 00:37:31 +00:00
gregfromstl 3b53732932 fix: ox is hard dependency (#5672)
CNCT-1111

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `thirdweb` package's `package.json` file to fix the version of `ox` to `0.4.0` while retaining other configurations and dependencies.

### Detailed summary
- Updated `ox` version to `0.4.0` in `peerDependencies`.
- Maintained all other existing fields and dependencies in `package.json`.
- No structural changes to the `thirdweb` package configuration.

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

<!-- end pr-codex -->
2024-12-10 00:29:15 +00:00
Joaquim VergesandGitHub 485dcc6020 fix: Add chain fallback for wallet switching (#5668) 2024-12-10 11:02:35 +13:00
MananTank a41e423a18 [Dashboard] Add published contracts page in teams, account and project layouts (#5659)
DASH-471

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring components related to contracts in the dashboard, enhancing the layout with `SidebarLayout`, and improving the user interface for contract management with better structure and loading states.

### Detailed summary
- Updated `layout.tsx` to use `SidebarLayout` for contracts.
- Simplified return statements in `DeployedContractsPage` components.
- Changed class names for better styling consistency.
- Added authentication checks in published contract pages.
- Created `PublishedContractsPage` to display published contracts with loading states.

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

<!-- end pr-codex -->
2024-12-09 21:19:25 +00:00
gregfromstl 2d66242365 [CI] Feature: Auto assign pr author to pr (#5661)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces a new GitHub Actions workflow for automatically assigning authors to pull requests when they are opened or reopened.

### Detailed summary
- Added a new workflow file: `.github/workflows/auto-assign.yml`
- Defined the workflow name as `Auto Author Assign`
- Set the trigger for the workflow on `pull_request` events for types `opened` and `reopened`
- Specified permissions for pull requests as `write`
- Created a job named `assign-author` that runs on `ubuntu-latest`
- Included a step to use the action `toshimaru/[email protected]`

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

<!-- end pr-codex -->
2024-12-09 21:11:01 +00:00
MananTank 3268af61eb [Dashboard] Login flow improvements (#5658)
Fixes: DASH-564 and DASH-494

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the user interface and functionality related to wallet connection across various components in the dashboard, primarily changing instances of "Sign In" to "Connect Wallet" and modifying how account information is handled.

### Detailed summary
- Changed button text from `Sign In` to `Connect Wallet` in multiple components.
- Updated `TeamHeader` to use `getRawAccount` instead of `getAuthToken`.
- Modified account handling in `TeamLayout` functions to include `getValidAccount`.
- Adjusted `LoginPage` logic for account onboarding and redirection.
- Enhanced `TeamHeaderLoggedIn` to accept `account` as a prop directly.

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

<!-- end pr-codex -->
2024-12-09 19:08:38 +00:00
kien-ngo 4ced3d26cd [Dashboard] Refactor: Remove chakra components (2) (#5545)
188 imports

part of DASH-388

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI components in the dashboard, specifically introducing a new `StatCard` component for displaying statistics and refactoring existing components to utilize this new design pattern, improving consistency and readability.

### Detailed summary
- Added a new `StatCard` component in `stat-card.tsx`.
- Refactored `SupplyCards` to use `StatCard` for displaying total, claimed, and unclaimed supply.
- Updated `NftProperty` component to replace `Text` with `<p>` tags for consistency.
- Replaced `Text` components with `<p>` tags in `ContractPermission` for improved styling.

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

<!-- end pr-codex -->
2024-12-09 16:44:36 +00:00
kien-ngo f69d1aad2d [SDK] Update to NFT Components (#5655)
CNCT-2584

- add custom resolver methods
- add better test coverage
- add caching for the getNFT method
- small fix: handling falsy values

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the NFT components in the `thirdweb` library by improving functionality, performance, and test coverage. It introduces custom resolver methods and caching mechanisms, along with fixes for handling falsy values.

### Detailed summary
- Added custom resolver methods to `NFTMedia`, `NFTName`, and `NFTDescription`.
- Implemented caching for the NFT info getter to improve performance.
- Fixed handling of falsy values for NFT media `src`, `name`, and `description`.
- Enhanced test coverage by extracting internal logic for testing.

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

<!-- end pr-codex -->
2024-12-09 11:39:39 +00:00
Joaquim VergesandGitHub f680496ccb fix: Coinbase Wallet connection on same chain (#5656) 2024-12-09 22:17:55 +13:00
Joaquim VergesandGitHub df734baf97 [SDK] Fix: Improve enclave and userop error messages (#5653) 2024-12-09 22:08:27 +13:00
DustinTurska 015293eb82 Update CTA (#5627)
Set CTA for Engine Playground to "Deploy an Instance"

## Problem solved
Update for [DASH-535](https://linear.app/thirdweb/issue/DASH-535/make-deploy-an-instance-the-primary-cta-in-playground)

Set CTA for Engine Playground to "Deploy an Instance"

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily updates the `deployLink` across multiple pages and modifies the `EngineAPIHeader` component to dynamically include the `deployLink` prop, enhancing the deployment functionality in the playground web application.

### Detailed summary
- Added `deployLink` prop to `page.tsx` files for `webhooks`, `minting`, and `airdrop`.
- Updated `docsLink` for `minting` and `airdrop` pages to point to specific API documentation.
- Modified `EngineAPIHeader` component to utilize the `deployLink` prop for dynamic linking.
- Removed hardcoded `Deploy an Instance` button from `EngineAPIHeader`.

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

<!-- end pr-codex -->
2024-12-08 20:35:36 +00:00
theItalianDevandGitHub 5fd51866c4 Update page.mdx (#5626) 2024-12-08 12:34:11 -08:00
gregfromstl 8234dbae8f [SDK] Feature: Adds tsdoc parsing and other eslint rules (#5622)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving TypeScript typings, updating ESLint configurations, and refining package dependencies across various files in the `thirdweb` package.

### Detailed summary
- Changed parameter name from `uri` to `_uri` in `onDisplayUri` callback.
- Updated `WalletCreationOptions` type definition.
- Modified listener type to use `_event`.
- Added ESLint rules for better coding practices.
- Removed outdated TSDoc tags and added `@example` tag.
- Updated package dependencies in `pnpm-lock.yaml`.

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

<!-- end pr-codex -->
2024-12-07 16:24:58 +00:00
kien-ngo 43fbcac25e [SDK] USD balance for AccountBalance (#5533)
CNCT-2494

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `AccountBalance` and `ConnectButton` components to support displaying balances in fiat currency (currently only USD). It also introduces new utility functions and improves test coverage across various components.

### Detailed summary
- Added `showBalanceInFiat` prop to `AccountBalance` and `ConnectButton`.
- Updated `formatFn` in `AccountBalance` to accept an object of type `AccountBalanceInfo`.
- Introduced `shortenLargeNumber` and `formatNumber` utility functions.
- Improved tests for `ConnectButton`, `AccountBalance`, and related components.
- Enhanced rendering logic for displaying balances in both token and fiat.

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

<!-- end pr-codex -->
2024-12-07 02:29:27 +00:00
jnsdls 37aa1637de add zero address fallback for publisher header (#5651)
fixes: DASH-563
2024-12-07 02:19:54 +00:00
gregfromstl c48e0c9320 [SDK] Fix: Disconnect smart wallet when signer is disconnected (#5617)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving error handling and connection management for smart wallets within the `thirdweb` package, enhancing user experience by ensuring proper disconnection and validation of address inputs.

### Detailed summary
- Added error handling in `AccountProvider` for missing addresses.
- Implemented disconnection logic for smart wallets in `handleSmartWalletConnection`.
- Updated tests to cover new error scenarios and connection handling.
- Refactored `DetailsModal` to manage modal state based on active accounts.
- Improved test coverage for `DetailsModal` and connection manager functionalities.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2024-12-07 01:27:52 +00:00
jnsdls 625febb37e update dependencies (#5649)
closes: DASH-545
2024-12-07 00:41:32 +00:00
110 changed files with 5834 additions and 1918 deletions
+14
View File
@@ -0,0 +1,14 @@
name: Auto Author Assign
on:
pull_request:
types: [ opened, reopened ]
permissions:
pull-requests: write
jobs:
assign-author:
runs-on: ubuntu-latest
steps:
- uses: toshimaru/[email protected]
+3 -3
View File
@@ -48,7 +48,7 @@
"@radix-ui/react-tooltip": "1.1.4",
"@sentry/nextjs": "8.42.0",
"@shazow/whatsabi": "^0.17.0",
"@tanstack/react-query": "5.62.2",
"@tanstack/react-query": "5.62.3",
"@tanstack/react-table": "^8.17.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "^1.5.1",
@@ -119,8 +119,8 @@
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^4.18.3",
@@ -150,7 +150,7 @@ export const CustomConnectWallet = (props: {
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Sign In
Connect Wallet
</Link>
</Button>
</>
@@ -146,7 +146,7 @@ export function FaucetButton({
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Sign In
Connect Wallet
</Link>
</Button>
);
@@ -1,4 +1,4 @@
import { Card, Text } from "tw-components";
import { Card } from "@/components/ui/card";
interface NftPropertyProps {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
@@ -7,22 +7,17 @@ interface NftPropertyProps {
export const NftProperty: React.FC<NftPropertyProps> = ({ property }) => {
return (
<Card className="flex flex-col gap-2">
<Card className="flex flex-col gap-2 p-3">
{property?.trait_type && (
<Text
size="label.sm"
color="primary.500"
textAlign="center"
lineHeight={1.2}
>
<p className="text-center text-link-foreground text-sm leading-[1.2]">
{property?.trait_type}
</Text>
</p>
)}
<Text size="label.md" textAlign="center">
<p className="text-center text-muted-foreground text-sm">
{typeof property?.value === "object"
? JSON.stringify(property?.value || {})
: property?.value}
</Text>
</p>
</Card>
);
};
@@ -1,6 +1,4 @@
"use client";
import { Skeleton, Stat, StatLabel, StatNumber } from "@chakra-ui/react";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import {
@@ -9,7 +7,7 @@ import {
totalSupply,
} from "thirdweb/extensions/erc721";
import { useReadContract } from "thirdweb/react";
import { Card } from "tw-components";
import { StatCard } from "../../overview/components/stat-card";
interface SupplyCardsProps {
contract: ThirdwebContract;
@@ -37,27 +35,22 @@ export const SupplyCards: React.FC<SupplyCardsProps> = ({ contract }) => {
);
return (
<div className="flex flex-col gap-3 md:flex-row md:gap-6">
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Total Supply</StatLabel>
<Skeleton isLoaded={nextTokenIdQuery.isSuccess}>
<StatNumber>{realTotalSupply.toString()}</StatNumber>
</Skeleton>
</Card>
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Claimed Supply</StatLabel>
<Skeleton isLoaded={totalSupplyQuery.isSuccess}>
<StatNumber>{totalSupplyQuery?.data?.toString()}</StatNumber>
</Skeleton>
</Card>
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Unclaimed Supply</StatLabel>
<Skeleton
isLoaded={totalSupplyQuery.isSuccess && nextTokenIdQuery.isSuccess}
>
<StatNumber>{unclaimedSupply}</StatNumber>
</Skeleton>
</Card>
<div className="flex flex-row gap-3 md:gap-6 [&>*]:grow">
<StatCard
value={realTotalSupply.toString()}
label="Total Supply"
isPending={nextTokenIdQuery.isPending}
/>
<StatCard
value={totalSupplyQuery?.data?.toString() || "N/A"}
label="Claimed Supply"
isPending={totalSupplyQuery.isPending}
/>
<StatCard
value={unclaimedSupply}
label="Unclaimed Supply"
isPending={totalSupplyQuery.isPending || nextTokenIdQuery.isPending}
/>
</div>
);
};
@@ -41,7 +41,6 @@ import type { NFT, ThirdwebContract } from "thirdweb";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { useReadContract } from "thirdweb/react";
import { Heading, Text } from "tw-components";
interface ContractOverviewNFTGetAllProps {
contract: ThirdwebContract;
@@ -61,11 +60,7 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
{
Header: "Token Id",
accessor: (row) => row.id?.toString(),
Cell: (cell: CellProps<NFT, string>) => (
<Text size="body.md" fontFamily="mono">
{cell.value}
</Text>
),
Cell: (cell: CellProps<NFT, string>) => <p>{cell.value}</p>,
},
{
Header: "Media",
@@ -140,9 +135,9 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
Cell: (cell: CellProps<NFT, number>) => {
if (cell.row.original.type === "ERC1155") {
return (
<Text noOfLines={4} size="body.md" fontFamily="mono">
<p className="line-clamp-4 font-mono text-base">
{cell.row.original.supply.toString()}
</Text>
</p>
);
}
},
@@ -267,9 +262,9 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={columnIndex}
>
<Text as="label" size="label.sm" color="faded">
<p className="text-muted-foreground">
{column.render("Header")}
</Text>
</p>
</Th>
))}
{/* Need to add an empty header for the drawer button */}
@@ -352,7 +347,7 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
>
<Flex align="center" gap={4}>
<Spinner size="sm" />
<Heading size="label.lg">Fetching new page</Heading>
<p className="text-lg">Fetching new page</p>
</Flex>
</Flex>
)}
@@ -373,12 +368,12 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
icon={<ChevronLeftIcon className="size-4" />}
onClick={() => previousPage()}
/>
<Text whiteSpace="nowrap">
<p className="whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<Skeleton as="span" display="inline" isLoaded={querySuccess}>
<strong>{pageCount}</strong>
</Skeleton>
</Text>
</p>
<IconButton
isDisabled={!canNextPage || queryLoading}
aria-label="next page"
@@ -1,8 +1,8 @@
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import { totalAuctions, totalListings } from "thirdweb/extensions/marketplace";
import { useReadContract } from "thirdweb/react";
import { StatCard } from "./stat-card";
const TotalListingsStat: React.FC<{ contract: ThirdwebContract }> = ({
contract,
@@ -82,20 +82,3 @@ export const ListingStatsV3: React.FC<ListingStatsV3Props> = ({
</div>
);
};
function StatCard(props: {
value: string;
isPending: boolean;
label: string;
}) {
return (
<dl className="block rounded-lg border border-border bg-muted/50 p-4">
<dt className="mb-1.5 text-sm md:text-base">{props.label}</dt>
<SkeletonContainer
loadedData={props.isPending ? undefined : props.value}
skeletonData={"0000"}
render={(v) => <dd className="truncate font-semibold text-xl">{v}</dd>}
/>
</dl>
);
}
@@ -0,0 +1,18 @@
import { SkeletonContainer } from "@/components/ui/skeleton";
export function StatCard(props: {
value: string;
isPending: boolean;
label: string;
}) {
return (
<dl className="block rounded-lg border border-border bg-muted/50 p-4">
<dt className="mb-1.5 text-sm md:text-base">{props.label}</dt>
<SkeletonContainer
loadedData={props.isPending ? undefined : props.value}
skeletonData={"0000"}
render={(v) => <dd className="truncate font-semibold text-xl">{v}</dd>}
/>
</dl>
);
}
@@ -1,9 +1,9 @@
import { Card } from "@/components/ui/card";
import { useIsAdmin } from "@3rdweb-sdk/react/hooks/useContractRoles";
import { Flex, Select, Spinner } from "@chakra-ui/react";
import { InfoIcon } from "lucide-react";
import { useFormContext } from "react-hook-form";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import { Card, Heading, Text } from "tw-components";
import { PermissionEditor } from "./permissions-editor";
interface ContractPermissionProps {
@@ -17,7 +17,6 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
role,
description,
isPending,
contract,
}) => {
const {
@@ -33,15 +32,15 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
const isAdmin = useIsAdmin(contract);
return (
<Card position="relative">
<Card className="relative p-4">
<Flex direction="column" gap={3}>
<div className="mb-3 flex flex-col gap-2">
<div className="flex flex-row">
<div className="flex grow flex-col gap-1">
<Heading size="subtitle.sm" textTransform="capitalize">
<p className="font-semibold text-base capitalize">
{role === "minter" ? "Minter / Creator" : role}
</Heading>
<Text>{description}</Text>
</p>
<p className="text-muted-foreground">{description}</p>
</div>
{role === "transfer" && (
@@ -49,7 +48,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
{isPending || isSubmitting ? (
<Flex align="center" gap={2} px={2}>
<Spinner size="sm" />
<Text>{isPending ? "Loading ..." : "Updating ..."}</Text>
<p>{isPending ? "Loading ..." : "Updating ..."}</p>
</Flex>
) : (
<Select
@@ -84,7 +83,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
{isPending || isSubmitting ? (
<Flex align="center" gap={2} px={2}>
<Spinner size="sm" />
<Text>{isPending ? "Loading ..." : "Updating ..."}</Text>
<p>{isPending ? "Loading ..." : "Updating ..."}</p>
</Flex>
) : (
<Select
@@ -119,7 +118,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
{isPending || isSubmitting ? (
<Flex align="center" gap={2} px={2}>
<Spinner size="sm" />
<Text>{isPending ? "Loading ..." : "Updating ..."}</Text>
<p>{isPending ? "Loading ..." : "Updating ..."}</p>
</Flex>
) : (
<Select
@@ -165,8 +164,8 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
padding="10px"
gap={3}
>
<InfoIcon className="size-6 text-primary-foreground hover:opacity-10" />
<Text color="primary.800" _dark={{ color: "primary.100" }}>
<InfoIcon className="size-6 text-primary hover:opacity-10 dark:text-primary-foreground" />
<p className="text-primary dark:text-primary-foreground">
{isRestricted ? (
<>
The tokens in this contract are currently{" "}
@@ -181,7 +180,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
transfer their tokens.
</>
)}
</Text>
</p>
</Flex>
)}
@@ -200,8 +199,8 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
padding="10px"
gap={3}
>
<InfoIcon className="size-6 text-primary-foreground hover:opacity-10" />
<Text color="primary.800" _dark={{ color: "primary.100" }}>
<InfoIcon className="size-6 text-primary hover:opacity-10 dark:text-primary-foreground" />
<p className="text-primary dark:text-primary-foreground">
{isRestricted ? (
<>
Currently, only addresses specified below will be able to
@@ -210,7 +209,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
) : (
<>This marketplace is open for anyone to create listings.</>
)}
</Text>
</p>
</Flex>
)}
@@ -229,8 +228,8 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
padding="10px"
gap={3}
>
<InfoIcon className="size-6 text-primary-foreground hover:opacity-10" />
<Text color="primary.800" _dark={{ color: "primary.100" }}>
<InfoIcon className="size-6 text-primary hover:opacity-10 dark:text-primary-foreground" />
<p className="text-primary dark:text-primary-foreground">
{isRestricted ? (
<>
Currently, only assets from the contracts specified below
@@ -242,7 +241,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
contract.
</>
)}
</Text>
</p>
</Flex>
)}
@@ -17,14 +17,14 @@ export function DeployedContractsPageHeader() {
setImportModalOpen(false);
}}
/>
<div className="flex flex-col gap-4 md:pb-4 lg:flex-row lg:justify-between">
<div className="flex flex-col gap-4 lg:flex-row lg:justify-between">
<div>
<h1 className="mb-1.5 font-semibold text-3xl tracking-tight lg:text-4xl">
Your contracts
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Deployed contracts
</h1>
<p className="text-muted-foreground text-sm">
The list of contract instances that you have deployed or imported
with thirdweb across all networks
The list of contracts that you have deployed or imported with
thirdweb across all networks
</p>
</div>
<div className="flex gap-2 [&>*]:grow">
@@ -8,12 +8,11 @@ import { getSortedDeployedContracts } from "./getSortedDeployedContracts";
export function DeployedContractsPage(props: {
address: string;
className?: string;
}) {
return (
<div className={props.className}>
<div className="flex grow flex-col">
<DeployedContractsPageHeader />
<div className="h-6" />
<div className="h-8" />
<Suspense fallback={<Loading />}>
<DeployedContractsPageAsync {...props} />
</Suspense>
@@ -1,5 +1,26 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
export default function Layout(props: {
children: React.ReactNode;
}) {
return <div className="container flex grow flex-col">{props.children}</div>;
const layoutPath = "/account/contracts";
return (
<SidebarLayout
sidebarLinks={[
{
href: layoutPath,
label: "Deployed contracts",
exactMatch: true,
},
{
href: `${layoutPath}/published`,
label: "Published contracts",
exactMatch: true,
},
]}
>
{props.children}
</SidebarLayout>
);
}
@@ -9,10 +9,5 @@ export default async function Page() {
return redirect(`/login?next=${encodeURIComponent("/account/contracts")}`);
}
return (
<DeployedContractsPage
address={accountAddress}
className="flex grow flex-col pt-10 pb-10"
/>
);
return <DeployedContractsPage address={accountAddress} />;
}
@@ -0,0 +1,81 @@
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { PublishedContracts } from "../../../(dashboard)/profile/[addressOrEns]/components/published-contracts";
import { resolveAddressAndEns } from "../../../(dashboard)/profile/[addressOrEns]/resolveAddressAndEns";
import { fetchPublishedContracts } from "../../../../components/contract-components/fetchPublishedContracts";
export async function PublishedContractsPage(props: {
publisherAddress: string;
}) {
const resolvedInfo = await resolveAddressAndEns(props.publisherAddress);
return (
<div className="flex grow flex-col">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-start">
<div>
<h2 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Published contracts
</h2>
<p className="text-muted-foreground text-sm">
The list of contracts published to thirdweb across all networks.{" "}
<Link
href="https://portal.thirdweb.com/contracts/publish/overview"
className="text-link-foreground hover:text-foreground"
target="_blank"
>
Learn more about publishing contracts
</Link>
</p>
</div>
<Button asChild>
<Link
href="https://portal.thirdweb.com/contracts/publish/publish-contract"
target="_blank"
className="gap-2"
>
<PlusIcon className="size-4" />
Publish Contract
</Link>
</Button>
</div>
<div className="h-8" />
<Suspense fallback={<GenericLoadingPage />}>
<AsyncPublishedContractsTable
publisherAddress={props.publisherAddress}
publisherEnsName={resolvedInfo?.ensName}
/>
</Suspense>
</div>
);
}
async function AsyncPublishedContractsTable(props: {
publisherAddress: string;
publisherEnsName: string | undefined;
}) {
const publishedContracts = await fetchPublishedContracts(
props.publisherAddress,
);
if (publishedContracts.length === 0) {
return (
<div className="flex min-h-[300px] grow items-center justify-center rounded-lg border border-border">
No published contracts found
</div>
);
}
return (
<PublishedContracts
publishedContracts={publishedContracts}
publisherEnsName={props.publisherEnsName}
/>
);
}
@@ -0,0 +1,13 @@
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { PublishedContractsPage } from "./PublishedContractsPage";
export default async function Page() {
const accountAddress = await getAuthTokenWalletAddress();
if (!accountAddress) {
loginRedirect("/account/contracts");
}
return <PublishedContractsPage publisherAddress={accountAddress} />;
}
@@ -51,7 +51,6 @@ async function HeaderAndNav(props: {
{
path: "/account/contracts",
name: "Contracts",
exactMatch: true,
},
{
path: "/account/settings",
+1 -5
View File
@@ -123,11 +123,7 @@ function PageContent(props: {
| {
id: "complete";
}
>(
props.account
? { id: "onboarding", account: props.account }
: { id: "login" },
);
>({ id: "login" });
const router = useDashboardRouter();
const connectionStatus = useActiveWalletConnectionStatus();
+5 -9
View File
@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { getRawAccount } from "../account/settings/getAccount";
import { LoginAndOnboardingPage } from "./LoginPage";
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
export default async function Page(props: {
searchParams: Promise<{
@@ -11,13 +9,11 @@ export default async function Page(props: {
const nextPath = (await props.searchParams).next;
const account = await getRawAccount();
if (account && isOnboardingComplete(account)) {
if (nextPath) {
redirect(nextPath);
} else {
redirect("/team");
}
}
// don't redirect away from login page if authToken is already present and onboarding is done
// so that if user is stuck in a state where cookie is set, account onboarding is complete but the wallet is not connected, they can connect wallet, sign in and continue
// if the user is already logged in, wallet is connected and onboarding is complete
// user will be redirected to the next path on the client side without having to do anything
return <LoginAndOnboardingPage account={account} nextPath={nextPath} />;
}
@@ -2,6 +2,7 @@ import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { notFound, redirect } from "next/navigation";
import { getValidAccount } from "../../../account/settings/getAccount";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
export default async function TeamLayout(props: {
@@ -9,7 +10,11 @@ export default async function TeamLayout(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const teams = await getTeams();
const [account, teams] = await Promise.all([
getValidAccount(`/team/${params.team_slug}`),
getTeams(),
]);
if (!teams) {
redirect("/login");
@@ -37,6 +42,7 @@ export default async function TeamLayout(props: {
currentTeam={team}
teamsAndProjects={teamsAndProjects}
currentProject={undefined}
account={account}
/>
<TabPathLinks
@@ -0,0 +1,28 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const layoutPath = `/team/${params.team_slug}/~/contracts`;
return (
<SidebarLayout
sidebarLinks={[
{
href: layoutPath,
label: "Deployed contracts",
exactMatch: true,
},
{
href: `${layoutPath}/published`,
label: "Published contracts",
exactMatch: true,
},
]}
>
{props.children}
</SidebarLayout>
);
}
@@ -14,12 +14,5 @@ export default async function Page(props: {
);
}
return (
<div className="container flex grow flex-col">
<DeployedContractsPage
address={accountAddress}
className="flex grow flex-col pt-10 pb-10"
/>
</div>
);
return <DeployedContractsPage address={accountAddress} />;
}
@@ -0,0 +1,16 @@
import { PublishedContractsPage } from "../../../../../../account/contracts/published/PublishedContractsPage";
import { getAuthTokenWalletAddress } from "../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../login/loginRedirect";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const accountAddress = await getAuthTokenWalletAddress();
const params = await props.params;
if (!accountAddress) {
loginRedirect(`/team/${params.team_slug}/~/contracts`);
}
return <PublishedContractsPage publisherAddress={accountAddress} />;
}
@@ -0,0 +1,28 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
export default async function Layout(props: {
children?: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const layoutPath = `/team/${params.team_slug}/${params.project_slug}/contracts`;
return (
<SidebarLayout
sidebarLinks={[
{
href: layoutPath,
label: "Deployed contracts",
exactMatch: true,
},
{
href: `${layoutPath}/published`,
label: "Published contracts",
exactMatch: true,
},
]}
>
{props.children}
</SidebarLayout>
);
}
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { DeployedContractsPage } from "../../../../account/contracts/_components/DeployedContractsPage";
import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -9,17 +9,8 @@ export default async function Page(props: {
if (!accountAddress) {
const { team_slug, project_slug } = await props.params;
return redirect(
`/login?next=${encodeURIComponent(`/team/${team_slug}/${project_slug}/contracts`)}`,
);
loginRedirect(`/team/${team_slug}/${project_slug}/contracts`);
}
return (
<div className="container flex grow flex-col">
<DeployedContractsPage
address={accountAddress}
className="flex grow flex-col pt-10 pb-10"
/>
</div>
);
return <DeployedContractsPage address={accountAddress} />;
}
@@ -0,0 +1,16 @@
import { PublishedContractsPage } from "../../../../../account/contracts/published/PublishedContractsPage";
import { getAuthTokenWalletAddress } from "../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../login/loginRedirect";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const accountAddress = await getAuthTokenWalletAddress();
const params = await props.params;
if (!accountAddress) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/contracts`);
}
return <PublishedContractsPage publisherAddress={accountAddress} />;
}
@@ -2,6 +2,7 @@ import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { notFound, redirect } from "next/navigation";
import { getValidAccount } from "../../../account/settings/getAccount";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
export default async function TeamLayout(props: {
@@ -10,7 +11,10 @@ export default async function TeamLayout(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const teams = await getTeams();
const [teams, account] = await Promise.all([
getTeams(),
getValidAccount(`/team/${params.team_slug}/${params.project_slug}`),
]);
if (!teams) {
redirect("/login");
@@ -49,6 +53,7 @@ export default async function TeamLayout(props: {
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
account={account}
/>
<TabPathLinks
tabContainerClassName="px-4 lg:px-6"
@@ -35,7 +35,7 @@ export function TeamHeaderLoggedOutDesktopUI(props: {
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Sign in
Connect Wallet
</Link>
</Button>
</div>
@@ -64,7 +64,7 @@ export function TeamHeaderLoggedOutMobileUI(props: {
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Sign in
Connect Wallet
</Link>
</Button>
<MobileBurgerMenuButton type="loggedOut" />
@@ -5,7 +5,7 @@ import type { Team } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
@@ -20,10 +20,10 @@ export function TeamHeaderLoggedIn(props: {
currentTeam: Team;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
currentProject: Project | undefined;
account: Pick<Account, "email" | "id">;
}) {
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const myAccountQuery = useAccount();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
const router = useDashboardRouter();
@@ -45,7 +45,7 @@ export function TeamHeaderLoggedIn(props: {
currentProject: props.currentProject,
currentTeam: props.currentTeam,
teamsAndProjects: props.teamsAndProjects,
account: myAccountQuery.data,
account: props.account,
logout: logout,
connectButton: <CustomConnectWallet />,
createProject: () => setIsCreateProjectDialogOpen(true),
@@ -1,13 +1,13 @@
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { getRawAccount } from "../../../account/settings/getAccount";
import { TeamHeaderLoggedOut } from "./TeamHeaderLoggedOut";
import { TeamHeaderLoggedIn } from "./team-header-logged-in.client";
export async function TeamHeader() {
const authToken = await getAuthToken();
const account = await getRawAccount();
if (!authToken) {
if (!account) {
return <TeamHeaderLoggedOut />;
}
@@ -34,6 +34,7 @@ export async function TeamHeader() {
currentTeam={firstTeam}
teamsAndProjects={teamsAndProjects}
currentProject={undefined}
account={account}
/>
);
}
@@ -108,7 +108,7 @@ export const MismatchButton = forwardRef<
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Sign In
Connect Wallet
</Link>
</Button>
);
@@ -4,6 +4,7 @@ import { useThirdwebClient } from "@/constants/thirdweb.client";
import { Flex, Skeleton } from "@chakra-ui/react";
import { useTrack } from "hooks/analytics/useTrack";
import { replaceDeployerAddress } from "lib/publisher-utils";
import { ZERO_ADDRESS } from "thirdweb";
import {
AccountAddress,
AccountAvatar,
@@ -41,7 +42,11 @@ export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
Published by
</Heading>
<AccountProvider address={ensQuery.data?.address || ""} client={client}>
<AccountProvider
// passing zero address during loading time to prevent the component from crashing
address={ensQuery.data?.address || ZERO_ADDRESS}
client={client}
>
<div className="flex items-center gap-4">
<Link
href={replaceDeployerAddress(
+3 -3
View File
@@ -27,7 +27,7 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "1.1.4",
"@tanstack/react-query": "5.62.2",
"@tanstack/react-query": "5.62.3",
"@thirdweb-dev/engine": "^0.0.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -47,8 +47,8 @@
},
"devDependencies": {
"@types/node": "22.10.1",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "8.57.0",
"eslint-config-next": "15.0.4",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
@@ -15,6 +15,7 @@ export default function Page() {
address!
</>
}
deployLink="https://thirdweb.com/team/~/~/engine/create?utm_source=playground"
docsLink="https://thirdweb-engine.apidocumentation.com/reference#tag/erc20/POST/contract/{chain}/{contractAddress}/erc20/mint-batch-to?utm_source=playground"
heroLink="/airdrop.avif"
/>
@@ -14,6 +14,7 @@ export default function Page() {
sponsor the gas so your users only need a wallet address!
</>
}
deployLink="https://thirdweb.com/team/~/~/engine/create?utm_source=playground"
docsLink="https://thirdweb-engine.apidocumentation.com/reference#tag/erc1155/POST/contract/{chain}/{contractAddress}/erc1155/mint-to?utm_source=playground"
heroLink="/minting.avif"
/>
@@ -14,6 +14,7 @@ export default function Page() {
transaction or backend wallet events.
</>
}
deployLink="https://thirdweb.com/team/~/~/engine/create?utm_source=playground"
docsLink="https://portal.thirdweb.com/engine/features/webhooks?utm_source=playground"
heroLink="/webhooks.avif"
/>
@@ -5,6 +5,7 @@ import { Button } from "../ui/button";
export function EngineAPIHeader(props: {
title: string;
description: React.ReactNode;
deployLink: string;
docsLink: string;
heroLink: string;
}) {
@@ -27,6 +28,11 @@ export function EngineAPIHeader(props: {
<div className="flex flex-col gap-3 md:flex-row">
<Button asChild size="lg">
<Link target="_blank" href={props.deployLink}>
Deploy an Instance
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link target="_blank" href={props.docsLink}>
View docs
</Link>
@@ -39,14 +45,6 @@ export function EngineAPIHeader(props: {
Book a Demo
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link
target="_blank"
href="https://thirdweb.com/team/~/~/engine/create?utm_source=playground"
>
Deploy an Instance
</Link>
</Button>
</div>
</div>
+3 -3
View File
@@ -24,7 +24,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@tanstack/react-query": "5.62.2",
"@tanstack/react-query": "5.62.3",
"@tryghost/content-api": "^1.11.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -55,8 +55,8 @@
"@types/flexsearch": "^0.7.6",
"@types/mdx": "^2.0.13",
"@types/node": "22.10.1",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/react-html-parser": "^2.0.6",
"@types/tryghost__content-api": "^1.3.16",
"@typescript-eslint/eslint-plugin": "7.14.1",
@@ -230,10 +230,10 @@ import { privateKeyToAccount } from "thirdweb/wallets";
import { client } from "@/lib/client";
import { cookies } from "next/headers";
const privateKey = process.env.THIRDWEB_ADMIN_PRIVATE_KEY || "";
const privateKey = process.env.AUTH_PRIVATE_KEY || "";
if (!privateKey) {
throw new Error("Missing THIRDWEB_ADMIN_PRIVATE_KEY in .env file.");
throw new Error("Missing AUTH_PRIVATE_KEY in .env file.");
}
const thirdwebAuth = createAuth({
+3 -3
View File
@@ -16,7 +16,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "5.62.2",
"@tanstack/react-query": "5.62.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@@ -37,8 +37,8 @@
"devDependencies": {
"@next/eslint-plugin-next": "15.0.4",
"@types/node": "22.10.1",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"eslint": "8.57.0",
-2
View File
@@ -70,8 +70,6 @@
"ws@>=8.0.0 <8.17.1": "8.17.1",
"ws@>=7.0.0 <7.5.10": "7.5.10",
"ws@>=6.0.0 <6.2.3": "6.2.3",
"eslint": "8.57.0",
"@typescript-eslint/typescript-estree": "^7.14.1",
"axios@<1.7.4": "^1.7.4",
"send@<0.19.0": "^0.19.0",
"elliptic@<6.5.7": "^6.5.7",
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/service-utils
## 0.4.52
### Patch Changes
- [#5670](https://github.com/thirdweb-dev/js/pull/5670) [`e702490`](https://github.com/thirdweb-dev/js/commit/e702490f2ff5e2b7d6b28c06731af880583cf8bc) Thanks [@arcoraven](https://github.com/arcoraven)! - Add modelName, sessionId, requestId to usage
## 0.4.51
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@thirdweb-dev/service-utils",
"version": "0.4.51",
"version": "0.4.52",
"type": "module",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
+3
View File
@@ -78,5 +78,8 @@ export const usageEventSchema = z.object({
providerIp: z.string().optional(),
promptTokens: z.number().int().nonnegative().optional(),
completionTokens: z.number().int().nonnegative().optional(),
modelName: z.string().optional(),
sessionId: z.string().optional(),
requestId: z.string().optional(),
});
export type UsageEvent = z.infer<typeof usageEventSchema>;
+54
View File
@@ -0,0 +1,54 @@
module.exports = {
rules: {
"no-restricted-syntax": [
"warn",
{
selector: "CallExpression[callee.name='useEffect']",
message:
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
},
{
selector: "CallExpression[callee.name='createContext']",
message:
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
},
{
selector: "CallExpression[callee.name='defineChain']",
message: "Use getCachedChain instead for all internal usage",
},
],
"no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
ignoreRestSiblings: true,
args: "none", // Ignore variables used in type definitions
},
],
"tsdoc/syntax": "warn",
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "eslint-plugin-tsdoc"],
parserOptions: {
ecmaVersion: 2019,
ecmaFeatures: {
impliedStrict: true,
jsx: true,
},
warnOnUnsupportedTypeScriptVersion: false,
},
overrides: [
// THIS NEEDS TO GO LAST!
{
files: ["*.ts", "*.js", "*.tsx", "*.jsx"],
extends: ["biome"],
},
],
env: {
browser: true,
node: true,
},
};
+103
View File
@@ -1,5 +1,108 @@
# thirdweb
## 5.76.0
### Minor Changes
- [#5533](https://github.com/thirdweb-dev/js/pull/5533) [`43fbcac`](https://github.com/thirdweb-dev/js/commit/43fbcac25e9383743f1f42af9da7fe1c1eae12b4) Thanks [@kien-ngo](https://github.com/kien-ngo)! - The Connected-details button now shows USD value next to the token balance.
### Breaking change to the AccountBalance
The formatFn props now takes in an object of type `AccountBalanceInfo`. The old `formatFn` was inflexible because it only allowed you to format the balance value.
With this new version, you have access to both the balance and symbol.
```tsx
import { AccountBalance, type AccountBalanceInfo } from "thirdweb/react";
<AccountBalance
// Show the symbol in lowercase, before the balance
formatFn={(props: AccountBalanceInfo) =>
`${props.symbol.toLowerCase()} ${props.balance}`
}
/>;
```
AccountBalance now supports showing the token balance in fiat value (only USD supported at the moment)
```tsx
<AccountBalance showBalanceInFiat="USD" />
```
The `formatFn` prop now takes in an object of type `AccountBalanceInfo` and outputs a string
```tsx
import { AccountBalance, type AccountBalanceInfo } from "thirdweb/react";
<AccountBalance
formatFn={(props: AccountBalanceInfo) =>
`${props.balance}---${props.symbol.toLowerCase()}`
}
/>;
// Result: 11.12---eth
```
### ConnectButton also supports displaying balance in fiat since it uses AccountBalance internally
```tsx
<ConnectButton
// Show USD value on the button
detailsButton={{
showBalanceInFiat: "USD",
}}
// Show USD value on the modal
detailsModal={{
showBalanceInFiat: "USD",
}}
/>
```
### Export utils functions:
formatNumber: Round up a number to a certain decimal place
```tsx
import { formatNumber } from "thirdweb/utils";
const value = formatNumber(12.1214141, 1); // 12.1
```
shortenLargeNumber: Shorten the string for large value. Mainly used for the AccountBalance's `formatFn`
```tsx
import { shortenLargeNumber } from "thirdweb/utils";
const numStr = shortenLargeNumber(1_000_000_000);
```
### Fix to ConnectButton
The social image of the Details button now display correctly for non-square image.
### Massive test coverage improvement for the Connected-button components
- [#5655](https://github.com/thirdweb-dev/js/pull/5655) [`f69d1aa`](https://github.com/thirdweb-dev/js/commit/f69d1aad2d154a05ebc61a1d7545bd9e5aab17be) Thanks [@kien-ngo](https://github.com/kien-ngo)! - Improve NFT Components
- Add custom resolver methods to NFTMedia, NFTName and NFTDescription
- Add caching for the NFT-info-getter method to improve performance
- Small fix to handle falsy values for NFT media src, name and description
- Improve test coverage by extracting internal logics and testing them
### Patch Changes
- [#5660](https://github.com/thirdweb-dev/js/pull/5660) [`d5a68c8`](https://github.com/thirdweb-dev/js/commit/d5a68c85111809bf39e57f5fb39b5458c1f3fe9a) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Fix: Correctly cleans the "Custom Auth" profile type label
- [#5672](https://github.com/thirdweb-dev/js/pull/5672) [`3b53732`](https://github.com/thirdweb-dev/js/commit/3b5373293202b8ff13cc1502bef3cc9dffaa5afa) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Fix ox hardset version
- [#5653](https://github.com/thirdweb-dev/js/pull/5653) [`df734ba`](https://github.com/thirdweb-dev/js/commit/df734baf97ec2e976fedb124ecfbac7119c0bc5f) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - More helpful error messages for enclave and userop errors
- [#5656](https://github.com/thirdweb-dev/js/pull/5656) [`f680496`](https://github.com/thirdweb-dev/js/commit/f680496ccb1c639fab644fb54e9a962627cf3228) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix connecting to cb wallet browser extension when already on the same chain
- [#5671](https://github.com/thirdweb-dev/js/pull/5671) [`dcd5822`](https://github.com/thirdweb-dev/js/commit/dcd5822ee707fa52f1c3e02c75992485f83922f0) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Allow signature minting for LoyaltyCard contracts by passing the contractType
- [#5673](https://github.com/thirdweb-dev/js/pull/5673) [`c55f02f`](https://github.com/thirdweb-dev/js/commit/c55f02fa75a7f8959bbe69eac6cebfc7927ed865) Thanks [@ElasticBottle](https://github.com/ElasticBottle)! - always include gas price information even if it's 0 for enclave wallets
- [#5617](https://github.com/thirdweb-dev/js/pull/5617) [`c48e0c9`](https://github.com/thirdweb-dev/js/commit/c48e0c9320830aa69c0e9567d985ed8a94eeaaf1) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Fix: Disconnect smart account when account signer is disconnected
- [#5668](https://github.com/thirdweb-dev/js/pull/5668) [`485dcc6`](https://github.com/thirdweb-dev/js/commit/485dcc6020089a80d994c24882f389c24a0af039) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix add chain not triggering for certain wallets
## 5.75.0
### Minor Changes
+374 -373
View File
@@ -1,375 +1,376 @@
{
"name": "thirdweb",
"version": "5.75.0",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
},
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/thirdweb-dev/js/issues"
},
"author": "thirdweb eng <[email protected]>",
"type": "module",
"bin": {
"thirdweb": "./dist/esm/cli/bin.js",
"thirdweb-cli": "./dist/esm/cli/bin.js"
},
"main": "./dist/cjs/exports/thirdweb.js",
"module": "./dist/esm/exports/thirdweb.js",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"exports": {
".": {
"types": "./dist/types/exports/thirdweb.d.ts",
"import": "./dist/esm/exports/thirdweb.js",
"default": "./dist/cjs/exports/thirdweb.js"
},
"./adapters/*": {
"types": "./dist/types/exports/adapters/*.d.ts",
"import": "./dist/esm/exports/adapters/*.js",
"default": "./dist/cjs/exports/adapters/*.js"
},
"./auth": {
"types": "./dist/types/exports/auth.d.ts",
"import": "./dist/esm/exports/auth.js",
"default": "./dist/cjs/exports/auth.js"
},
"./chains": {
"types": "./dist/types/exports/chains.d.ts",
"import": "./dist/esm/exports/chains.js",
"default": "./dist/cjs/exports/chains.js"
},
"./contract": {
"types": "./dist/types/exports/contract.d.ts",
"import": "./dist/esm/exports/contract.js",
"default": "./dist/cjs/exports/contract.js"
},
"./deploys": {
"types": "./dist/types/exports/deploys.d.ts",
"import": "./dist/esm/exports/deploys.js",
"default": "./dist/cjs/exports/deploys.js"
},
"./event": {
"types": "./dist/types/exports/event.d.ts",
"import": "./dist/esm/exports/event.js",
"default": "./dist/cjs/exports/event.js"
},
"./extensions/*": {
"types": "./dist/types/exports/extensions/*.d.ts",
"import": "./dist/esm/exports/extensions/*.js",
"default": "./dist/cjs/exports/extensions/*.js"
},
"./pay": {
"types": "./dist/types/exports/pay.d.ts",
"import": "./dist/esm/exports/pay.js",
"default": "./dist/cjs/exports/pay.js"
},
"./react": {
"types": "./dist/types/exports/react.d.ts",
"import": "./dist/esm/exports/react.js",
"react-native": "./dist/esm/exports/react.native.js",
"default": "./dist/cjs/exports/react.js"
},
"./react-native": {
"types": "./dist/types/exports/react-native.d.ts",
"import": "./dist/esm/exports/react-native.js",
"default": "./dist/cjs/exports/react-native.js"
},
"./rpc": {
"types": "./dist/types/exports/rpc.d.ts",
"import": "./dist/esm/exports/rpc.js",
"default": "./dist/cjs/exports/rpc.js"
},
"./storage": {
"types": "./dist/types/exports/storage.d.ts",
"import": "./dist/esm/exports/storage.js",
"default": "./dist/cjs/exports/storage.js"
},
"./transaction": {
"types": "./dist/types/exports/transaction.d.ts",
"import": "./dist/esm/exports/transaction.js",
"default": "./dist/cjs/exports/transaction.js"
},
"./utils": {
"types": "./dist/types/exports/utils.d.ts",
"import": "./dist/esm/exports/utils.js",
"default": "./dist/cjs/exports/utils.js"
},
"./wallets": {
"types": "./dist/types/exports/wallets.d.ts",
"import": "./dist/esm/exports/wallets.js",
"react-native": "./dist/esm/exports/wallets.native.js",
"default": "./dist/cjs/exports/wallets.js"
},
"./wallets/in-app": {
"types": "./dist/types/exports/wallets/in-app.d.ts",
"import": "./dist/esm/exports/wallets/in-app.js",
"react-native": "./dist/esm/exports/wallets/in-app.native.js",
"default": "./dist/cjs/exports/wallets/in-app.js"
},
"./wallets/*": {
"types": "./dist/types/exports/wallets/*.d.ts",
"import": "./dist/esm/exports/wallets/*.js",
"default": "./dist/cjs/exports/wallets/*.js"
},
"./modules": {
"types": "./dist/types/exports/modules.d.ts",
"import": "./dist/esm/exports/modules.js",
"default": "./dist/cjs/exports/modules.js"
},
"./social": {
"types": "./dist/types/exports/social.d.ts",
"import": "./dist/esm/exports/social.js",
"default": "./dist/cjs/exports/social.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"adapters/*": [
"./dist/types/exports/adapters/*.d.ts"
],
"auth": [
"./dist/types/exports/auth.d.ts"
],
"chains": [
"./dist/types/exports/chains.d.ts"
],
"contract": [
"./dist/types/exports/contract.d.ts"
],
"deploys": [
"./dist/types/exports/deploys.d.ts"
],
"event": [
"./dist/types/exports/event.d.ts"
],
"extensions/*": [
"./dist/types/exports/extensions/*.d.ts"
],
"pay": [
"./dist/types/exports/pay.d.ts"
],
"react": [
"./dist/types/exports/react.d.ts"
],
"react-native": [
"./dist/types/exports/react-native.d.ts"
],
"rpc": [
"./dist/types/exports/rpc.d.ts"
],
"storage": [
"./dist/types/exports/storage.d.ts"
],
"transaction": [
"./dist/types/exports/transaction.d.ts"
],
"utils": [
"./dist/types/exports/utils.d.ts"
],
"wallets": [
"./dist/types/exports/wallets.d.ts"
],
"wallets/*": [
"./dist/types/exports/wallets/*.d.ts"
],
"modules": [
"./dist/types/exports/modules.d.ts"
],
"social": [
"./dist/types/exports/social.d.ts"
]
}
},
"browser": {
"crypto": false
},
"sideEffects": false,
"files": [
"dist/*",
"src/*",
"!**/*.tsbuildinfo",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.ts.snap",
"!**/*.test-d.ts",
"!**/*.bench.ts",
"!tsconfig.build.json"
],
"dependencies": {
"@coinbase/wallet-sdk": "4.2.4",
"@emotion/react": "11.13.5",
"@emotion/styled": "11.13.5",
"@google/model-viewer": "2.1.1",
"@noble/curves": "1.7.0",
"@noble/hashes": "1.6.1",
"@passwordless-id/webauthn": "^2.1.2",
"@radix-ui/react-dialog": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-tooltip": "1.1.4",
"@tanstack/react-query": "5.62.2",
"@walletconnect/ethereum-provider": "2.17.2",
"@walletconnect/sign-client": "2.17.2",
"abitype": "1.0.7",
"fuse.js": "7.0.0",
"input-otp": "^1.4.1",
"mipd": "0.0.7",
"uqr": "0.1.2",
"viem": "2.21.54"
},
"peerDependencies": {
"@aws-sdk/client-lambda": "^3",
"@aws-sdk/credential-providers": "^3",
"@coinbase/wallet-mobile-sdk": "^1",
"@mobile-wallet-protocol/client": "0.1.1",
"@react-native-async-storage/async-storage": "^1 || ^2",
"ethers": "^5 || ^6",
"expo-linking": "^6",
"expo-web-browser": "^13 || ^14",
"ox": "0.4.0",
"react": "^18 || ^19",
"react-native": "*",
"react-native-aes-gcm-crypto": "^0.2",
"react-native-passkey": "^3",
"react-native-quick-crypto": ">=0.7.0-rc.6 || >=0.7",
"react-native-svg": "^15",
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"ethers": {
"optional": true
},
"typescript": {
"optional": true
},
"react-native-aes-gcm-crypto": {
"optional": true
},
"expo-linking": {
"optional": true
},
"expo-web-browser": {
"optional": true
},
"react-native-quick-crypto": {
"optional": true
},
"react-native-passkey": {
"optional": true
},
"react-native-svg": {
"optional": true
},
"@aws-sdk/client-lambda": {
"optional": true
},
"@aws-sdk/client-kms": {
"optional": true
},
"@aws-sdk/credential-providers": {
"optional": true
},
"@react-native-async-storage/async-storage": {
"optional": true
},
"@coinbase/wallet-mobile-sdk": {
"optional": true
},
"@mobile-wallet-protocol/client": {
"optional": true
}
},
"scripts": {
"bench:compare": "bun run ./benchmarks/run.ts",
"bench": "vitest -c ./test/vitest.config.ts bench",
"format": "biome format ./src --write",
"lint": "knip && biome check ./src && tsc --project ./tsconfig.build.json --module esnext --noEmit",
"fix": "biome check ./src --fix",
"knip": "knip",
"build:generate": "bun scripts/generate/generate.ts",
"build:generate-wallets": "bun scripts/wallets/generate.ts",
"dev": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"dev:cjs": "printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json && tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false --watch",
"dev:esm": "printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json && tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm",
"build:cjs": "tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json",
"build:esm": "tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",
"build:types": "tsc --project ./tsconfig.build.json --module esnext --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap",
"clean": "rimraf dist",
"size": "size-limit",
"test:watch": "vitest -c ./test/vitest.config.ts dev",
"test": "vitest run -c ./test/vitest.config.ts --coverage",
"test:cov": "vitest dev -c ./test/vitest.config.ts --coverage",
"test:dev": "vitest run -c ./test/vitest.config.ts",
"test:react": "vitest run -c ./test/vitest.config.ts dev --ui src/react",
"typedoc": "bun run scripts/typedoc.mjs",
"update-version": "node scripts/version.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"engines": {
"node": ">=18"
},
"devDependencies": {
"@aws-sdk/client-kms": "3.699.0",
"@aws-sdk/client-lambda": "3.699.0",
"@aws-sdk/credential-providers": "3.699.0",
"@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "3.2.2",
"@codspeed/vitest-plugin": "3.1.1",
"@coinbase/wallet-mobile-sdk": "1.1.2",
"@mobile-wallet-protocol/client": "0.1.2",
"@react-native-async-storage/async-storage": "2.1.0",
"@size-limit/preset-big-lib": "11.1.6",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/cross-spawn": "^6.0.6",
"@types/react": "19.0.0",
"@viem/anvil": "0.0.10",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.8",
"@vitest/ui": "2.1.8",
"cross-spawn": "7.0.6",
"dotenv-mono": "^1.3.14",
"ethers5": "npm:ethers@5",
"ethers6": "npm:ethers@6",
"expo-linking": "7.0.3",
"expo-web-browser": "14.0.1",
"happy-dom": "15.11.7",
"knip": "5.39.2",
"msw": "2.6.6",
"prettier": "3.3.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.76.3",
"react-native-aes-gcm-crypto": "0.2.2",
"react-native-passkey": "3.0.0",
"react-native-quick-crypto": "0.7.8",
"react-native-svg": "15.9.0",
"rimraf": "6.0.1",
"sharp": "^0.33.5",
"size-limit": "11.1.6",
"storybook": "8.4.7",
"typedoc": "0.27.3",
"typescript": "5.7.2",
"vite": "6.0.3",
"vitest": "2.1.8"
}
"name": "thirdweb",
"version": "5.76.0",
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
},
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/thirdweb-dev/js/issues"
},
"author": "thirdweb eng <[email protected]>",
"type": "module",
"bin": {
"thirdweb": "./dist/esm/cli/bin.js",
"thirdweb-cli": "./dist/esm/cli/bin.js"
},
"main": "./dist/cjs/exports/thirdweb.js",
"module": "./dist/esm/exports/thirdweb.js",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"exports": {
".": {
"types": "./dist/types/exports/thirdweb.d.ts",
"import": "./dist/esm/exports/thirdweb.js",
"default": "./dist/cjs/exports/thirdweb.js"
},
"./adapters/*": {
"types": "./dist/types/exports/adapters/*.d.ts",
"import": "./dist/esm/exports/adapters/*.js",
"default": "./dist/cjs/exports/adapters/*.js"
},
"./auth": {
"types": "./dist/types/exports/auth.d.ts",
"import": "./dist/esm/exports/auth.js",
"default": "./dist/cjs/exports/auth.js"
},
"./chains": {
"types": "./dist/types/exports/chains.d.ts",
"import": "./dist/esm/exports/chains.js",
"default": "./dist/cjs/exports/chains.js"
},
"./contract": {
"types": "./dist/types/exports/contract.d.ts",
"import": "./dist/esm/exports/contract.js",
"default": "./dist/cjs/exports/contract.js"
},
"./deploys": {
"types": "./dist/types/exports/deploys.d.ts",
"import": "./dist/esm/exports/deploys.js",
"default": "./dist/cjs/exports/deploys.js"
},
"./event": {
"types": "./dist/types/exports/event.d.ts",
"import": "./dist/esm/exports/event.js",
"default": "./dist/cjs/exports/event.js"
},
"./extensions/*": {
"types": "./dist/types/exports/extensions/*.d.ts",
"import": "./dist/esm/exports/extensions/*.js",
"default": "./dist/cjs/exports/extensions/*.js"
},
"./pay": {
"types": "./dist/types/exports/pay.d.ts",
"import": "./dist/esm/exports/pay.js",
"default": "./dist/cjs/exports/pay.js"
},
"./react": {
"types": "./dist/types/exports/react.d.ts",
"import": "./dist/esm/exports/react.js",
"react-native": "./dist/esm/exports/react.native.js",
"default": "./dist/cjs/exports/react.js"
},
"./react-native": {
"types": "./dist/types/exports/react-native.d.ts",
"import": "./dist/esm/exports/react-native.js",
"default": "./dist/cjs/exports/react-native.js"
},
"./rpc": {
"types": "./dist/types/exports/rpc.d.ts",
"import": "./dist/esm/exports/rpc.js",
"default": "./dist/cjs/exports/rpc.js"
},
"./storage": {
"types": "./dist/types/exports/storage.d.ts",
"import": "./dist/esm/exports/storage.js",
"default": "./dist/cjs/exports/storage.js"
},
"./transaction": {
"types": "./dist/types/exports/transaction.d.ts",
"import": "./dist/esm/exports/transaction.js",
"default": "./dist/cjs/exports/transaction.js"
},
"./utils": {
"types": "./dist/types/exports/utils.d.ts",
"import": "./dist/esm/exports/utils.js",
"default": "./dist/cjs/exports/utils.js"
},
"./wallets": {
"types": "./dist/types/exports/wallets.d.ts",
"import": "./dist/esm/exports/wallets.js",
"react-native": "./dist/esm/exports/wallets.native.js",
"default": "./dist/cjs/exports/wallets.js"
},
"./wallets/in-app": {
"types": "./dist/types/exports/wallets/in-app.d.ts",
"import": "./dist/esm/exports/wallets/in-app.js",
"react-native": "./dist/esm/exports/wallets/in-app.native.js",
"default": "./dist/cjs/exports/wallets/in-app.js"
},
"./wallets/*": {
"types": "./dist/types/exports/wallets/*.d.ts",
"import": "./dist/esm/exports/wallets/*.js",
"default": "./dist/cjs/exports/wallets/*.js"
},
"./modules": {
"types": "./dist/types/exports/modules.d.ts",
"import": "./dist/esm/exports/modules.js",
"default": "./dist/cjs/exports/modules.js"
},
"./social": {
"types": "./dist/types/exports/social.d.ts",
"import": "./dist/esm/exports/social.js",
"default": "./dist/cjs/exports/social.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"adapters/*": [
"./dist/types/exports/adapters/*.d.ts"
],
"auth": [
"./dist/types/exports/auth.d.ts"
],
"chains": [
"./dist/types/exports/chains.d.ts"
],
"contract": [
"./dist/types/exports/contract.d.ts"
],
"deploys": [
"./dist/types/exports/deploys.d.ts"
],
"event": [
"./dist/types/exports/event.d.ts"
],
"extensions/*": [
"./dist/types/exports/extensions/*.d.ts"
],
"pay": [
"./dist/types/exports/pay.d.ts"
],
"react": [
"./dist/types/exports/react.d.ts"
],
"react-native": [
"./dist/types/exports/react-native.d.ts"
],
"rpc": [
"./dist/types/exports/rpc.d.ts"
],
"storage": [
"./dist/types/exports/storage.d.ts"
],
"transaction": [
"./dist/types/exports/transaction.d.ts"
],
"utils": [
"./dist/types/exports/utils.d.ts"
],
"wallets": [
"./dist/types/exports/wallets.d.ts"
],
"wallets/*": [
"./dist/types/exports/wallets/*.d.ts"
],
"modules": [
"./dist/types/exports/modules.d.ts"
],
"social": [
"./dist/types/exports/social.d.ts"
]
}
},
"browser": {
"crypto": false
},
"sideEffects": false,
"files": [
"dist/*",
"src/*",
"!**/*.tsbuildinfo",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.ts.snap",
"!**/*.test-d.ts",
"!**/*.bench.ts",
"!tsconfig.build.json"
],
"dependencies": {
"@coinbase/wallet-sdk": "4.2.4",
"@emotion/react": "11.13.5",
"@emotion/styled": "11.13.5",
"@google/model-viewer": "2.1.1",
"@noble/curves": "1.7.0",
"@noble/hashes": "1.6.1",
"@passwordless-id/webauthn": "^2.1.2",
"@radix-ui/react-dialog": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-tooltip": "1.1.4",
"@tanstack/react-query": "5.62.3",
"@walletconnect/ethereum-provider": "2.17.2",
"@walletconnect/sign-client": "2.17.2",
"abitype": "1.0.7",
"fuse.js": "7.0.0",
"input-otp": "^1.4.1",
"mipd": "0.0.7",
"ox": "0.4.0",
"uqr": "0.1.2",
"viem": "2.21.54"
},
"peerDependencies": {
"@aws-sdk/client-lambda": "^3",
"@aws-sdk/credential-providers": "^3",
"@coinbase/wallet-mobile-sdk": "^1",
"@mobile-wallet-protocol/client": "0.1.1",
"@react-native-async-storage/async-storage": "^1 || ^2",
"ethers": "^5 || ^6",
"expo-linking": "^6",
"expo-web-browser": "^13 || ^14",
"react": "^18 || ^19",
"react-native": "*",
"react-native-aes-gcm-crypto": "^0.2",
"react-native-passkey": "^3",
"react-native-quick-crypto": ">=0.7.0-rc.6 || >=0.7",
"react-native-svg": "^15",
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"ethers": {
"optional": true
},
"typescript": {
"optional": true
},
"react-native-aes-gcm-crypto": {
"optional": true
},
"expo-linking": {
"optional": true
},
"expo-web-browser": {
"optional": true
},
"react-native-quick-crypto": {
"optional": true
},
"react-native-passkey": {
"optional": true
},
"react-native-svg": {
"optional": true
},
"@aws-sdk/client-lambda": {
"optional": true
},
"@aws-sdk/client-kms": {
"optional": true
},
"@aws-sdk/credential-providers": {
"optional": true
},
"@react-native-async-storage/async-storage": {
"optional": true
},
"@coinbase/wallet-mobile-sdk": {
"optional": true
},
"@mobile-wallet-protocol/client": {
"optional": true
}
},
"scripts": {
"bench:compare": "bun run ./benchmarks/run.ts",
"bench": "vitest -c ./test/vitest.config.ts bench",
"format": "biome format ./src --write",
"lint": "knip && biome check ./src && tsc --project ./tsconfig.build.json --module esnext --noEmit",
"fix": "biome check ./src --fix",
"knip": "knip",
"build:generate": "bun scripts/generate/generate.ts",
"build:generate-wallets": "bun scripts/wallets/generate.ts",
"dev": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"dev:cjs": "printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json && tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false --watch",
"dev:esm": "printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json && tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm",
"build:cjs": "tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json",
"build:esm": "tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",
"build:types": "tsc --project ./tsconfig.build.json --module esnext --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap",
"clean": "rimraf dist",
"size": "size-limit",
"test:watch": "vitest -c ./test/vitest.config.ts dev",
"test": "vitest run -c ./test/vitest.config.ts --coverage",
"test:cov": "vitest dev -c ./test/vitest.config.ts --coverage",
"test:ui": "vitest dev -c ./test/vitest.config.ts --coverage --ui",
"test:dev": "vitest run -c ./test/vitest.config.ts",
"test:react": "vitest run -c ./test/vitest.config.ts dev --ui src/react",
"typedoc": "bun run scripts/typedoc.mjs",
"update-version": "node scripts/version.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"engines": {
"node": ">=18"
},
"devDependencies": {
"@aws-sdk/client-kms": "3.699.0",
"@aws-sdk/client-lambda": "3.699.0",
"@aws-sdk/credential-providers": "3.699.0",
"@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "3.2.2",
"@codspeed/vitest-plugin": "3.1.1",
"@coinbase/wallet-mobile-sdk": "1.1.2",
"@mobile-wallet-protocol/client": "0.1.2",
"@react-native-async-storage/async-storage": "2.1.0",
"@size-limit/preset-big-lib": "11.1.6",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/cross-spawn": "^6.0.6",
"@types/react": "19.0.1",
"@viem/anvil": "0.0.10",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.8",
"@vitest/ui": "2.1.8",
"cross-spawn": "7.0.6",
"dotenv-mono": "^1.3.14",
"ethers5": "npm:ethers@5",
"ethers6": "npm:ethers@6",
"expo-linking": "7.0.3",
"expo-web-browser": "14.0.1",
"happy-dom": "15.11.7",
"knip": "5.39.2",
"msw": "2.6.7",
"prettier": "3.3.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.76.4",
"react-native-aes-gcm-crypto": "0.2.2",
"react-native-passkey": "3.0.0",
"react-native-quick-crypto": "0.7.8",
"react-native-svg": "15.10.1",
"rimraf": "6.0.1",
"sharp": "^0.33.5",
"size-limit": "11.1.6",
"storybook": "8.4.7",
"typedoc": "0.27.3",
"typescript": "5.7.2",
"vite": "6.0.3",
"vitest": "2.1.8"
}
}
@@ -0,0 +1,5 @@
[
"function mintWithSignature((address to, address royaltyRecipient, uint256 royaltyBps, address primarySaleRecipient, string uri, uint256 quantity, uint256 pricePerToken, address currency, uint128 validityStartTimestamp, uint128 validityEndTimestamp, bytes32 uid) payload, bytes signature) payable returns (address signer)",
"function verify((address to, address royaltyRecipient, uint256 royaltyBps, address primarySaleRecipient, string uri, uint256 quantity, uint256 pricePerToken, address currency, uint128 validityStartTimestamp, uint128 validityEndTimestamp, bytes32 uid) req, bytes signature) view returns (bool success, address signer)",
"event TokensMintedWithSignature(address indexed signer, address indexed mintedTo, uint256 indexed tokenIdMinted, (address to, address royaltyRecipient, uint256 royaltyBps, address primarySaleRecipient, string uri, uint256 quantity, uint256 pricePerToken, address currency, uint128 validityStartTimestamp, uint128 validityEndTimestamp, bytes32 uid) mintRequest)"
]
+2
View File
@@ -189,6 +189,7 @@ export {
export {
NFTMedia,
type NFTMediaProps,
type NFTMediaInfo,
} from "../react/web/ui/prebuilt/NFT/media.js";
export { useConnectionManager } from "../react/core/providers/connection-manager.js";
@@ -221,6 +222,7 @@ export {
export {
AccountBalance,
type AccountBalanceProps,
type AccountBalanceInfo,
} from "../react/web/ui/prebuilt/Account/balance.js";
export {
AccountName,
+3
View File
@@ -204,3 +204,6 @@ export type {
AbiConstructor,
AbiFallback,
} from "abitype";
export { shortenLargeNumber } from "../utils/shortenLargeNumber.js";
export { formatNumber } from "../utils/formatNumber.js";
@@ -0,0 +1,55 @@
import { prepareEvent } from "../../../../../event/prepare-event.js";
import type { AbiParameterToPrimitiveType } from "abitype";
/**
* Represents the filters for the "TokensMintedWithSignature" event.
*/
export type TokensMintedWithSignatureEventFilters = Partial<{
signer: AbiParameterToPrimitiveType<{
type: "address";
name: "signer";
indexed: true;
}>;
mintedTo: AbiParameterToPrimitiveType<{
type: "address";
name: "mintedTo";
indexed: true;
}>;
tokenIdMinted: AbiParameterToPrimitiveType<{
type: "uint256";
name: "tokenIdMinted";
indexed: true;
}>;
}>;
/**
* Creates an event object for the TokensMintedWithSignature event.
* @param filters - Optional filters to apply to the event.
* @returns The prepared event object.
* @extension ERC721
* @example
* ```ts
* import { getContractEvents } from "thirdweb";
* import { tokensMintedWithSignatureEvent } from "thirdweb/extensions/erc721";
*
* const events = await getContractEvents({
* contract,
* events: [
* tokensMintedWithSignatureEvent({
* signer: ...,
* mintedTo: ...,
* tokenIdMinted: ...,
* })
* ],
* });
* ```
*/
export function tokensMintedWithSignatureEvent(
filters: TokensMintedWithSignatureEventFilters = {},
) {
return prepareEvent({
signature:
"event TokensMintedWithSignature(address indexed signer, address indexed mintedTo, uint256 indexed tokenIdMinted, (address to, address royaltyRecipient, uint256 royaltyBps, address primarySaleRecipient, string uri, uint256 quantity, uint256 pricePerToken, address currency, uint128 validityStartTimestamp, uint128 validityEndTimestamp, bytes32 uid) mintRequest)",
filters,
});
}
@@ -0,0 +1,196 @@
import type { AbiParameterToPrimitiveType } from "abitype";
import { readContract } from "../../../../../transaction/read-contract.js";
import type { BaseTransactionOptions } from "../../../../../transaction/types.js";
import { encodeAbiParameters } from "../../../../../utils/abi/encodeAbiParameters.js";
import { decodeAbiParameters } from "viem";
import type { Hex } from "../../../../../utils/encoding/hex.js";
import { detectMethod } from "../../../../../utils/bytecode/detectExtension.js";
/**
* Represents the parameters for the "verify" function.
*/
export type VerifyParams = {
req: AbiParameterToPrimitiveType<{
type: "tuple";
name: "req";
components: [
{ type: "address"; name: "to" },
{ type: "address"; name: "royaltyRecipient" },
{ type: "uint256"; name: "royaltyBps" },
{ type: "address"; name: "primarySaleRecipient" },
{ type: "string"; name: "uri" },
{ type: "uint256"; name: "quantity" },
{ type: "uint256"; name: "pricePerToken" },
{ type: "address"; name: "currency" },
{ type: "uint128"; name: "validityStartTimestamp" },
{ type: "uint128"; name: "validityEndTimestamp" },
{ type: "bytes32"; name: "uid" },
];
}>;
signature: AbiParameterToPrimitiveType<{ type: "bytes"; name: "signature" }>;
};
export const FN_SELECTOR = "0x252e82e8" as const;
const FN_INPUTS = [
{
type: "tuple",
name: "req",
components: [
{
type: "address",
name: "to",
},
{
type: "address",
name: "royaltyRecipient",
},
{
type: "uint256",
name: "royaltyBps",
},
{
type: "address",
name: "primarySaleRecipient",
},
{
type: "string",
name: "uri",
},
{
type: "uint256",
name: "quantity",
},
{
type: "uint256",
name: "pricePerToken",
},
{
type: "address",
name: "currency",
},
{
type: "uint128",
name: "validityStartTimestamp",
},
{
type: "uint128",
name: "validityEndTimestamp",
},
{
type: "bytes32",
name: "uid",
},
],
},
{
type: "bytes",
name: "signature",
},
] as const;
const FN_OUTPUTS = [
{
type: "bool",
name: "success",
},
{
type: "address",
name: "signer",
},
] as const;
/**
* Checks if the `verify` method is supported by the given contract.
* @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors.
* @returns A boolean indicating if the `verify` method is supported.
* @extension ERC721
* @example
* ```ts
* import { isVerifySupported } from "thirdweb/extensions/erc721";
* const supported = isVerifySupported(["0x..."]);
* ```
*/
export function isVerifySupported(availableSelectors: string[]) {
return detectMethod({
availableSelectors,
method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const,
});
}
/**
* Encodes the parameters for the "verify" function.
* @param options - The options for the verify function.
* @returns The encoded ABI parameters.
* @extension ERC721
* @example
* ```ts
* import { encodeVerifyParams } from "thirdweb/extensions/erc721";
* const result = encodeVerifyParams({
* req: ...,
* signature: ...,
* });
* ```
*/
export function encodeVerifyParams(options: VerifyParams) {
return encodeAbiParameters(FN_INPUTS, [options.req, options.signature]);
}
/**
* Encodes the "verify" function into a Hex string with its parameters.
* @param options - The options for the verify function.
* @returns The encoded hexadecimal string.
* @extension ERC721
* @example
* ```ts
* import { encodeVerify } from "thirdweb/extensions/erc721";
* const result = encodeVerify({
* req: ...,
* signature: ...,
* });
* ```
*/
export function encodeVerify(options: VerifyParams) {
// we do a "manual" concat here to avoid the overhead of the "concatHex" function
// we can do this because we know the specific formats of the values
return (FN_SELECTOR +
encodeVerifyParams(options).slice(2)) as `${typeof FN_SELECTOR}${string}`;
}
/**
* Decodes the result of the verify function call.
* @param result - The hexadecimal result to decode.
* @returns The decoded result as per the FN_OUTPUTS definition.
* @extension ERC721
* @example
* ```ts
* import { decodeVerifyResult } from "thirdweb/extensions/erc721";
* const result = decodeVerifyResultResult("...");
* ```
*/
export function decodeVerifyResult(result: Hex) {
return decodeAbiParameters(FN_OUTPUTS, result);
}
/**
* Calls the "verify" function on the contract.
* @param options - The options for the verify function.
* @returns The parsed result of the function call.
* @extension ERC721
* @example
* ```ts
* import { verify } from "thirdweb/extensions/erc721";
*
* const result = await verify({
* contract,
* req: ...,
* signature: ...,
* });
*
* ```
*/
export async function verify(options: BaseTransactionOptions<VerifyParams>) {
return readContract({
contract: options.contract,
method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const,
params: [options.req, options.signature],
});
}
@@ -0,0 +1,214 @@
import type { AbiParameterToPrimitiveType } from "abitype";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../../transaction/types.js";
import { prepareContractCall } from "../../../../../transaction/prepare-contract-call.js";
import { encodeAbiParameters } from "../../../../../utils/abi/encodeAbiParameters.js";
import { once } from "../../../../../utils/promise/once.js";
import { detectMethod } from "../../../../../utils/bytecode/detectExtension.js";
/**
* Represents the parameters for the "mintWithSignature" function.
*/
export type MintWithSignatureParams = WithOverrides<{
payload: AbiParameterToPrimitiveType<{
type: "tuple";
name: "payload";
components: [
{ type: "address"; name: "to" },
{ type: "address"; name: "royaltyRecipient" },
{ type: "uint256"; name: "royaltyBps" },
{ type: "address"; name: "primarySaleRecipient" },
{ type: "string"; name: "uri" },
{ type: "uint256"; name: "quantity" },
{ type: "uint256"; name: "pricePerToken" },
{ type: "address"; name: "currency" },
{ type: "uint128"; name: "validityStartTimestamp" },
{ type: "uint128"; name: "validityEndTimestamp" },
{ type: "bytes32"; name: "uid" },
];
}>;
signature: AbiParameterToPrimitiveType<{ type: "bytes"; name: "signature" }>;
}>;
export const FN_SELECTOR = "0x439c7be5" as const;
const FN_INPUTS = [
{
type: "tuple",
name: "payload",
components: [
{
type: "address",
name: "to",
},
{
type: "address",
name: "royaltyRecipient",
},
{
type: "uint256",
name: "royaltyBps",
},
{
type: "address",
name: "primarySaleRecipient",
},
{
type: "string",
name: "uri",
},
{
type: "uint256",
name: "quantity",
},
{
type: "uint256",
name: "pricePerToken",
},
{
type: "address",
name: "currency",
},
{
type: "uint128",
name: "validityStartTimestamp",
},
{
type: "uint128",
name: "validityEndTimestamp",
},
{
type: "bytes32",
name: "uid",
},
],
},
{
type: "bytes",
name: "signature",
},
] as const;
const FN_OUTPUTS = [
{
type: "address",
name: "signer",
},
] as const;
/**
* Checks if the `mintWithSignature` method is supported by the given contract.
* @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors.
* @returns A boolean indicating if the `mintWithSignature` method is supported.
* @extension ERC721
* @example
* ```ts
* import { isMintWithSignatureSupported } from "thirdweb/extensions/erc721";
*
* const supported = isMintWithSignatureSupported(["0x..."]);
* ```
*/
export function isMintWithSignatureSupported(availableSelectors: string[]) {
return detectMethod({
availableSelectors,
method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const,
});
}
/**
* Encodes the parameters for the "mintWithSignature" function.
* @param options - The options for the mintWithSignature function.
* @returns The encoded ABI parameters.
* @extension ERC721
* @example
* ```ts
* import { encodeMintWithSignatureParams } from "thirdweb/extensions/erc721";
* const result = encodeMintWithSignatureParams({
* payload: ...,
* signature: ...,
* });
* ```
*/
export function encodeMintWithSignatureParams(
options: MintWithSignatureParams,
) {
return encodeAbiParameters(FN_INPUTS, [options.payload, options.signature]);
}
/**
* Encodes the "mintWithSignature" function into a Hex string with its parameters.
* @param options - The options for the mintWithSignature function.
* @returns The encoded hexadecimal string.
* @extension ERC721
* @example
* ```ts
* import { encodeMintWithSignature } from "thirdweb/extensions/erc721";
* const result = encodeMintWithSignature({
* payload: ...,
* signature: ...,
* });
* ```
*/
export function encodeMintWithSignature(options: MintWithSignatureParams) {
// we do a "manual" concat here to avoid the overhead of the "concatHex" function
// we can do this because we know the specific formats of the values
return (FN_SELECTOR +
encodeMintWithSignatureParams(options).slice(
2,
)) as `${typeof FN_SELECTOR}${string}`;
}
/**
* Prepares a transaction to call the "mintWithSignature" function on the contract.
* @param options - The options for the "mintWithSignature" function.
* @returns A prepared transaction object.
* @extension ERC721
* @example
* ```ts
* import { sendTransaction } from "thirdweb";
* import { mintWithSignature } from "thirdweb/extensions/erc721";
*
* const transaction = mintWithSignature({
* contract,
* payload: ...,
* signature: ...,
* overrides: {
* ...
* }
* });
*
* // Send the transaction
* await sendTransaction({ transaction, account });
* ```
*/
export function mintWithSignature(
options: BaseTransactionOptions<
| MintWithSignatureParams
| {
asyncParams: () => Promise<MintWithSignatureParams>;
}
>,
) {
const asyncOptions = once(async () => {
return "asyncParams" in options ? await options.asyncParams() : options;
});
return prepareContractCall({
contract: options.contract,
method: [FN_SELECTOR, FN_INPUTS, FN_OUTPUTS] as const,
params: async () => {
const resolvedOptions = await asyncOptions();
return [resolvedOptions.payload, resolvedOptions.signature] as const;
},
value: async () => (await asyncOptions()).overrides?.value,
accessList: async () => (await asyncOptions()).overrides?.accessList,
gas: async () => (await asyncOptions()).overrides?.gas,
gasPrice: async () => (await asyncOptions()).overrides?.gasPrice,
maxFeePerGas: async () => (await asyncOptions()).overrides?.maxFeePerGas,
maxPriorityFeePerGas: async () =>
(await asyncOptions()).overrides?.maxPriorityFeePerGas,
nonce: async () => (await asyncOptions()).overrides?.nonce,
extraGas: async () => (await asyncOptions()).overrides?.extraGas,
erc20Value: async () => (await asyncOptions()).overrides?.erc20Value,
});
}
@@ -15,6 +15,10 @@ import {
type MintWithSignatureParams,
mintWithSignature as generatedMintWithSignature,
} from "../__generated__/ISignatureMintERC721/write/mintWithSignature.js";
import {
type MintWithSignatureParams as MintWithSignatureParamsV2,
mintWithSignature as generatedMintWithSignatureV2,
} from "../__generated__/ISignatureMintERC721_v2/write/mintWithSignature.js";
/**
* Mints a new ERC721 token with the given minter signature
@@ -38,6 +42,23 @@ import {
* @returns A promise that resolves to the transaction result.
*/
export function mintWithSignature(
options: BaseTransactionOptions<
| { payload: PayloadTypeV2; signature: Hex }
| { payload: PayloadType; signature: Hex }
>,
) {
const { payload } = options;
if ("quantity" in payload) {
return mintWithSignatureV2(
options as BaseTransactionOptions<MintWithSignatureParamsV2>,
);
}
return mintWithSignatureV1(
options as BaseTransactionOptions<MintWithSignatureParams>,
);
}
function mintWithSignatureV1(
options: BaseTransactionOptions<MintWithSignatureParams>,
) {
const value = isNativeTokenAddress(options.payload.currency)
@@ -51,10 +72,27 @@ export function mintWithSignature(
});
}
export type GenerateMintSignatureOptions = {
function mintWithSignatureV2(
options: BaseTransactionOptions<MintWithSignatureParamsV2>,
) {
const value = isNativeTokenAddress(options.payload.currency)
? options.payload.pricePerToken * options.payload.quantity
: 0n;
return generatedMintWithSignatureV2({
...options,
overrides: {
value,
},
});
}
export type GenerateMintSignatureOptions<
T extends "LoyaltyCard" | "TokenERC721" = "TokenERC721",
> = {
account: Account;
contract: ThirdwebContract;
mintRequest: GeneratePayloadInput;
contractType?: T;
};
/**
@@ -87,10 +125,10 @@ export type GenerateMintSignatureOptions = {
* @extension ERC721
* @returns A promise that resolves to the payload and signature.
*/
export async function generateMintSignature(
options: GenerateMintSignatureOptions,
) {
const { mintRequest, account, contract } = options;
export async function generateMintSignature<
T extends "LoyaltyCard" | "TokenERC721" = "TokenERC721",
>(options: GenerateMintSignatureOptions<T>): Promise<SignPayloadResult<T>> {
const { mintRequest, account, contract, contractType } = options;
const currency = mintRequest.currency || NATIVE_TOKEN_ADDRESS;
const [price, uri, uid] = await Promise.all([
@@ -175,15 +213,70 @@ export async function generateMintSignature(
royaltyRecipient = mintRequest.royaltyRecipient;
}
if (contractType === "LoyaltyCard") {
return signPayloadV2({
mintRequest,
account,
contract,
uri,
currency,
uid,
price,
royaltyRecipient,
primarySaleRecipient: saleRecipient,
startTime,
endTime,
}) as Promise<SignPayloadResult<T>>;
}
return signPayloadV1({
mintRequest,
account,
contract,
uri,
currency,
uid,
price,
royaltyRecipient,
primarySaleRecipient: saleRecipient,
startTime,
endTime,
}) as Promise<SignPayloadResult<T>>;
}
async function signPayloadV1({
mintRequest,
account,
contract,
uri,
currency,
uid,
price,
royaltyRecipient,
primarySaleRecipient,
startTime,
endTime,
}: {
mintRequest: GeneratePayloadInput;
account: Account;
contract: ThirdwebContract;
uri: string;
currency: Address;
uid: Hex;
price: bigint;
royaltyRecipient: Address;
primarySaleRecipient: Address;
startTime: Date;
endTime: Date;
}): Promise<{ payload: PayloadType; signature: Hex }> {
const payload: PayloadType = {
uri,
currency,
uid,
price,
to: mintRequest.to,
royaltyRecipient: royaltyRecipient,
royaltyRecipient,
royaltyBps: toBigInt(mintRequest.royaltyBps || 0),
primarySaleRecipient: saleRecipient,
primarySaleRecipient,
validityStartTimestamp: dateToSeconds(startTime),
validityEndTimestamp: dateToSeconds(endTime),
};
@@ -195,19 +288,86 @@ export async function generateMintSignature(
chainId: contract.chain.id,
verifyingContract: contract.address,
},
types: { MintRequest: MintRequest721 },
types: {
MintRequest: MintRequest721,
},
primaryType: "MintRequest",
message: payload,
});
return { payload, signature };
}
async function signPayloadV2({
mintRequest,
account,
contract,
uri,
currency,
uid,
price,
royaltyRecipient,
primarySaleRecipient,
startTime,
endTime,
}: {
mintRequest: GeneratePayloadInput;
account: Account;
contract: ThirdwebContract;
uri: string;
currency: Address;
uid: Hex;
price: bigint;
royaltyRecipient: Address;
primarySaleRecipient: Address;
startTime: Date;
endTime: Date;
}): Promise<{ payload: PayloadTypeV2; signature: Hex }> {
const payload: PayloadTypeV2 = {
uri,
currency,
uid,
quantity: toBigInt(1), // always 1 for 721 NFTs
pricePerToken: price,
to: mintRequest.to,
royaltyRecipient,
royaltyBps: toBigInt(mintRequest.royaltyBps || 0),
primarySaleRecipient,
validityStartTimestamp: dateToSeconds(startTime),
validityEndTimestamp: dateToSeconds(endTime),
};
const signature = await account.signTypedData({
domain: {
name: "SignatureMintERC721",
version: "1",
chainId: contract.chain.id,
verifyingContract: contract.address,
},
types: {
MintRequest: MintRequest721_V2,
},
primaryType: "MintRequest",
message: payload,
});
return { payload, signature };
}
type SignPayloadResult<T> = T extends "LoyaltyCard"
? Awaited<ReturnType<typeof signPayloadV2>>
: Awaited<ReturnType<typeof signPayloadV1>>;
type PayloadType = AbiParameterToPrimitiveType<{
type: "tuple";
name: "payload";
components: typeof MintRequest721;
}>;
type PayloadTypeV2 = AbiParameterToPrimitiveType<{
type: "tuple";
name: "payload";
components: typeof MintRequest721_V2;
}>;
type GeneratePayloadInput = {
to: string;
metadata: NFTInput | string;
@@ -234,3 +394,19 @@ const MintRequest721 = [
{ name: "validityEndTimestamp", type: "uint128" },
{ name: "uid", type: "bytes32" },
] as const;
// used for LoyaltyCard contract and base sigmint contracts
// adds quantity to the payload so its the same as 1155
const MintRequest721_V2 = [
{ name: "to", type: "address" },
{ name: "royaltyRecipient", type: "address" },
{ name: "royaltyBps", type: "uint256" },
{ name: "primarySaleRecipient", type: "address" },
{ name: "uri", type: "string" },
{ name: "quantity", type: "uint256" },
{ name: "pricePerToken", type: "uint256" },
{ name: "currency", type: "address" },
{ name: "validityStartTimestamp", type: "uint128" },
{ name: "validityEndTimestamp", type: "uint128" },
{ name: "uid", type: "bytes32" },
] as const;
@@ -60,6 +60,48 @@ describe.runIf(process.env.TW_SECRET_KEY)(
});
}, 60000);
it("should generate a mint signature and mint an NFT for LoyaltyContract", async () => {
const loyaltyContract = getContract({
address: await deployERC721Contract({
account: TEST_ACCOUNT_A,
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
params: {
name: "Test",
symbol: "TST",
royaltyRecipient: TEST_ACCOUNT_C.address,
saleRecipient: TEST_ACCOUNT_B.address,
},
type: "LoyaltyCard",
}),
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
});
const { payload, signature } = await generateMintSignature({
mintRequest: {
to: TEST_ACCOUNT_B.address,
metadata: {
name: "My NFT",
description: "This is my NFT",
image: "https://example.com/image.png",
},
},
account: TEST_ACCOUNT_A,
contract: loyaltyContract,
contractType: "LoyaltyCard",
});
const transaction = mintWithSignature({
contract: loyaltyContract,
payload,
signature,
});
const { transactionHash } = await sendTransaction({
transaction,
account: TEST_ACCOUNT_A,
});
expect(transactionHash.length).toBe(66);
});
it("should generate a mint signature and mint an NFT", async () => {
const { payload, signature } = await generateMintSignature({
mintRequest: {
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { base } from "../../chains/chain-definitions/base.js";
@@ -92,4 +92,24 @@ describe.runIf(process.env.TW_SECRET_KEY)("Pay: crypto-to-fiat", () => {
`Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`,
);
});
it("should throw if response is not OK", async () => {
global.fetch = vi.fn();
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
statusText: "Bad Request",
});
await expect(() =>
convertCryptoToFiat({
chain: base,
fromTokenAddress: NATIVE_TOKEN_ADDRESS,
fromAmount: 1,
to: "USD",
client: TEST_CLIENT,
}),
).rejects.toThrowError(
`Failed to fetch USD value for token (${NATIVE_TOKEN_ADDRESS}) on chainId: ${base.id}`,
);
vi.restoreAllMocks();
});
});
@@ -7,6 +7,7 @@ import { getContract } from "../../contract/contract.js";
import { isAddress } from "../../utils/address.js";
import { getClientFetch } from "../../utils/fetch.js";
import { getPayConvertCryptoToFiatEndpoint } from "../utils/definitions.js";
import type { SupportedFiatCurrency } from "./type.js";
/**
* Props for the `convertCryptoToFiat` function
@@ -31,7 +32,7 @@ export type ConvertCryptoToFiatParams = {
* The fiat symbol. e.g "USD"
* Only USD is supported at the moment.
*/
to: "USD";
to: SupportedFiatCurrency;
};
/**
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { base } from "../../chains/chain-definitions/base.js";
@@ -93,4 +93,24 @@ describe.runIf(process.env.TW_SECRET_KEY)("Pay: fiatToCrypto", () => {
`Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`,
);
});
it("should throw if response is not OK", async () => {
global.fetch = vi.fn();
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
statusText: "Bad Request",
});
await expect(() =>
convertFiatToCrypto({
chain: ethereum,
to: NATIVE_TOKEN_ADDRESS,
fromAmount: 1,
from: "USD",
client: TEST_CLIENT,
}),
).rejects.toThrowError(
`Failed to convert USD value to token (${NATIVE_TOKEN_ADDRESS}) on chainId: 1`,
);
vi.restoreAllMocks();
});
});
@@ -7,6 +7,7 @@ import { getContract } from "../../contract/contract.js";
import { isAddress } from "../../utils/address.js";
import { getClientFetch } from "../../utils/fetch.js";
import { getPayConvertFiatToCryptoEndpoint } from "../utils/definitions.js";
import type { SupportedFiatCurrency } from "./type.js";
/**
* Props for the `convertFiatToCrypto` function
@@ -18,7 +19,7 @@ export type ConvertFiatToCryptoParams = {
* The fiat symbol. e.g: "USD"
* Currently only USD is supported.
*/
from: "USD";
from: SupportedFiatCurrency;
/**
* The total amount of fiat to convert
* e.g: If you want to convert 2 cents to USD, enter `0.02`
@@ -101,7 +102,7 @@ export async function convertFiatToCrypto(
const response = await getClientFetch(client)(url);
if (!response.ok) {
throw new Error(
`Failed to convert ${to} value to token (${to}) on chainId: ${chain.id}`,
`Failed to convert ${from} value to token (${to}) on chainId: ${chain.id}`,
);
}
@@ -0,0 +1,5 @@
const SUPPORTED_FIAT_CURRENCIES = ["USD"] as const;
/**
* @internal
*/
export type SupportedFiatCurrency = (typeof SUPPORTED_FIAT_CURRENCIES)[number];
@@ -3,6 +3,7 @@ import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../pay/buyWithFiat/getStatus.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import type { FiatProvider } from "../../../../pay/utils/commonTypes.js";
import type { AssetTabs } from "../../../../react/web/ui/ConnectWallet/screens/ViewAssets.js";
import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
@@ -320,6 +321,12 @@ export type ConnectButton_detailsModalOptions = {
* Note: If an empty array is passed, the [View Funds] button will be hidden
*/
assetTabs?: AssetTabs[];
/**
* Show the token balance's value in fiat.
* Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown.
*/
showBalanceInFiat?: SupportedFiatCurrency;
};
/**
@@ -377,6 +384,12 @@ export type ConnectButton_detailsButtonOptions = {
* Use custom avatar URL for the connected wallet image in the `ConnectButton` details button, overriding ENS avatar or Blobbie icon.
*/
connectedAccountAvatarUrl?: string;
/**
* Show the token balance's value in fiat.
* Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown.
*/
showBalanceInFiat?: SupportedFiatCurrency;
};
/**
@@ -50,4 +50,19 @@ describe("ConnectButton", () => {
);
expect(screen.getByText("Sign In")).toBeInTheDocument();
});
it("should render with fiat balance props", () => {
render(
<ConnectButton
client={TEST_CLIENT}
detailsButton={{
showBalanceInFiat: "USD",
}}
detailsModal={{
showBalanceInFiat: "USD",
}}
/>,
);
expect(screen.getByText("Connect Wallet")).toBeInTheDocument();
});
});
@@ -0,0 +1,587 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FC } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VITALIK_WALLET } from "~test/addresses.js";
import {
fireEvent,
render,
renderHook,
screen,
waitFor,
} from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { base } from "../../../../chains/chain-definitions/base.js";
import { ethereum } from "../../../../chains/chain-definitions/ethereum.js";
import { useActiveAccount } from "../../../../react/core/hooks/wallets/useActiveAccount.js";
import { useActiveWalletChain } from "../../../../react/core/hooks/wallets/useActiveWalletChain.js";
import { ThirdwebProvider } from "../../providers/thirdweb-provider.js";
import { AccountProvider } from "../prebuilt/Account/provider.js";
import {
ConnectedToSmartWallet,
ConnectedWalletDetails,
DetailsModal,
InAppWalletUserInfo,
SwitchNetworkButton,
detailsBtn_formatFiatBalanceForButton,
detailsBtn_formatTokenBalanceForButton,
useWalletDetailsModal,
} from "./Details.js";
import en from "./locale/en.js";
import { getConnectLocale } from "./locale/getConnectLocale.js";
/**
* Tests for the Details button and Details Modal (parts of the ConnectButton component)
*/
const queryClient = new QueryClient();
const client = TEST_CLIENT;
vi.mock("../../../core/hooks/wallets/useActiveAccount.js", () => ({
useActiveAccount: vi.fn(),
}));
const mockDetailsModalOptions = {};
const mockSupportedTokens = {};
const mockSupportedNFTs = {};
// biome-ignore lint/suspicious/noExplicitAny: Mock
const mockChains: any[] = [];
const mockDisplayBalanceToken = {};
const mockConnectOptions = {};
// biome-ignore lint/suspicious/noExplicitAny: Mock
const mockAssetTabs: any[] = [];
const mockOnDisconnect = vi.fn();
describe("Details button", () => {
it("should render (when a wallet is connected)", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={undefined}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName("tw-connected-wallet");
expect(!!elements.length).toBe(true);
});
it("should render with showBalanceInFiat", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
showBalanceInFiat: "USD",
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName("tw-connected-wallet");
expect(!!elements.length).toBe(true);
});
it("should render with custom UI from the `render` prop", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
render: () => <p className="thirdweb_tw" />,
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const element = container.querySelector("p.thirdweb_tw");
expect(element).not.toBe(null);
});
it("should render style properly", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
style: {
color: "red",
width: "4444px",
},
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const element = container.querySelector(
'[data-test="connected-wallet-details"]',
);
if (!element) {
throw new Error("Details button not rendered properly");
}
const styles = window.getComputedStyle(element);
expect(styles.color).toBe("red");
expect(styles.width).toBe("4444px");
});
it("should render the Balance section", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={undefined}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName(
"tw-connected-wallet__balance",
);
expect(!!elements.length).toBe(true);
});
it("should render the Address section if detailsButton.connectedAccountName is not passed", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={undefined}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName(
"tw-connected-wallet__address",
);
expect(!!elements.length).toBe(true);
});
it("should NOT render the Address section if detailsButton.connectedAccountName is passed", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
connectedAccountName: "test name",
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName(
"tw-connected-wallet__address",
);
expect(elements.length).toBe(1);
expect(elements[0]?.innerHTML).toBe("test name");
});
it("should render a custom img if detailsButton?.connectedAccountAvatarUrl is passed", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
connectedAccountAvatarUrl: "https://thirdweb.com/cat.png",
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByTagName("img");
expect(elements.length).toBe(1);
expect(elements[0]?.src).toBe("https://thirdweb.com/cat.png");
});
it("should render AccountAvatar if no custom image is passed", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
connectedAccountName: "test name",
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={undefined}
client={client}
connectLocale={en}
connectOptions={undefined}
/>
</AccountProvider>
</QueryClientProvider>,
);
const elements = container.getElementsByClassName(
"tw-connected-wallet__account_avatar",
);
expect(elements.length).toBe(1);
});
it("should render the SwitchNetworkButton if chain is mismatched", () => {
vi.mock(
"../../../../react/core/hooks/wallets/useActiveWalletChain.js",
() => ({
useActiveWalletChain: vi.fn(),
}),
);
vi.mocked(useActiveWalletChain).mockReturnValue(base);
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={client}>
<ConnectedWalletDetails
theme={"dark"}
detailsButton={{
connectedAccountName: "test name",
}}
detailsModal={undefined}
supportedTokens={undefined}
supportedNFTs={undefined}
onDisconnect={() => {}}
chains={[]}
switchButton={{
style: {
color: "red",
},
className: "thirdwebSwitchBtn",
label: "switchbtn",
}}
client={client}
connectLocale={en}
connectOptions={undefined}
chain={ethereum}
/>
</AccountProvider>
</QueryClientProvider>,
);
const element = container.querySelector(
"button.tw-connect-wallet--switch-network",
);
expect(element).not.toBe(null);
const element2 = container.querySelector("button.thirdwebSwitchBtn");
expect(element2).not.toBe(null);
expect(element && element.innerHTML === "switchbtn").toBe(true);
vi.resetAllMocks();
});
it("should render the fiat value properly", () => {
expect(
detailsBtn_formatFiatBalanceForButton({ balance: 12.9231, symbol: "$" }),
).toBe(" ($13)");
});
it("should render the token balance properly", () => {
expect(
detailsBtn_formatTokenBalanceForButton({
balance: 12.923111,
symbol: "ETH",
}),
).toBe("12.9231 ETH");
});
});
const thirdwebWrapper: FC = ({ children }: React.PropsWithChildren) => (
<ThirdwebProvider>{children}</ThirdwebProvider>
);
/**
* useWalletDetailsModal
*/
describe("useWalletDetailsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return an object with an open function", () => {
const { result } = renderHook(() => useWalletDetailsModal(), {
wrapper: thirdwebWrapper,
});
expect(result.current).toHaveProperty("open");
expect(typeof result.current.open).toBe("function");
});
it("should throw an error when opening modal without a connected wallet", () => {
const { result } = renderHook(() => useWalletDetailsModal(), {
wrapper: thirdwebWrapper,
});
expect(() =>
result.current.open({
client,
}),
).toThrow("Wallet is not connected.");
});
});
/**
* SwitchNetworkButton
*/
describe("SwitchNetworkButton", () => {
it("should render a default button", () => {
const { container } = render(
<SwitchNetworkButton targetChain={ethereum} connectLocale={en} />,
);
const element = container.querySelector(
"button.tw-connect-wallet--switch-network",
);
expect(element).not.toBe(null);
});
it("should apply the style properly", () => {
const { container } = render(
<SwitchNetworkButton
targetChain={ethereum}
connectLocale={en}
style={{ color: "red", width: "4444px" }}
/>,
);
const element = container.querySelector(
"button.tw-connect-wallet--switch-network",
);
if (!element) {
throw new Error("Failed to render SwitchNetworkButton");
}
const styles = window.getComputedStyle(element);
expect(styles.color).toBe("red");
expect(styles.width).toBe("4444px");
});
it("should apply the className properly", () => {
const { container } = render(
<SwitchNetworkButton
targetChain={ethereum}
connectLocale={en}
className="thirdwebRocks"
/>,
);
const element = container.querySelector("button.thirdwebRocks");
expect(element).not.toBe(null);
});
it("should render button's text with locale.switchNetwork by default", () => {
const { container } = render(
<SwitchNetworkButton targetChain={ethereum} connectLocale={en} />,
);
const element = container.querySelector(
"button.tw-connect-wallet--switch-network",
);
if (!element) {
throw new Error("Failed to render SwitchNetworkButton");
}
expect(element.innerHTML).toBe(en.switchNetwork);
});
it("should render `switchNetworkBtnTitle` properly", () => {
const { container } = render(
<SwitchNetworkButton
targetChain={ethereum}
connectLocale={en}
switchNetworkBtnTitle="cat"
/>,
);
const element = container.querySelector(
"button.tw-connect-wallet--switch-network",
);
if (!element) {
throw new Error("Failed to render SwitchNetworkButton");
}
expect(element.innerHTML).toBe("cat");
});
});
describe("ConnectedToSmartWallet", () => {
it("should render nothing since no active wallet exists in default test env", () => {
const { container } = render(
<ThirdwebProvider>
<ConnectedToSmartWallet client={client} connectLocale={en} />
</ThirdwebProvider>,
);
// no smart wallet exists in this env so this component should render null
const element = container.querySelector("span");
expect(element).toBe(null);
});
});
describe("InAppWalletUserInfo", () => {
it("should render a Skeleton since no active wallet exists in default test env", () => {
const { container } = render(
<ThirdwebProvider>
<InAppWalletUserInfo client={client} locale={en} />
</ThirdwebProvider>,
);
// no smart wallet exists in this env so this component should render null
const element = container.querySelector(
"div.InAppWalletUserInfo__skeleton",
);
expect(element).not.toBe(null);
});
});
describe("Details Modal", () => {
beforeEach(() => {
// Mock the animate method
HTMLDivElement.prototype.animate = vi.fn().mockReturnValue({
onfinish: vi.fn(),
});
});
it("should close the modal when activeAccount is falsy", async () => {
const closeModalMock = vi.fn();
const locale = await getConnectLocale("en_US");
vi.mocked(useActiveAccount).mockReturnValue(undefined);
render(
<DetailsModal
client={TEST_CLIENT}
locale={locale}
detailsModal={mockDetailsModalOptions}
theme="light"
supportedTokens={mockSupportedTokens}
supportedNFTs={mockSupportedNFTs}
closeModal={closeModalMock}
onDisconnect={mockOnDisconnect}
chains={mockChains}
displayBalanceToken={mockDisplayBalanceToken}
connectOptions={mockConnectOptions}
assetTabs={mockAssetTabs}
/>,
);
await waitFor(() => {
expect(closeModalMock).toHaveBeenCalled();
});
});
it("should render the DetailsModal with default props", async () => {
const closeModalMock = vi.fn();
const locale = await getConnectLocale("en_US");
render(
<DetailsModal
client={TEST_CLIENT}
locale={locale}
theme="light"
closeModal={closeModalMock}
onDisconnect={mockOnDisconnect}
chains={mockChains}
connectOptions={mockConnectOptions}
/>,
);
// Add assertions to check if the modal is rendered correctly
expect(screen.getByText("Connect Modal")).toBeInTheDocument();
});
it("should call closeModal when the close button is clicked", async () => {
const closeModalMock = vi.fn();
const locale = await getConnectLocale("en_US");
render(
<DetailsModal
client={TEST_CLIENT}
locale={locale}
theme="light"
closeModal={closeModalMock}
onDisconnect={mockOnDisconnect}
chains={mockChains}
connectOptions={mockConnectOptions}
/>,
);
// Simulate clicking the close button
fireEvent.click(screen.getByRole("button", { name: /close/i }));
await waitFor(() => {
expect(closeModalMock).toHaveBeenCalled();
});
});
});
@@ -9,15 +9,15 @@ import {
TextAlignJustifyIcon,
} from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { type JSX, useContext, useEffect, useState } from "react";
import { type JSX, useCallback, useContext, useEffect, useState } from "react";
import { trackPayEvent } from "../../../../analytics/track/pay.js";
import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { getContract } from "../../../../contract/contract.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import { getLastAuthProvider } from "../../../../react/core/utils/storage.js";
import { shortenAddress } from "../../../../utils/address.js";
import { isContractDeployed } from "../../../../utils/bytecode/is-contract-deployed.js";
import { formatNumber } from "../../../../utils/formatNumber.js";
import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js";
import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js";
@@ -86,7 +86,12 @@ import { fadeInAnimation } from "../design-system/animations.js";
import { StyledButton } from "../design-system/elements.js";
import { AccountAddress } from "../prebuilt/Account/address.js";
import { AccountAvatar } from "../prebuilt/Account/avatar.js";
import { AccountBalance } from "../prebuilt/Account/balance.js";
import {
AccountBalance,
type AccountBalanceInfo,
formatAccountFiatBalance,
formatAccountTokenBalance,
} from "../prebuilt/Account/balance.js";
import { AccountBlobbie } from "../prebuilt/Account/blobbie.js";
import { AccountName } from "../prebuilt/Account/name.js";
import { AccountProvider } from "../prebuilt/Account/provider.js";
@@ -144,7 +149,6 @@ export const ConnectedWalletDetails: React.FC<{
}> = (props) => {
const { connectLocale: locale, client } = props;
const setRootEl = useContext(SetRootElementContext);
const activeAccount = useActiveAccount();
const walletChain = useActiveWalletChain();
function closeModal() {
@@ -196,6 +200,9 @@ export const ConnectedWalletDetails: React.FC<{
const combinedClassName = `${TW_CONNECTED_WALLET} ${props.detailsButton?.className || ""}`;
const tokenAddress =
props.detailsButton?.displayBalanceToken?.[Number(walletChain?.id)];
return (
<WalletInfoButton
type="button"
@@ -223,16 +230,30 @@ export const ConnectedWalletDetails: React.FC<{
}}
/>
) : (
activeAccount && (
<AccountAvatar
loadingComponent={<AccountBlobbie size={35} />}
fallbackComponent={<AccountBlobbie size={35} />}
queryOptions={{
refetchOnWindowFocus: false,
refetchOnMount: false,
}}
/>
)
<AccountAvatar
className={`${TW_CONNECTED_WALLET}__account_avatar`}
loadingComponent={
<AccountBlobbie
size={35}
className={`${TW_CONNECTED_WALLET}__account_avatar`}
/>
}
fallbackComponent={
<AccountBlobbie
size={35}
className={`${TW_CONNECTED_WALLET}__account_avatar`}
/>
}
queryOptions={{
refetchOnWindowFocus: false,
refetchOnMount: false,
}}
style={{
height: "100%",
width: "100%",
objectFit: "cover",
}}
/>
)}
</Container>
<Container
@@ -273,25 +294,71 @@ export const ConnectedWalletDetails: React.FC<{
size="xs"
color="secondaryText"
weight={400}
style={{
display: "flex",
gap: "2px",
alignItems: "center",
}}
>
<AccountBalance
chain={walletChain}
loadingComponent={<Skeleton height={fontSize.xs} width="70px" />}
fallbackComponent={<Skeleton height={fontSize.xs} width="70px" />}
formatFn={formatBalanceOnButton}
tokenAddress={
props.detailsButton?.displayBalanceToken?.[
Number(walletChain?.id)
]
}
/>
{props.detailsButton?.showBalanceInFiat ? (
<>
<AccountBalance
chain={walletChain}
loadingComponent={
<Skeleton height={fontSize.xs} width="50px" />
}
fallbackComponent={
<Skeleton height={fontSize.xs} width="50px" />
}
tokenAddress={tokenAddress}
/>
<AccountBalance
chain={walletChain}
tokenAddress={tokenAddress}
showBalanceInFiat="USD"
formatFn={detailsBtn_formatFiatBalanceForButton}
loadingComponent={
<Skeleton height={fontSize.xs} width="20px" />
}
/>
</>
) : (
<AccountBalance
chain={walletChain}
loadingComponent={<Skeleton height={fontSize.xs} width="70px" />}
fallbackComponent={<Skeleton height={fontSize.xs} width="70px" />}
formatFn={detailsBtn_formatTokenBalanceForButton}
tokenAddress={tokenAddress}
/>
)}
</Text>
</Container>
</WalletInfoButton>
);
};
function DetailsModal(props: {
/**
* @internal Exported for tests
*/
export function detailsBtn_formatFiatBalanceForButton(
props: AccountBalanceInfo,
) {
return ` (${formatAccountFiatBalance({ ...props, decimals: 0 })})`;
}
/**
* @internal Exported for test
*/
export function detailsBtn_formatTokenBalanceForButton(
props: AccountBalanceInfo,
) {
return `${formatAccountTokenBalance({ ...props, decimals: props.balance < 1 ? 5 : 4 })}`;
}
/**
* @internal Exported for tests only
*/
export function DetailsModal(props: {
client: ThirdwebClient;
locale: ConnectLocale;
detailsModal?: ConnectButton_detailsModalOptions;
@@ -307,6 +374,7 @@ function DetailsModal(props: {
displayBalanceToken?: Record<number, string>;
connectOptions: DetailsModalConnectOptions | undefined;
assetTabs?: AssetTabs[];
showBalanceInFiat?: SupportedFiatCurrency;
}) {
const [screen, setScreen] = useState<WalletDetailsModalScreen>("main");
const { disconnect } = useDisconnect();
@@ -330,12 +398,12 @@ function DetailsModal(props: {
wallets: activeWallet ? [activeWallet] : [],
});
function closeModal() {
const closeModal = useCallback(() => {
setIsOpen(false);
onModalUnmount(() => {
props.closeModal();
});
}
}, [props.closeModal]);
function handleDisconnect(info: { wallet: Wallet; account: Account }) {
setIsOpen(false);
@@ -343,6 +411,12 @@ function DetailsModal(props: {
props.onDisconnect(info);
}
useEffect(() => {
if (!activeAccount) {
closeModal();
}
}, [activeAccount, closeModal]);
const networkSwitcherButton = (
<MenuButton
type="button"
@@ -377,15 +451,44 @@ function DetailsModal(props: {
<Text color="primaryText" size="md" multiline>
{chainNameQuery.name || `Unknown chain #${walletChain?.id}`}
<Text color="secondaryText" size="xs">
<AccountBalance
fallbackComponent={<Skeleton height="1em" width="100px" />}
loadingComponent={<Skeleton height="1em" width="100px" />}
formatFn={(num: number) => formatNumber(num, 9)}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
/>
{props.showBalanceInFiat ? (
<>
<AccountBalance
fallbackComponent={<Skeleton height="1em" width="70px" />}
loadingComponent={<Skeleton height="1em" width="70px" />}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
formatFn={(props: AccountBalanceInfo) =>
formatAccountTokenBalance({ ...props, decimals: 7 })
}
/>{" "}
<AccountBalance
loadingComponent={<Skeleton height="1em" width="30px" />}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
formatFn={(props: AccountBalanceInfo) =>
` (${formatAccountFiatBalance({ ...props, decimals: 3 })})`
}
showBalanceInFiat="USD"
/>
</>
) : (
<AccountBalance
fallbackComponent={<Skeleton height="1em" width="100px" />}
loadingComponent={<Skeleton height="1em" width="100px" />}
formatFn={(props: AccountBalanceInfo) =>
formatAccountTokenBalance({ ...props, decimals: 7 })
}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
/>
)}
</Text>
</Text>
)}
@@ -437,6 +540,11 @@ function DetailsModal(props: {
<AccountAvatar
loadingComponent={<AccountBlobbie size={Number(iconSize.xxl)} />}
fallbackComponent={<AccountBlobbie size={Number(iconSize.xxl)} />}
style={{
height: "100%",
width: "100%",
objectFit: "cover",
}}
/>
)
)}
@@ -470,7 +578,7 @@ function DetailsModal(props: {
);
let content = (
<div>
<div className={`${TW_CONNECTED_WALLET}__default_modal_screen`}>
<Spacer y="xs" />
<Container
px="lg"
@@ -691,20 +799,6 @@ function DetailsModal(props: {
<Text color="primaryText">{props.locale.manageWallet.title}</Text>
</MenuButton>
{/* Switch to Personal Wallet */}
{/* {personalWallet &&
!props.detailsModal?.hideSwitchToPersonalWallet && (
<AccountSwitcher
wallet={personalWallet}
name={locale.personalWallet}
/>
)} */}
{/* Switch to Smart Wallet */}
{/* {smartWallet && (
<AccountSwitcher name={locale.smartWallet} wallet={smartWallet} />
)} */}
{/* Request Testnet funds */}
{(props.detailsModal?.showTestnetFaucet ?? false) &&
(chainFaucetsQuery.faucets.length > 0 ||
@@ -993,12 +1087,11 @@ function DetailsModal(props: {
}
}}
>
<AccountProvider
address={activeAccount?.address || ""}
client={client}
>
{content}
</AccountProvider>
{activeAccount?.address && (
<AccountProvider address={activeAccount.address} client={client}>
{content}
</AccountProvider>
)}
</Modal>
</ScreenSetupContext.Provider>
</WalletUIStatesProvider>
@@ -1006,10 +1099,6 @@ function DetailsModal(props: {
);
}
function formatBalanceOnButton(num: number) {
return formatNumber(num, num < 1 ? 5 : 4);
}
const WalletInfoButton = /* @__PURE__ */ StyledButton((_) => {
const theme = useCustomTheme();
return {
@@ -1045,7 +1134,10 @@ const StyledChevronRightIcon = /* @__PURE__ */ styled(
};
});
function ConnectedToSmartWallet(props: {
/**
* @internal Exported for test
*/
export function ConnectedToSmartWallet(props: {
client: ThirdwebClient;
connectLocale: ConnectLocale;
}) {
@@ -1104,7 +1196,10 @@ function ConnectedToSmartWallet(props: {
return null;
}
function InAppWalletUserInfo(props: {
/**
* @internal Exported for tests
*/
export function InAppWalletUserInfo(props: {
client: ThirdwebClient;
locale: ConnectLocale;
}) {
@@ -1191,13 +1286,19 @@ function InAppWalletUserInfo(props: {
);
}
return <Skeleton width="50px" height="10px" />;
return (
<Skeleton
width="50px"
height="10px"
className="InAppWalletUserInfo__skeleton"
/>
);
}
/**
* @internal
* @internal Exported for tests
*/
function SwitchNetworkButton(props: {
export function SwitchNetworkButton(props: {
style?: React.CSSProperties;
className?: string;
switchNetworkBtnTitle?: string;
@@ -1507,6 +1608,12 @@ export type UseWalletDetailsModalOptions = {
* Note: If an empty array is passed, the [View Funds] button will be hidden
*/
assetTabs?: AssetTabs[];
/**
* Show the token balance's value in fiat.
* Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown.
*/
showBalanceInFiat?: SupportedFiatCurrency;
};
/**
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "../../../../../../test/src/react-render.js";
import { useSocialProfiles } from "../../../../core/social/useSocialProfiles.js";
import { useProfiles } from "../../../hooks/wallets/useProfiles.js";
import { LinkedProfilesScreen } from "./LinkedProfilesScreen.jsx";
// Mock the hooks
vi.mock("../../../hooks/wallets/useProfiles");
vi.mock("../../../../core/social/useSocialProfiles");
vi.mock("../../components/Img", () => ({
Img: () => <div data-testid="mock-img">Mock Image</div>,
}));
describe("LinkedProfilesScreen", () => {
const mockClient = {
clientId: "test-client-id",
secretKey: undefined,
};
const mockProps = {
onBack: vi.fn(),
setScreen: vi.fn(),
locale: {
manageWallet: {
linkedProfiles: "Linked Profiles",
linkProfile: "Link Profile",
},
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any,
client: mockClient,
};
beforeEach(() => {
vi.mocked(useSocialProfiles).mockReturnValue({
data: undefined,
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
});
describe("getProfileDisplayName", () => {
it("should display email for email profile type", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "email", details: { email: "[email protected]" } }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("[email protected]")).toBeInTheDocument();
});
it("should display email for google profile type", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "google", details: { email: "[email protected]" } }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("[email protected]")).toBeInTheDocument();
});
it("should display phone number for phone profile type", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "phone", details: { phone: "+1234567890" } }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("+1234567890")).toBeInTheDocument();
});
it("should display shortened address when address is present", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [
{
type: "wallet",
details: { address: "0x1234567890abcdef1234567890abcdef12345678" },
},
],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("0x123456...345678")).toBeInTheDocument();
});
it("should display email for cognito profile type", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "cognito", details: { email: "[email protected]" } }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("[email protected]")).toBeInTheDocument();
});
it("should display Custom Profile for custom_auth_endpoint", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "Custom_auth_endpoint", details: {} }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("Custom Profile")).toBeInTheDocument();
});
it("should capitalize unknown profile types", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "unknown", details: {} }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("Unknown")).toBeInTheDocument();
});
it("should not display guest profiles", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "guest", details: {} }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.queryByText("Guest")).not.toBeInTheDocument();
});
});
});
@@ -33,6 +33,8 @@ function getProfileDisplayName(profile: Profile) {
case (profile.type as string) === "cognito" &&
profile.details.email !== undefined:
return profile.details.email;
case (profile.type as string).toLowerCase() === "custom_auth_endpoint":
return "Custom Profile";
default:
return profile.type.slice(0, 1).toUpperCase() + profile.type.slice(1);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { render } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { createWallet } from "../../../../../wallets/create-wallet.js";
import en from "../locale/en.js";
import { PrivateKey } from "./PrivateKey.js";
const client = TEST_CLIENT;
describe("PrivateKey screen", () => {
it("should render the iframe", () => {
const { container } = render(
<PrivateKey
onBack={() => {}}
client={client}
connectLocale={en}
theme="dark"
wallet={createWallet("io.metamask")}
/>,
);
const iframe = container.querySelector("iframe");
expect(iframe).not.toBe(null);
});
it("should throw an error if no wallet is specified", () => {
expect(() =>
render(
<PrivateKey
onBack={() => {}}
client={client}
connectLocale={en}
theme="dark"
/>,
),
).toThrowError("[PrivateKey] No wallet found");
});
it("should render the modal title", () => {
const { container } = render(
<PrivateKey
onBack={() => {}}
client={client}
connectLocale={en}
theme="dark"
wallet={createWallet("io.metamask")}
/>,
);
const element = container.querySelector("h2");
expect(element).not.toBe(null);
expect(element?.innerHTML).toBe(en.manageWallet.exportPrivateKey);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { render } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import en from "../locale/en.js";
import { ReceiveFunds } from "./ReceiveFunds.js";
const client = TEST_CLIENT;
describe("ReceiveFunds screen", () => {
it("should render a title with locale.title", () => {
const { container } = render(
<ReceiveFunds onBack={() => {}} client={client} connectLocale={en} />,
);
const element = container.querySelector("h2");
expect(element).not.toBe(null);
expect(element?.innerHTML).toBe(en.receiveFundsScreen.title);
});
it("should render a span with locale.instruction", () => {
const { container } = render(
<ReceiveFunds onBack={() => {}} client={client} connectLocale={en} />,
);
const element = container.querySelector(
"span.receive_fund_screen_instruction",
);
expect(element).not.toBe(null);
expect(element?.innerHTML).toBe(en.receiveFundsScreen.instruction);
});
it("should render the CopyIcon", () => {
const { container } = render(
<ReceiveFunds onBack={() => {}} client={client} connectLocale={en} />,
);
const element = container.querySelector("svg.tw-copy-icon");
expect(element).not.toBe(null);
});
});
@@ -70,7 +70,12 @@ export function ReceiveFunds(props: {
<Spacer y="lg" />
<Text multiline center balance>
<Text
multiline
center
balance
className="receive_fund_screen_instruction"
>
{locale.instruction}
</Text>
</Container>
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { base } from "../../../../../chains/chain-definitions/base.js";
import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js";
import en from "../locale/en.js";
import { SendFundsForm } from "./SendFunds.js";
const client = TEST_CLIENT;
describe("SendFunds screen", () => {
it("should render a title with locale.title", () => {
vi.mock(
"../../../../../react/core/hooks/wallets/useActiveWalletChain.js",
() => ({
useActiveWalletChain: vi.fn(),
}),
);
vi.mocked(useActiveWalletChain).mockReturnValue(base);
const { container } = render(
<SendFundsForm
token={{ nativeToken: true }}
onTokenSelect={() => {}}
receiverAddress={TEST_ACCOUNT_A.address}
setReceiverAddress={() => {}}
amount={"1"}
setAmount={() => {}}
onBack={() => {}}
client={client}
connectLocale={en}
/>,
);
const element = container.querySelector("h2");
expect(element).not.toBe(null);
expect(element?.innerHTML).toBe(en.sendFundsScreen.title);
vi.resetAllMocks();
});
it("SendFundsForm should render the send button", () => {
vi.mock(
"../../../../../react/core/hooks/wallets/useActiveWalletChain.js",
() => ({
useActiveWalletChain: vi.fn(),
}),
);
vi.mocked(useActiveWalletChain).mockReturnValue(base);
const { container } = render(
<SendFundsForm
token={{ nativeToken: true }}
onTokenSelect={() => {}}
receiverAddress={TEST_ACCOUNT_A.address}
setReceiverAddress={() => {}}
amount={"1"}
setAmount={() => {}}
onBack={() => {}}
client={client}
connectLocale={en}
/>,
);
const element = container.querySelector(
"button.tw-sendfunds-screen-send-button",
);
expect(element?.innerHTML).toBe(en.sendFundsScreen.submitButton);
vi.resetAllMocks();
});
});
@@ -107,9 +107,9 @@ export function SendFunds(props: {
}
/**
* @internal
* @internal Exported for tests
*/
function SendFundsForm(props: {
export function SendFundsForm(props: {
onTokenSelect: () => void;
token: ERC20OrNativeToken;
receiverAddress: string;
@@ -321,6 +321,7 @@ function SendFundsForm(props: {
fullWidth
variant="accent"
type="submit"
className="tw-sendfunds-screen-send-button"
onClick={async () => {
if (!receiverAddress || !amount) {
return;
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { render } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import en from "../locale/en.js";
import { StartScreen } from "./StartScreen.js";
const client = TEST_CLIENT;
describe("StartScreen", () => {
it("should render an image for the welcome screen", () => {
const { container } = render(
<StartScreen
connectLocale={en}
client={client}
welcomeScreen={{
img: {
src: "https://cat.png",
width: 100,
height: 100,
},
}}
meta={{
showThirdwebBranding: false,
termsOfServiceUrl: "https://cat.com",
privacyPolicyUrl: "https://privacy.com",
}}
/>,
);
const img = container.querySelector("img");
expect(img).not.toBe(null);
expect(img?.src).toBe("https://cat.png/");
expect(img?.width).toBe(100);
});
it("should render new-to-wallet link", () => {
const { container } = render(
<StartScreen
connectLocale={en}
client={client}
welcomeScreen={undefined}
meta={{
showThirdwebBranding: false,
termsOfServiceUrl: "https://cat.com",
privacyPolicyUrl: "https://privacy.com",
}}
/>,
);
const a = container.querySelector("a");
expect(a).not.toBe(null);
expect(a?.href).toBe("https://blog.thirdweb.com/web3-wallet/");
});
it("should render an svg icon if a custom image is not passed", () => {
const { container } = render(
<StartScreen
connectLocale={en}
client={client}
welcomeScreen={undefined}
meta={{
showThirdwebBranding: false,
termsOfServiceUrl: "https://cat.com",
privacyPolicyUrl: "https://privacy.com",
}}
/>,
);
const svg = container.querySelector("svg");
expect(svg).not.toBe(null);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { formatTokenBalance } from "./formatTokenBalance.js";
describe("formatTokenBalance", () => {
const mockBalanceData = {
symbol: "ETH",
name: "Ethereum",
decimals: 18,
displayValue: "1.23456789",
};
it("formats balance with symbol by default", () => {
const result = formatTokenBalance(mockBalanceData);
expect(result).toBe("1.23457 ETH");
});
it("formats balance without symbol when showSymbol is false", () => {
const result = formatTokenBalance(mockBalanceData, false);
expect(result).toBe("1.23457");
});
it("respects custom decimal places", () => {
const result = formatTokenBalance(mockBalanceData, true, 3);
expect(result).toBe("1.235 ETH");
});
it("handles zero balance", () => {
const zeroBalance = { ...mockBalanceData, displayValue: "0" };
const result = formatTokenBalance(zeroBalance);
expect(result).toBe("0 ETH");
});
it("handles very small numbers", () => {
const smallBalance = { ...mockBalanceData, displayValue: "0.0000001" };
const result = formatTokenBalance(smallBalance);
expect(result).toBe("0.00001 ETH");
});
it("handles large numbers", () => {
const largeBalance = { ...mockBalanceData, displayValue: "1234567.89" };
const result = formatTokenBalance(largeBalance);
expect(result).toBe("1234567.89 ETH");
});
it("rounds up for very small non-zero values", () => {
const tinyBalance = { ...mockBalanceData, displayValue: "0.000000001" };
const result = formatTokenBalance(tinyBalance, true, 8);
expect(result).toBe("1e-8 ETH");
});
it("handles different token symbols", () => {
const usdcBalance = {
...mockBalanceData,
symbol: "USDC",
displayValue: "100.5",
};
const result = formatTokenBalance(usdcBalance);
expect(result).toBe("100.5 USDC");
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import {
NATIVE_TOKEN,
type NativeToken,
isNativeToken,
} from "./nativeToken.js"; // Replace with the actual file name// Assuming this is defined in a constants file
describe("isNativeToken", () => {
it("should return true for NATIVE_TOKEN", () => {
expect(isNativeToken(NATIVE_TOKEN)).toBe(true);
});
it("should return true for an object with nativeToken property", () => {
const token: NativeToken = { nativeToken: true };
expect(isNativeToken(token)).toBe(true);
});
it("should return true for a token with the native token address", () => {
const token = { address: NATIVE_TOKEN_ADDRESS };
expect(isNativeToken(token)).toBe(true);
});
it("should return true for a token with the native token address in uppercase", () => {
const token = { address: NATIVE_TOKEN_ADDRESS.toUpperCase() };
expect(isNativeToken(token)).toBe(true);
});
it("should return false for a non-native token", () => {
const token = { address: "0x1234567890123456789012345678901234567890" };
expect(isNativeToken(token)).toBe(false);
});
it("should return false for an empty object", () => {
expect(isNativeToken({})).toBe(false);
});
it("should return false for a token with a similar but incorrect address", () => {
const token = { address: `${NATIVE_TOKEN_ADDRESS.slice(0, -1)}0` };
expect(isNativeToken(token)).toBe(false);
});
});
@@ -38,7 +38,11 @@ export const CopyIcon: React.FC<{
flex="row"
center="both"
>
{showCheckIcon ? <CheckIcon /> : <CopyIconSVG />}
{showCheckIcon ? (
<CheckIcon className="tw-check-icon" />
) : (
<CopyIconSVG className="tw-copy-icon" />
)}
</Container>
</div>
</ToolTip>
@@ -11,6 +11,7 @@ export const Skeleton: React.FC<{
height: string;
width?: string;
color?: keyof Theme["colors"];
className?: string;
}> = (props) => {
return (
<SkeletonDiv
@@ -19,6 +20,7 @@ export const Skeleton: React.FC<{
height: props.height,
width: props.width || "auto",
}}
className={props.className || ""}
/>
);
};
@@ -1,54 +1,185 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { ANVIL_CHAIN } from "~test/chains.js";
import { render, screen, waitFor } from "~test/react-render.js";
import { VITALIK_WALLET } from "~test/addresses.js";
import { render } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js";
import { AccountBalance } from "./balance.js";
import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js";
import { sepolia } from "../../../../../chains/chain-definitions/sepolia.js";
import { defineChain } from "../../../../../chains/utils.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import {
AccountBalance,
formatAccountFiatBalance,
formatAccountTokenBalance,
loadAccountBalance,
} from "./balance.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountBalance component", () => {
it("format the balance properly", async () => {
const roundTo1Decimal = (num: number): number => Math.round(num * 10) / 10;
const balance = await getWalletBalance({
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
address: TEST_ACCOUNT_A.address,
});
const queryClient = new QueryClient();
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountBalance chain={ANVIL_CHAIN} formatFn={roundTo1Decimal} />
</AccountProvider>,
describe.runIf(process.env.TW_SECRET_KEY)("AccountBalance component", () => {
it("should render", async () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
<AccountProvider address={VITALIK_WALLET} client={TEST_CLIENT}>
<AccountBalance
chain={ethereum}
loadingComponent={<span />}
fallbackComponent={<span />}
formatFn={() => "nope"}
/>
</AccountProvider>
</QueryClientProvider>,
);
waitFor(() =>
expect(
screen.getByText(roundTo1Decimal(Number(balance.displayValue)), {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
const spans = container.getElementsByTagName("span");
expect(spans).toHaveLength(1);
});
it("`loadAccountBalance` should fetch the native balance properly", async () => {
const result = await loadAccountBalance({
client: TEST_CLIENT,
chain: ethereum,
address: VITALIK_WALLET,
});
expect(Number.isNaN(result.balance)).toBe(false);
expect(result.symbol).toBe("ETH");
});
it("`loadAccountBalance` should fetch the token balance properly", async () => {
const result = await loadAccountBalance({
client: TEST_CLIENT,
chain: ethereum,
address: VITALIK_WALLET,
// USDC
tokenAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
});
expect(Number.isNaN(result.balance)).toBe(false);
expect(result.symbol).toBe("USDC");
});
it("`loadAccountBalance` should fetch the fiat balance properly", async () => {
const result = await loadAccountBalance({
client: TEST_CLIENT,
chain: ethereum,
address: VITALIK_WALLET,
showBalanceInFiat: "USD",
});
expect(Number.isNaN(result.balance)).toBe(false);
expect(result.symbol).toBe("$");
});
it("`loadAccountBalance` should throw if `chain` is not passed", async () => {
await expect(() =>
loadAccountBalance({ client: TEST_CLIENT, address: VITALIK_WALLET }),
).rejects.toThrowError("chain is required");
});
it("`loadAccountBalance` should throw if `tokenAddress` is mistakenly passed as native token", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
tokenAddress: NATIVE_TOKEN_ADDRESS,
chain: ethereum,
}),
).rejects.toThrowError(
`Invalid tokenAddress - cannot be ${NATIVE_TOKEN_ADDRESS}`,
);
});
it("should fallback properly if failed to load", () => {
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountBalance
chain={undefined}
fallbackComponent={<span>oops</span>}
/>
</AccountProvider>,
);
it("`loadAccountBalance` should throw if `address` is not a valid evm address", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: "haha",
chain: ethereum,
}),
).rejects.toThrowError("Invalid wallet address. Expected an EVM address");
});
waitFor(() =>
expect(
screen.getByText("oops", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
it("`loadAccountBalance` should throw if `tokenAddress` is passed but is not a valid evm address", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
tokenAddress: "haha",
chain: ethereum,
}),
).rejects.toThrowError(
"Invalid tokenAddress. Expected an EVM contract address",
);
});
it("`formatAccountTokenBalance` should display a rounded-up value + symbol", () => {
expect(
formatAccountTokenBalance({
balance: 1.1999,
symbol: "ETH",
decimals: 1,
}),
).toBe("1.2 ETH");
});
it("`formatAccountFiatBalance` should display fiat symbol followed by a rounded-up fiat value", () => {
expect(
formatAccountFiatBalance({ balance: 55.001, symbol: "$", decimals: 0 }),
).toBe("$55");
});
it("`loadAccountBalance` should throw if failed to load tokenBalance (native token)", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
chain: defineChain(-1),
}),
).rejects.toThrowError(
`Failed to retrieve native token balance for address: ${VITALIK_WALLET} on chainId:-1`,
);
});
it("`loadAccountBalance` should throw if failed to load tokenBalance (erc20 token)", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
chain: defineChain(-1),
tokenAddress: "0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59",
}),
).rejects.toThrowError(
`Failed to retrieve token: 0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59 balance for address: ${VITALIK_WALLET} on chainId:-1`,
);
});
it("if fetching fiat value then it should throw if failed to resolve (native token)", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
chain: sepolia,
showBalanceInFiat: "USD",
}),
).rejects.toThrowError(
`Failed to resolve fiat value for native token on chainId: ${sepolia.id}`,
);
});
it("if fetching fiat value then it should throw if failed to resolve (erc20 token)", async () => {
await expect(() =>
loadAccountBalance({
client: TEST_CLIENT,
address: VITALIK_WALLET,
chain: sepolia,
showBalanceInFiat: "USD",
// this is a random erc20 token on sepolia that vitalik's wallet owns
tokenAddress: "0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59",
}),
).rejects.toThrowError(
`Failed to resolve fiat value for token: 0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59 on chainId: ${sepolia.id}`,
);
});
});
@@ -1,16 +1,36 @@
"use client";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { Address } from "abitype";
import type React from "react";
import type { JSX } from "react";
import type { Chain } from "../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import { convertCryptoToFiat } from "../../../../../exports/pay.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js";
import {
type GetWalletBalanceResult,
getWalletBalance,
} from "../../../../../wallets/utils/getWalletBalance.js";
import { isAddress } from "../../../../../utils/address.js";
import { formatNumber } from "../../../../../utils/formatNumber.js";
import { shortenLargeNumber } from "../../../../../utils/shortenLargeNumber.js";
import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js";
import { useAccountContext } from "./provider.js";
/**
* @component
* @wallet
*/
export type AccountBalanceInfo = {
/**
* Represents either token balance or fiat balance.
*/
balance: number;
/**
* Represents either token symbol or fiat symbol
*/
symbol: string;
};
/**
* Props for the AccountBalance component
* @component
@@ -33,7 +53,7 @@ export interface AccountBalanceProps
* use this function to transform the balance display value like round up the number
* Particularly useful to avoid overflowing-UI issues
*/
formatFn?: (num: number) => number;
formatFn?: (props: AccountBalanceInfo) => string;
/**
* This component will be shown while the balance of the account is being fetched
* If not passed, the component will return `null`.
@@ -67,9 +87,14 @@ export interface AccountBalanceProps
* Optional `useQuery` params
*/
queryOptions?: Omit<
UseQueryOptions<GetWalletBalanceResult>,
UseQueryOptions<AccountBalanceInfo>,
"queryFn" | "queryKey"
>;
/**
* Show the token balance in a supported fiat currency (e.g "USD")
*/
showBalanceInFiat?: SupportedFiatCurrency;
}
/**
@@ -94,18 +119,21 @@ export interface AccountBalanceProps
*
*
* ### Format the balance (round up, shorten etc.)
* The AccountBalance component accepts a `formatFn` which takes in a number and outputs a number
* The function is used to modify the display value of the wallet balance
* The AccountBalance component accepts a `formatFn` which takes in an object of type `AccountBalanceInfo` and outputs a string
* The function is used to modify the display value of the wallet balance (either in crypto or fiat)
*
* ```tsx
* const roundTo1Decimal = (num: number):number => Math.round(num * 10) / 10;
* import type { AccountBalanceInfo } from "thirdweb/react";
* import { formatNumber } from "thirdweb/utils";
*
* <AccountBalance formatFn={roundTo1Decimal} />
* const format = (props: AccountInfoBalance):string => `${formatNumber(props.balance, 1)} ${props.symbol.toLowerCase()}`
*
* <AccountBalance formatFn={format} />
* ```
*
* Result:
* ```html
* <span>1.1 ETH</span>
* <span>1.1 eth</span> // the balance is rounded up to 1 decimal and the symbol is lowercased
* ```
*
* ### Show a loading sign when the balance is being fetched
@@ -149,10 +177,11 @@ export interface AccountBalanceProps
export function AccountBalance({
chain,
tokenAddress,
formatFn,
loadingComponent,
fallbackComponent,
queryOptions,
formatFn,
showBalanceInFiat,
...restProps
}: AccountBalanceProps) {
const { address, client } = useAccountContext();
@@ -160,25 +189,20 @@ export function AccountBalance({
const chainToLoad = chain || walletChain;
const balanceQuery = useQuery({
queryKey: [
"walletBalance",
"internal_account_balance",
chainToLoad?.id || -1,
address || "0x0",
address,
{ tokenAddress },
showBalanceInFiat,
] as const,
queryFn: async () => {
if (!chainToLoad) {
throw new Error("chain is required");
}
if (!client) {
throw new Error("client is required");
}
return getWalletBalance({
queryFn: async (): Promise<AccountBalanceInfo> =>
loadAccountBalance({
chain: chainToLoad,
client,
address,
tokenAddress,
});
},
showBalanceInFiat,
}),
retry: false,
...queryOptions,
});
@@ -191,13 +215,129 @@ export function AccountBalance({
return fallbackComponent || null;
}
const displayValue = formatFn
? formatFn(Number(balanceQuery.data.displayValue))
: balanceQuery.data.displayValue;
// Prioritize using the formatFn from users
if (formatFn) {
return <span {...restProps}>{formatFn(balanceQuery.data)}</span>;
}
if (showBalanceInFiat) {
return (
<span {...restProps}>
{formatAccountFiatBalance({ ...balanceQuery.data, decimals: 0 })}
</span>
);
}
return (
<span {...restProps}>
{displayValue} {balanceQuery.data.symbol}
{formatAccountTokenBalance({
...balanceQuery.data,
decimals: balanceQuery.data.balance < 1 ? 3 : 2,
})}
</span>
);
}
/**
* @internal Exported for tests
*/
export async function loadAccountBalance(props: {
chain?: Chain;
client: ThirdwebClient;
address: Address;
tokenAddress?: Address;
showBalanceInFiat?: SupportedFiatCurrency;
}): Promise<AccountBalanceInfo> {
const { chain, client, address, tokenAddress, showBalanceInFiat } = props;
if (!chain) {
throw new Error("chain is required");
}
if (
tokenAddress &&
tokenAddress?.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
) {
throw new Error(`Invalid tokenAddress - cannot be ${NATIVE_TOKEN_ADDRESS}`);
}
if (!isAddress(address)) {
throw new Error("Invalid wallet address. Expected an EVM address");
}
if (tokenAddress && !isAddress(tokenAddress)) {
throw new Error("Invalid tokenAddress. Expected an EVM contract address");
}
const tokenBalanceData = await getWalletBalance({
chain,
client,
address,
tokenAddress,
}).catch(() => undefined);
if (!tokenBalanceData) {
throw new Error(
`Failed to retrieve ${tokenAddress ? `token: ${tokenAddress}` : "native token"} balance for address: ${address} on chainId:${chain.id}`,
);
}
if (showBalanceInFiat) {
const fiatData = await convertCryptoToFiat({
fromAmount: Number(tokenBalanceData.displayValue),
fromTokenAddress: tokenAddress || NATIVE_TOKEN_ADDRESS,
to: showBalanceInFiat,
chain,
client,
}).catch(() => undefined);
if (fiatData === undefined) {
throw new Error(
`Failed to resolve fiat value for ${tokenAddress ? `token: ${tokenAddress}` : "native token"} on chainId: ${chain.id}`,
);
}
return {
balance: fiatData?.result,
symbol:
new Intl.NumberFormat("en", {
style: "currency",
currency: showBalanceInFiat,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.formatToParts(0)
.find((p) => p.type === "currency")?.value ||
showBalanceInFiat.toUpperCase(),
};
}
return {
balance: Number(tokenBalanceData.displayValue),
symbol: tokenBalanceData.symbol,
};
}
/**
* Format the display balance for both crypto and fiat, in the Details button and Modal
* If both crypto balance and fiat balance exist, we have to keep the string very short to avoid UI issues.
* @internal
* Used internally for the Details button and the Details Modal
*/
export function formatAccountTokenBalance(
props: AccountBalanceInfo & { decimals: number },
): string {
const formattedTokenBalance = formatNumber(props.balance, props.decimals);
return `${formattedTokenBalance} ${props.symbol}`;
}
/**
* Used internally for the Details button and Details Modal
* @internal
*/
export function formatAccountFiatBalance(
props: AccountBalanceInfo & { decimals: number },
) {
const num = formatNumber(props.balance, props.decimals);
// Need to keep them short to avoid UI overflow issues
const formattedFiatBalance = shortenLargeNumber(num);
return `${props.symbol}${formattedFiatBalance}`;
}
@@ -35,4 +35,20 @@ describe.runIf(process.env.TW_SECRET_KEY)("AccountProvider component", () => {
}),
).toBeInTheDocument();
});
it("should throw an error if no address is provided", () => {
expect(() => {
render(
<AccountProvider
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
address={undefined as any}
client={TEST_CLIENT}
>
<AccountAddress />
</AccountProvider>,
);
}).toThrowError(
"AccountProvider: No address passed. Ensure an address is always provided to the AccountProvider",
);
});
});
@@ -48,6 +48,11 @@ const AccountProviderContext = /* @__PURE__ */ createContext<
export function AccountProvider(
props: React.PropsWithChildren<AccountProviderProps>,
) {
if (!props.address) {
throw new Error(
"AccountProvider: No address passed. Ensure an address is always provided to the AccountProvider",
);
}
return (
<AccountProviderContext.Provider value={props}>
{props.children}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
DOODLES_CONTRACT,
DROP1155_CONTRACT,
UNISWAPV3_FACTORY_CONTRACT,
} from "~test/test-contracts.js";
import { fetchNftDescription } from "./description.js";
describe.runIf(process.env.TW_SECRET_KEY)("NFTDescription", () => {
it("fetchNftDescription should work with ERC721", async () => {
const desc = await fetchNftDescription({
contract: DOODLES_CONTRACT,
tokenId: 0n,
});
expect(desc).toBe(
"A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.",
);
});
it("fetchNftDescription should work with ERC1155", async () => {
const desc = await fetchNftDescription({
contract: DROP1155_CONTRACT,
tokenId: 0n,
});
expect(desc).toBe("");
});
it("fetchNftDescription should respect descriptionResolver as a string", async () => {
const desc = await fetchNftDescription({
contract: DOODLES_CONTRACT,
tokenId: 0n,
descriptionResolver: "string",
});
expect(desc).toBe("string");
});
it("fetchNftDescription should respect descriptionResolver as a non-async function", async () => {
const desc = await fetchNftDescription({
contract: DOODLES_CONTRACT,
tokenId: 0n,
descriptionResolver: () => "non-async",
});
expect(desc).toBe("non-async");
});
it("fetchNftDescription should respect descriptionResolver as a async function", async () => {
const desc = await fetchNftDescription({
contract: DOODLES_CONTRACT,
tokenId: 0n,
descriptionResolver: async () => "async",
});
expect(desc).toBe("async");
});
it("fetchNftDescription should throw error if failed to resolve nft info", async () => {
await expect(() =>
fetchNftDescription({
contract: UNISWAPV3_FACTORY_CONTRACT,
tokenId: 0n,
}),
).rejects.toThrowError("Failed to resolve NFT info");
});
});
@@ -1,10 +1,10 @@
"use client";
import type { UseQueryOptions } from "@tanstack/react-query";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { JSX } from "react";
import type { NFT } from "../../../../../utils/nft/parseNft.js";
import { useNftInfo } from "./hooks.js";
import type { ThirdwebContract } from "../../../../../contract/contract.js";
import { useNFTContext } from "./provider.js";
import { getNFTInfo } from "./utils.js";
export interface NFTDescriptionProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
@@ -13,7 +13,12 @@ export interface NFTDescriptionProps
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<UseQueryOptions<NFT>, "queryFn" | "queryKey">;
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
/**
* This prop can be a string or a (async) function that resolves to a string, representing the description of the NFT
* This is particularly useful if you already have a way to fetch the data.
*/
descriptionResolver?: string | (() => string) | (() => Promise<string>);
}
/**
@@ -58,6 +63,21 @@ export interface NFTDescriptionProps
* </NFTProvider>
* ```
*
* ### Override the description with the `descriptionResolver` prop
* If you already have the url, you can skip the network requests and pass it directly to the NFTDescription
* ```tsx
* <NFTDescription descriptionResolver="The desc of the NFT" />
* ```
*
* You can also pass in your own custom (async) function that retrieves the description
* ```tsx
* const getDescription = async () => {
* // ...
* return description;
* };
*
* <NFTDescription descriptionResolver={getDescription} />
* ```
* @component
* @nft
* @beta
@@ -66,22 +86,61 @@ export function NFTDescription({
loadingComponent,
fallbackComponent,
queryOptions,
descriptionResolver,
...restProps
}: NFTDescriptionProps) {
const { contract, tokenId } = useNFTContext();
const nftQuery = useNftInfo({
contract,
tokenId,
queryOptions,
const descQuery = useQuery({
queryKey: [
"_internal_nft_description_",
contract.chain.id,
tokenId.toString(),
{
resolver:
typeof descriptionResolver === "string"
? descriptionResolver
: typeof descriptionResolver === "function"
? descriptionResolver.toString()
: undefined,
},
],
queryFn: async (): Promise<string> =>
fetchNftDescription({ descriptionResolver, contract, tokenId }),
...queryOptions,
});
if (nftQuery.isLoading) {
if (descQuery.isLoading) {
return loadingComponent || null;
}
if (!nftQuery.data?.metadata?.description) {
if (!descQuery.data) {
return fallbackComponent || null;
}
return <span {...restProps}>{nftQuery.data.metadata.description}</span>;
return <span {...restProps}>{descQuery.data}</span>;
}
/**
* @internal Exported for tests
*/
export async function fetchNftDescription(props: {
descriptionResolver?: string | (() => string) | (() => Promise<string>);
contract: ThirdwebContract;
tokenId: bigint;
}): Promise<string> {
const { descriptionResolver, contract, tokenId } = props;
if (typeof descriptionResolver === "string") {
return descriptionResolver;
}
if (typeof descriptionResolver === "function") {
return descriptionResolver();
}
const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined);
if (!nft) {
throw new Error("Failed to resolve NFT info");
}
if (typeof nft.metadata.description !== "string") {
throw new Error("Failed to resolve NFT description");
}
return nft.metadata.description;
}
@@ -1,53 +0,0 @@
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import { getNFT as getNFT721 } from "../../../../../extensions/erc721/read/getNFT.js";
import { getNFT as getNFT1155 } from "../../../../../extensions/erc1155/read/getNFT.js";
import type { NFT } from "../../../../../utils/nft/parseNft.js";
import type { NFTProviderProps } from "./provider.js";
/**
* @internal Only used for the NFT prebuilt components
*/
export function useNftInfo(
props: NFTProviderProps & {
queryOptions?: Omit<UseQueryOptions<NFT>, "queryFn" | "queryKey">;
},
) {
return useQuery({
queryKey: [
"__nft_component_internal__",
props.contract.chain.id,
props.contract.address,
props.tokenId.toString(),
],
queryFn: () =>
getNFTInfo({ contract: props.contract, tokenId: props.tokenId }),
...props.queryOptions,
});
}
/**
* @internal
*/
export async function getNFTInfo(options: NFTProviderProps): Promise<NFT> {
const nft = await Promise.allSettled([
getNFT721(options),
getNFT1155(options),
]).then(([possibleNFT721, possibleNFT1155]) => {
// getNFT extension always return an NFT object
// so we need to check if the tokenURI exists
if (
possibleNFT721.status === "fulfilled" &&
possibleNFT721.value.tokenURI
) {
return possibleNFT721.value;
}
if (
possibleNFT1155.status === "fulfilled" &&
possibleNFT1155.value.tokenURI
) {
return possibleNFT1155.value;
}
throw new Error("Failed to load NFT metadata");
});
return nft;
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
DOODLES_CONTRACT,
DROP1155_CONTRACT,
UNISWAPV3_FACTORY_CONTRACT,
} from "~test/test-contracts.js";
import { fetchNftMedia } from "./media.js";
describe.runIf(process.env.TW_SECRET_KEY)("NFTMedia", () => {
it("fetchNftMedia should work with ERC721", async () => {
const desc = await fetchNftMedia({
contract: DOODLES_CONTRACT,
tokenId: 0n,
});
expect(desc).toStrictEqual({
src: "ipfs://QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn",
poster: undefined,
});
});
it("fetchNftMedia should work with ERC1155", async () => {
const desc = await fetchNftMedia({
contract: DROP1155_CONTRACT,
tokenId: 0n,
});
expect(desc).toStrictEqual({
src: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
poster: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
});
});
it("fetchNftMedia should respect mediaResolver as a string", async () => {
const desc = await fetchNftMedia({
contract: DOODLES_CONTRACT,
tokenId: 0n,
mediaResolver: {
src: "string",
poster: undefined,
},
});
expect(desc).toStrictEqual({ src: "string", poster: undefined });
});
it("fetchNftMedia should respect mediaResolver as a non-async function", async () => {
const desc = await fetchNftMedia({
contract: DOODLES_CONTRACT,
tokenId: 0n,
mediaResolver: () => ({
src: "non-async",
poster: undefined,
}),
});
expect(desc).toStrictEqual({ src: "non-async", poster: undefined });
});
it("fetchNftMedia should respect mediaResolver as a async function", async () => {
const desc = await fetchNftMedia({
contract: DOODLES_CONTRACT,
tokenId: 0n,
mediaResolver: async () =>
await {
src: "async",
poster: undefined,
},
});
expect(desc).toStrictEqual({ src: "async", poster: undefined });
});
it("fetchNftMedia should throw error if failed to resolve nft info", async () => {
await expect(() =>
fetchNftMedia({
contract: UNISWAPV3_FACTORY_CONTRACT,
tokenId: 0n,
}),
).rejects.toThrowError("Failed to resolve NFT info");
});
});
@@ -1,13 +1,25 @@
import type { UseQueryOptions } from "@tanstack/react-query";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { JSX } from "react";
import type { NFT } from "../../../../../utils/nft/parseNft.js";
import type { ThirdwebContract } from "../../../../../contract/contract.js";
import { MediaRenderer } from "../../MediaRenderer/MediaRenderer.js";
import type { MediaRendererProps } from "../../MediaRenderer/types.js";
import { useNftInfo } from "./hooks.js";
import { useNFTContext } from "./provider.js";
import { getNFTInfo } from "./utils.js";
/**
* @component
* @beta
* @wallet
*/
export type NFTMediaInfo = {
src: string;
poster: string | undefined;
};
/**
* @component
* @beta
* @wallet
* The props for the <NFTMedia /> component
* It is similar to the [`MediaRendererProps`](https://portal.thirdweb.com/references/typescript/v5/MediaRendererProps)
* (excluding `src`, `poster` and `client`) that you can
@@ -22,7 +34,16 @@ export type NFTMediaProps = Omit<
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<UseQueryOptions<NFT>, "queryFn" | "queryKey">;
queryOptions?: Omit<UseQueryOptions<NFTMediaInfo>, "queryFn" | "queryKey">;
/**
* This prop can be a string or a (async) function that resolves to a string, representing the media url of the NFT
* This is particularly useful if you already have a way to fetch the image.
* In case of function, the function must resolve to an object of type `NFTMediaInfo`
*/
mediaResolver?:
| NFTMediaInfo
| (() => NFTMediaInfo)
| (() => Promise<NFTMediaInfo>);
};
/**
@@ -76,6 +97,26 @@ export type NFTMediaProps = Omit<
* ```tsx
* <NFTMedia style={{ borderRadius: "8px" }} className="mx-auto" />
* ```
*
* ### Override the media with the `mediaResolver` prop
* If you already have the url, you can skip the network requests and pass it directly to the NFTMedia
* ```tsx
* <NFTMedia mediaResolver={{
* src: "/cat_video.mp4",
* // Poster is applicable to medias that are videos and audios
* poster: "/cat-image.png",
* }} />
* ```
*
* You can also pass in your own custom (async) function that retrieves the media url
* ```tsx
* const getMedia = async () => {
* const url = getNFTMedia(props);
* return url;
* };
*
* <NFTMedia mediaResolver={getMedia} />
* ```
* @nft
* @beta
*/
@@ -83,37 +124,82 @@ export function NFTMedia({
loadingComponent,
fallbackComponent,
queryOptions,
mediaResolver,
...mediaRendererProps
}: NFTMediaProps) {
const { contract, tokenId } = useNFTContext();
const nftQuery = useNftInfo({
contract,
tokenId,
queryOptions,
const mediaQuery = useQuery({
queryKey: [
"_internal_nft_media_",
contract.chain.id,
tokenId.toString(),
{
resolver:
typeof mediaResolver === "object"
? mediaResolver
: typeof mediaResolver === "function"
? mediaResolver.toString()
: undefined,
},
],
queryFn: async (): Promise<NFTMediaInfo> =>
fetchNftMedia({ mediaResolver, contract, tokenId }),
...queryOptions,
});
if (nftQuery.isLoading) {
if (mediaQuery.isLoading) {
return loadingComponent || null;
}
if (!nftQuery.data) {
return fallbackComponent || null;
}
const animation_url = nftQuery.data.metadata.animation_url;
const image =
nftQuery.data.metadata.image || nftQuery.data.metadata.image_url;
if (!animation_url && !image) {
if (!mediaQuery.data) {
return fallbackComponent || null;
}
return (
<MediaRenderer
client={contract.client}
src={animation_url || image}
poster={image}
src={mediaQuery.data.src}
poster={mediaQuery.data.poster}
{...mediaRendererProps}
/>
);
}
/**
* @internal Exported for tests only
*/
export async function fetchNftMedia(props: {
mediaResolver?:
| NFTMediaInfo
| (() => NFTMediaInfo)
| (() => Promise<NFTMediaInfo>);
contract: ThirdwebContract;
tokenId: bigint;
}): Promise<{ src: string; poster: string | undefined }> {
const { mediaResolver, contract, tokenId } = props;
if (typeof mediaResolver === "object") {
return mediaResolver;
}
if (typeof mediaResolver === "function") {
return mediaResolver();
}
const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined);
if (!nft) {
throw new Error("Failed to resolve NFT info");
}
const animation_url = nft.metadata.animation_url;
const image = nft.metadata.image || nft.metadata.image_url;
if (animation_url) {
return {
src: animation_url,
poster: image || undefined,
};
}
if (image) {
return {
src: image,
poster: undefined,
};
}
throw new Error("Failed to resolve NFT media");
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
DOODLES_CONTRACT,
DROP1155_CONTRACT,
UNISWAPV3_FACTORY_CONTRACT,
} from "~test/test-contracts.js";
import { fetchNftName } from "./name.js";
describe.runIf(process.env.TW_SECRET_KEY)("NFTName", () => {
it("fetchNftName should work with ERC721", async () => {
const desc = await fetchNftName({
contract: DOODLES_CONTRACT,
tokenId: 0n,
});
expect(desc).toBe("Doodle #0");
});
it("fetchNftName should work with ERC1155", async () => {
const desc = await fetchNftName({
contract: DROP1155_CONTRACT,
tokenId: 0n,
});
expect(desc).toBe("Aura OG");
});
it("fetchNftName should respect nameResolver as a string", async () => {
const desc = await fetchNftName({
contract: DOODLES_CONTRACT,
tokenId: 0n,
nameResolver: "string",
});
expect(desc).toBe("string");
});
it("fetchNftName should respect nameResolver as a non-async function", async () => {
const desc = await fetchNftName({
contract: DOODLES_CONTRACT,
tokenId: 0n,
nameResolver: () => "non-async",
});
expect(desc).toBe("non-async");
});
it("fetchNftName should respect nameResolver as a async function", async () => {
const desc = await fetchNftName({
contract: DOODLES_CONTRACT,
tokenId: 0n,
nameResolver: async () => "async",
});
expect(desc).toBe("async");
});
it("fetchNftName should throw error if failed to resolve nft info", async () => {
await expect(() =>
fetchNftName({
contract: UNISWAPV3_FACTORY_CONTRACT,
tokenId: 0n,
}),
).rejects.toThrowError("Failed to resolve NFT info");
});
});
@@ -1,8 +1,8 @@
import type { UseQueryOptions } from "@tanstack/react-query";
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { JSX } from "react";
import type { NFT } from "../../../../../utils/nft/parseNft.js";
import { useNftInfo } from "./hooks.js";
import type { ThirdwebContract } from "../../../../../contract/contract.js";
import { useNFTContext } from "./provider.js";
import { getNFTInfo } from "./utils.js";
export interface NFTNameProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
@@ -11,7 +11,12 @@ export interface NFTNameProps
/**
* Optional `useQuery` params
*/
queryOptions?: Omit<UseQueryOptions<NFT>, "queryFn" | "queryKey">;
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
/**
* This prop can be a string or a (async) function that resolves to a string, representing the name of the NFT
* This is particularly useful if you already have a way to fetch the name of the NFT.
*/
nameResolver?: string | (() => string) | (() => Promise<string>);
}
/**
@@ -56,6 +61,22 @@ export interface NFTNameProps
* </NFTProvider>
* ```
*
* ### Override the name with the `nameResolver` prop
* If you already have the name, you can skip the network requests and pass it directly to the NFTName
* ```tsx
* <NFTName nameResolver="Doodles #1" />
* ```
*
* You can also pass in your own custom (async) function that retrieves the name
* ```tsx
* const getName = async () => {
* // ...
* return name;
* };
*
* <NFTName nameResolver={getName} />
* ```
*
* @nft
* @component
* @beta
@@ -64,22 +85,61 @@ export function NFTName({
loadingComponent,
fallbackComponent,
queryOptions,
nameResolver,
...restProps
}: NFTNameProps) {
const { contract, tokenId } = useNFTContext();
const nftQuery = useNftInfo({
contract,
tokenId,
queryOptions,
const nameQuery = useQuery({
queryKey: [
"_internal_nft_name_",
contract.chain.id,
tokenId.toString(),
{
resolver:
typeof nameResolver === "string"
? nameResolver
: typeof nameResolver === "function"
? nameResolver.toString()
: undefined,
},
],
queryFn: async (): Promise<string> =>
fetchNftName({ nameResolver, contract, tokenId }),
...queryOptions,
});
if (nftQuery.isLoading) {
if (nameQuery.isLoading) {
return loadingComponent || null;
}
if (!nftQuery.data?.metadata?.name) {
if (!nameQuery.data) {
return fallbackComponent || null;
}
return <span {...restProps}>{nftQuery.data.metadata.name}</span>;
return <span {...restProps}>{nameQuery.data}</span>;
}
/**
* @internal Exported for tests
*/
export async function fetchNftName(props: {
nameResolver?: string | (() => string) | (() => Promise<string>);
contract: ThirdwebContract;
tokenId: bigint;
}): Promise<string> {
const { nameResolver, contract, tokenId } = props;
if (typeof nameResolver === "string") {
return nameResolver;
}
if (typeof nameResolver === "function") {
return nameResolver();
}
const nft = await getNFTInfo({ contract, tokenId }).catch(() => undefined);
if (!nft) {
throw new Error("Failed to resolve NFT info");
}
if (typeof nft.metadata.name !== "string") {
throw new Error("Failed to resolve NFT name");
}
return nft.metadata.name;
}
@@ -1,48 +1,13 @@
import { useContext } from "react";
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { type FC, useContext } from "react";
import { describe, expect, it, vi } from "vitest";
import { render, renderHook, screen, waitFor } from "~test/react-render.js";
import { DOODLES_CONTRACT } from "~test/test-contracts.js";
import { getNFTInfo } from "./hooks.js";
import { NFTDescription } from "./description.js";
import { NFTMedia } from "./media.js";
import { NFTName } from "./name.js";
import { NFTProvider, NFTProviderContext } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => {
it("should fetch the NFT metadata", async () => {
const nft = await getNFTInfo({
contract: DOODLES_CONTRACT,
tokenId: 1n,
});
expect(nft.metadata).toStrictEqual({
attributes: [
{
trait_type: "face",
value: "holographic beard",
},
{
trait_type: "hair",
value: "white bucket cap",
},
{
trait_type: "body",
value: "purple sweater with satchel",
},
{
trait_type: "background",
value: "grey",
},
{
trait_type: "head",
value: "gradient 2",
},
],
description:
"A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.",
image: "ipfs://QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9",
name: "Doodle #1",
});
});
import { NFTProvider, NFTProviderContext, useNFTContext } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("NFTProvider", () => {
it("should render children correctly", () => {
render(
<NFTProvider contract={DOODLES_CONTRACT} tokenId={0n}>
@@ -99,7 +64,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => {
it("should render the NFT description", () => {
render(
<NFTProvider contract={DOODLES_CONTRACT} tokenId={1n}>
<NFTName />
<NFTDescription />
</NFTProvider>,
);
@@ -111,4 +76,31 @@ describe.runIf(process.env.TW_SECRET_KEY)("NFT prebuilt component", () => {
).toBeInTheDocument(),
);
});
it("useNFTContext should return the context value when used within NFTProvider", () => {
const wrapper: FC = ({ children }: React.PropsWithChildren) => (
<NFTProvider contract={DOODLES_CONTRACT} tokenId={0n}>
{children}
</NFTProvider>
);
const { result } = renderHook(() => useNFTContext(), { wrapper });
expect(result.current.contract).toStrictEqual(DOODLES_CONTRACT);
expect(result.current.tokenId).toBe(0n);
});
it("useNFTContext should throw an error when used outside of NFTProvider", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
expect(() => {
renderHook(() => useNFTContext());
}).toThrow(
"NFTProviderContext not found. Make sure you are using NFTMedia, NFTDescription, etc. inside a <NFTProvider /> component",
);
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
DOODLES_CONTRACT,
DROP1155_CONTRACT,
UNISWAPV3_FACTORY_CONTRACT,
} from "~test/test-contracts.js";
import { getNFTInfo } from "./utils.js";
describe.runIf(process.env.TW_SECRET_KEY)("getNFTInfo", () => {
it("should work with ERC721", async () => {
const nft = await getNFTInfo({
contract: DOODLES_CONTRACT,
tokenId: 0n,
});
expect(nft).toStrictEqual({
id: 0n,
metadata: {
attributes: [
{
trait_type: "face",
value: "mustache",
},
{
trait_type: "hair",
value: "purple long",
},
{
trait_type: "body",
value: "blue and yellow jacket",
},
{
trait_type: "background",
value: "green",
},
{
trait_type: "head",
value: "tan",
},
],
description:
"A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.",
image: "ipfs://QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn",
name: "Doodle #0",
},
owner: null,
tokenURI: "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0",
type: "ERC721",
});
});
it("should work with ERC1155", async () => {
const nft = await getNFTInfo({
contract: DROP1155_CONTRACT,
tokenId: 0n,
});
expect(nft).toStrictEqual({
id: 0n,
metadata: {
animation_url:
"ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4",
attributes: [
{
trait_type: "Revenue Share",
value: "40%",
},
{
trait_type: "Max Supply",
value: "50",
},
{
trait_type: "Max Per Wallet",
value: "1",
},
],
background_color: "",
description: "",
external_url: "https://auraexchange.org",
image: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png",
name: "Aura OG",
},
owner: null,
supply: 33n,
tokenURI: "ipfs://QmNgevzVNwJWJdErFY2B7KsuKdJz3gVuBraNKaSxPktLh5/0",
type: "ERC1155",
});
});
it("should throw error if failed to load nft info", async () => {
await expect(() =>
getNFTInfo({ contract: UNISWAPV3_FACTORY_CONTRACT, tokenId: 0n }),
).rejects.toThrowError("Failed to load NFT metadata");
});
});
@@ -0,0 +1,41 @@
import {} from "@tanstack/react-query";
import { getNFT as getNFT721 } from "../../../../../extensions/erc721/read/getNFT.js";
import { getNFT as getNFT1155 } from "../../../../../extensions/erc1155/read/getNFT.js";
import type { NFT } from "../../../../../utils/nft/parseNft.js";
import { withCache } from "../../../../../utils/promise/withCache.js";
import type { NFTProviderProps } from "./provider.js";
/**
* @internal
*/
export async function getNFTInfo(options: NFTProviderProps): Promise<NFT> {
return withCache(
async () => {
const nft = await Promise.allSettled([
getNFT721(options),
getNFT1155(options),
]).then(([possibleNFT721, possibleNFT1155]) => {
// getNFT extension always return an NFT object
// so we need to check if the tokenURI exists
if (
possibleNFT721.status === "fulfilled" &&
possibleNFT721.value.tokenURI
) {
return possibleNFT721.value;
}
if (
possibleNFT1155.status === "fulfilled" &&
possibleNFT1155.value.tokenURI
) {
return possibleNFT1155.value;
}
throw new Error("Failed to load NFT metadata");
});
return nft;
},
{
cacheKey: `nft_info:${options.contract.chain.id}:${options.contract.address}:${options.tokenId.toString()}`,
cacheTime: 15 * 60 * 1000,
},
);
}
@@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest";
import { numberToHex } from "./hex.js";
describe("hex.ts", () => {
it("should convert number with no padding", () => {
const result = numberToHex(1);
expect(result).toBe("0x1");
});
it("should convert", () => {
const result = numberToHex(100n, { size: 32, signed: false });
expect(result).toBe(
+7 -1
View File
@@ -1,5 +1,11 @@
/**
* @internal
* Round up a number to a certain decimal place
* @example
* ```ts
* import { formatNumber } from "thirdweb/utils";
* const value = formatNumber(12.1214141, 1); // 12.1
* ```
* @utils
*/
export function formatNumber(value: number, decimalPlaces: number) {
if (value === 0) return 0;
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { shortenLargeNumber } from "./shortenLargeNumber.js";
describe("shortenLargeNumber", () => {
it("should not affect number below 999", () => {
expect(shortenLargeNumber(999)).toBe("999");
});
it("should toLocaleString number below 10000", () => {
expect(shortenLargeNumber(1000)).toBe("1,000");
expect(shortenLargeNumber(9999)).toBe("9,999");
});
it("should shorten the number to `k`", () => {
expect(shortenLargeNumber(10000)).toBe("10k");
});
it("should shorten the number to `M`", () => {
expect(shortenLargeNumber(1_000_000)).toBe("1M");
});
it("should shorten the number to `B`", () => {
expect(shortenLargeNumber(1_000_000_000)).toBe("1B");
});
it("should shorten the number to `k`", () => {
expect(shortenLargeNumber(11100)).toBe("11.1k");
});
it("should shorten the number to `M`", () => {
expect(shortenLargeNumber(1_100_000)).toBe("1.1M");
});
it("should shorten the number to `B`", () => {
expect(shortenLargeNumber(1_100_000_001)).toBe("1.1B");
});
});
@@ -0,0 +1,48 @@
/**
* Shorten the string for large value
* Mainly used for
* Examples:
* 10_000 -> 10k
* 1_000_000 -> 1M
* 1_000_000_000 -> 1B
* @example
* ```ts
* import { shortenLargeNumber } from "thirdweb/utils";
* const numStr = shortenLargeNumber(1_000_000_000, )
* ```
* @utils
*/
export function shortenLargeNumber(value: number) {
if (value < 1000) {
return value.toString();
}
if (value < 10_000) {
return value.toLocaleString("en-US");
}
if (value < 1_000_000) {
return formatLargeNumber(value, 1_000, "k");
}
if (value < 1_000_000_000) {
return formatLargeNumber(value, 1_000_000, "M");
}
return formatLargeNumber(value, 1_000_000_000, "B");
}
/**
* Shorten the string for large value (over 4 digits)
* 1000 -> 1000
* 10_000 -> 10k
* 1_000_000 -> 1M
* 1_000_000_000 -> 1B
*/
function formatLargeNumber(
value: number,
divisor: number,
suffix: "k" | "M" | "B",
) {
const quotient = value / divisor;
if (Number.isInteger(quotient)) {
return Math.floor(quotient) + suffix;
}
return quotient.toFixed(1).replace(/\.0$/, "") + suffix;
}
@@ -463,11 +463,7 @@ export async function connectCoinbaseWalletSDK(
? options.chain
: getCachedChain(chainId);
// Switch to chain if provided
if (
connectedChainId &&
options?.chain &&
connectedChainId !== options?.chain.id
) {
if (chainId && options?.chain && chainId !== options?.chain.id) {
await switchChainCoinbaseWalletSDK(provider, options.chain);
chain = options.chain;
}
@@ -31,7 +31,9 @@ export async function generateWallet({
);
if (!response.ok) {
throw new Error("Failed to generate wallet");
throw new Error(
`Failed to generate wallet - ${response.status} ${response.statusText}`,
);
}
const { wallet } = (await response.json()) as {
@@ -43,7 +43,9 @@ export async function signMessage({
);
if (!response.ok) {
throw new Error("Failed to sign message");
throw new Error(
`Failed to sign message - ${response.status} ${response.statusText}`,
);
}
const signedMessage = (await response.json()) as {
@@ -38,7 +38,9 @@ export async function signTransaction({
);
if (!response.ok) {
throw new Error("Failed to sign transaction");
throw new Error(
`Failed to sign transaction - ${response.status} ${response.statusText}`,
);
}
const signedTransaction = (await response.json()) as {
@@ -41,7 +41,9 @@ export async function signTypedData<
);
if (!response.ok) {
throw new Error("Failed to sign typed data");
throw new Error(
`Failed to sign typed data - ${response.status} ${response.statusText}`,
);
}
const signedTypedData = (await response.json()) as {
@@ -162,7 +162,7 @@ export class EnclaveWallet implements IWebWallet {
chainId: toHex(tx.chainId),
};
if (tx.maxFeePerGas) {
if (typeof tx.maxFeePerGas === "bigint") {
transaction.maxFeePerGas = toHex(tx.maxFeePerGas);
transaction.maxPriorityFeePerGas =
typeof tx.maxPriorityFeePerGas === "bigint"

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