diff --git a/apps/dashboard/src/@/components/blocks/wallet-address.tsx b/apps/dashboard/src/@/components/blocks/wallet-address.tsx index 94c90837c8d..d839ee14a16 100644 --- a/apps/dashboard/src/@/components/blocks/wallet-address.tsx +++ b/apps/dashboard/src/@/components/blocks/wallet-address.tsx @@ -1,5 +1,5 @@ "use client"; -import { CheckIcon, CopyIcon, XIcon } from "lucide-react"; +import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react"; import { useMemo } from "react"; import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb"; import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react"; @@ -24,6 +24,7 @@ export function WalletAddress(props: { iconClassName?: string; client: ThirdwebClient; preventOpenOnFocus?: boolean; + fallbackIcon?: React.ReactNode; }) { // default back to zero address if no address provided const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]); @@ -60,9 +61,16 @@ export function WalletAddress(props: { // special case for zero address if (address === ZERO_ADDRESS) { return ( - - {shortenedAddress} - +
+ + + {shortenedAddress} + +
); } @@ -86,6 +94,7 @@ export function WalletAddress(props: { iconClassName={props.iconClassName} profiles={profiles.data || []} thirdwebClient={props.client} + fallbackIcon={props.fallbackIcon} /> )} @@ -177,6 +186,7 @@ function WalletAvatar(props: { profiles: SocialProfile[]; thirdwebClient: ThirdwebClient; iconClassName?: string; + fallbackIcon?: React.ReactNode; }) { const avatar = useMemo(() => { return props.profiles.find( @@ -203,6 +213,8 @@ function WalletAvatar(props: { className={cn("size-6 object-cover", props.iconClassName)} src={resolvedAvatarSrc} /> + ) : props.fallbackIcon ? ( + props.fallbackIcon ) : ( = { DropERC20: "Coin", DropERC721: "NFT Collection", DropERC1155: "NFT Collection", + ERC20Asset: "Coin", }; const NetworkFilterCell = React.memo(function NetworkFilterCell({ diff --git a/apps/dashboard/src/@/components/ui/CopyAddressButton.tsx b/apps/dashboard/src/@/components/ui/CopyAddressButton.tsx index 84cb36d2879..928de7e559d 100644 --- a/apps/dashboard/src/@/components/ui/CopyAddressButton.tsx +++ b/apps/dashboard/src/@/components/ui/CopyAddressButton.tsx @@ -25,6 +25,7 @@ export function CopyAddressButton(props: { copyIconPosition={props.copyIconPosition} textToCopy={props.address} textToShow={shortenedAddress} + iconClassName={props.iconClassName} tooltip={props.tooltip || "Copy Address"} variant={props.variant} /> diff --git a/apps/dashboard/src/@/components/ui/tabs.tsx b/apps/dashboard/src/@/components/ui/tabs.tsx index 47acea12b5d..a2239f181ef 100644 --- a/apps/dashboard/src/@/components/ui/tabs.tsx +++ b/apps/dashboard/src/@/components/ui/tabs.tsx @@ -98,6 +98,7 @@ export function TabButtons(props: { shadowColor?: string; tabIconClassName?: string; hideBottomLine?: boolean; + bottomLineClassName?: string; }) { const { containerRef, lineRef, activeTabRef } = useUnderline(); @@ -106,7 +107,12 @@ export function TabButtons(props: {
{/* Bottom line */} {!props.hideBottomLine && ( -
+
)} { const res = await apiServerProxy({ body: JSON.stringify({ diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadata.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadata.ts index c95e928e05b..df9b49592c9 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadata.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadata.ts @@ -25,6 +25,7 @@ export type ContractPageMetadata = { isAccount: boolean; isAccountPermissionsSupported: boolean; functionSelectors: string[]; + showClaimRewards: boolean; }; export async function getContractPageMetadata(contract: ThirdwebContract) { diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadataSetup.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadataSetup.ts index 5785ed02e8a..ed173c54984 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadataSetup.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageMetadataSetup.ts @@ -1,6 +1,8 @@ import type { ThirdwebContract } from "thirdweb"; +import { getDeployedEntrypointERC20 } from "thirdweb/assets"; import { contractType as getContractType } from "thirdweb/extensions/thirdweb"; import { resolveFunctionSelectors } from "@/lib/selectors"; +import { getValidReward } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards"; import { isERC20ClaimConditionsSupported, isERC721ClaimConditionsSupported, @@ -43,6 +45,7 @@ type ContractPageMetadata = { isAccount: boolean; isAccountPermissionsSupported: boolean; functionSelectors: string[]; + showClaimRewards: boolean; }; export async function getContractPageMetadataSetup( @@ -53,10 +56,14 @@ export async function getContractPageMetadataSetup( functionSelectorsResult, isInsightSupportedResult, contractTypeResult, + claimRewardResult, ] = await Promise.allSettled([ resolveFunctionSelectors(contract), isAnalyticsSupportedFn(contract.chain.id), getContractType({ contract }), + isClaimRewardsSupported({ + assetContract: contract, + }), ]); const functionSelectors = @@ -72,6 +79,11 @@ export async function getContractPageMetadataSetup( const contractType = contractTypeResult.status === "fulfilled" ? contractTypeResult.value : null; + const showClaimRewards = + claimRewardResult.status === "fulfilled" + ? !!claimRewardResult.value + : false; + return { embedType: getEmbedTypeToShow(functionSelectors), functionSelectors, @@ -93,5 +105,30 @@ export async function getContractPageMetadataSetup( isSplitSupported: contractType === "Split", isVoteContract: contractType === "VoteERC20", supportedERCs: supportedERCs(functionSelectors), + showClaimRewards, }; } + +async function isClaimRewardsSupported(params: { + assetContract: ThirdwebContract; +}): Promise { + try { + const entrypointContract = await getDeployedEntrypointERC20({ + chain: params.assetContract.chain, + client: params.assetContract.client, + }); + + if (!entrypointContract) { + return false; + } + + const reward = await getValidReward({ + assetContract: params.assetContract, + entrypointContract, + }); + + return !!reward; + } catch { + return false; + } +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts index c6e714628d9..489371c7f1e 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractPageSidebarLinks.ts @@ -140,6 +140,12 @@ export function getContractPageSidebarLinks(data: { href: `${layoutPrefix}/permissions`, label: "Permissions", }, + { + exactMatch: true, + hide: !data.metadata.showClaimRewards, + href: `${layoutPrefix}/rewards`, + label: "Rewards", + }, ]; const extensionsToShow = extensionsLinks.filter((l) => !l.hide); diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx index fc04b64021a..60798949aa6 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/RecentTransfers.tsx @@ -1,11 +1,12 @@ "use client"; import { formatDistanceToNow } from "date-fns"; import { + ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; import { WalletAddress } from "@/components/blocks/wallet-address"; @@ -20,7 +21,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { cn } from "@/lib/utils"; import { type TokenTransfersData, useTokenTransfers, @@ -45,6 +45,32 @@ function RecentTransfersUI(props: { explorerUrl: string; client: ThirdwebClient; }) { + const groupedData = useMemo(() => { + const data: Array<{ + group: TokenTransfersData[]; + transactionHash: string; + blockTimestamp: string; + }> = []; + + for (const transfer of props.data) { + const existingGroup = data.find( + (group) => group.transactionHash === transfer.transaction_hash, + ); + + if (existingGroup) { + existingGroup.group.push(transfer); + } else { + data.push({ + group: [transfer], + transactionHash: transfer.transaction_hash, + blockTimestamp: transfer.block_timestamp, + }); + } + } + + return data; + }, [props.data]); + return (
@@ -74,76 +100,89 @@ function RecentTransfersUI(props: { // biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED )) - : props.data.map((transfer) => ( + : groupedData.map((group) => ( - - - - - + {/* From */} + + {group.group.map((transfer) => ( +
+ + +
+ ))}
- -
- - {tokenAmountFormatter.format( - Number( - toTokens( - BigInt(transfer.amount), - props.tokenMetadata.decimals, - ), - ), - )} - - - {props.tokenMetadata.symbol} - -
+ + {/* To */} + + {group.group.map((transfer) => ( +
+ +
+ ))}
- - {formatDistanceToNow( - new Date( - transfer.block_timestamp.endsWith("Z") - ? transfer.block_timestamp - : `${transfer.block_timestamp}Z`, - ), - { - addSuffix: true, - }, - )} + + {/* Amount */} + + {group.group.map((transfer) => ( +
+ +
+ ))}
+ + {/* timestamp */} - + + + + +
))} @@ -183,6 +222,34 @@ function RecentTransfersUI(props: { ); } +function timestamp(block_timestamp: string) { + return formatDistanceToNow( + new Date( + block_timestamp.endsWith("Z") ? block_timestamp : `${block_timestamp}Z`, + ), + { + addSuffix: true, + }, + ); +} + +function TokenAmount(props: { + amount: string; + decimals: number; + symbol: string; +}) { + return ( +
+ + {tokenAmountFormatter.format( + Number(toTokens(BigInt(props.amount), props.decimals)), + )} + + {props.symbol} +
+ ); +} + function SkeletonRow() { return ( @@ -199,7 +266,7 @@ function SkeletonRow() { - + ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_apis/create-token-on-bridge.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_apis/create-token-on-bridge.ts new file mode 100644 index 00000000000..2d7eee9fef3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_apis/create-token-on-bridge.ts @@ -0,0 +1,23 @@ +import type { ThirdwebClient } from "thirdweb"; +import { isProd } from "@/constants/env-utils"; + +export async function createTokenOnUniversalBridge(params: { + chainId: number; + tokenAddress: string; + client: ThirdwebClient; +}) { + const domain = isProd ? "thirdweb.com" : "thirdweb-dev.com"; + const res = await fetch(`https://bridge.${domain}/v1/tokens`, { + body: JSON.stringify({ + chainId: params.chainId.toString(), + tokenAddress: params.tokenAddress, + }), + headers: { + "Content-Type": "application/json", + "x-client-id": params.client.clientId, + }, + method: "POST", + }); + + return res; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/step-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/step-card.tsx index fa14752c80f..316ee8b824f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/step-card.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/step-card.tsx @@ -35,7 +35,7 @@ export function StepCard(props: { {props.children} {(props.prevButton || props.nextButton) && ( -
+
{props.prevButton && ( +
+
+
+ ); +} + +function TokenReward(props: { + token: { + address: string; + amount: bigint; + symbol: string; + }; + client: ThirdwebClient; + chain: Chain; + chainSlug: string; +}) { + const fallbackIcon = ( +
+ {props.token.symbol[0]} +
+ ); + + return ( +
+
+ + + +
+
+

+ {toTokens(props.token.amount, 18)} {props.token.symbol} +

+ + + {props.token.address.slice(0, 6)}... + {props.token.address.slice(-4)} + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/page.tsx new file mode 100644 index 00000000000..eecf4a3d445 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/page.tsx @@ -0,0 +1,96 @@ +import { notFound, redirect } from "next/navigation"; +import { getContract } from "thirdweb"; +import { + getDeployedEntrypointERC20, + getRewardLocker, + v3PositionManager as getV3PositionManager, +} from "thirdweb/assets"; +import { getProject } from "@/api/projects"; +import { getContractPageParamsInfo } from "../../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams"; +import type { ProjectContractPageParams } from "../types"; +import { ClaimRewardsPage } from "./components/claim-rewards-page"; +import { getValidReward } from "./utils/rewards"; +import { getUnclaimedFees } from "./utils/unclaimed-fees"; + +export default async function Page(props: { + params: Promise; +}) { + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + notFound(); + } + + const info = await getContractPageParamsInfo({ + chainIdOrSlug: params.chainIdOrSlug, + contractAddress: params.contractAddress, + teamId: project.teamId, + }); + + if (!info) { + notFound(); + } + + const assetContractClient = info.clientContract; + + const entrypointContractClient = await getDeployedEntrypointERC20({ + chain: assetContractClient.chain, + client: assetContractClient.client, + }); + + const reward = await getValidReward({ + assetContract: assetContractClient, + entrypointContract: entrypointContractClient, + }); + + const rewardLocker = await getRewardLocker({ + contract: entrypointContractClient, + }).catch(() => null); + + if (!reward || !rewardLocker) { + redirect( + `/team/${params.team_slug}/${params.project_slug}/contract/${params.chainIdOrSlug}/${params.contractAddress}`, + ); + } + + const rewardLockerContractClient = getContract({ + address: rewardLocker, + chain: assetContractClient.chain, + client: assetContractClient.client, + }); + + const v3PositionManager = await getV3PositionManager({ + contract: rewardLockerContractClient, + }).catch(() => null); + + if (!v3PositionManager || v3PositionManager !== reward.positionManager) { + redirect( + `/team/${params.team_slug}/${params.project_slug}/contract/${params.chainIdOrSlug}/${params.contractAddress}`, + ); + } + + const v3PositionManagerContract = getContract({ + address: reward.positionManager, + chain: assetContractClient.chain, + client: assetContractClient.client, + }); + + const unclaimedFees = await getUnclaimedFees({ + positionManager: v3PositionManagerContract, + reward: { + tokenId: reward.tokenId, + recipient: reward.recipient, + }, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards.ts new file mode 100644 index 00000000000..7c6283d0100 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards.ts @@ -0,0 +1,28 @@ +import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; +import { getReward } from "thirdweb/assets"; + +export async function getValidReward(params: { + assetContract: ThirdwebContract; + entrypointContract: ThirdwebContract; +}) { + try { + const reward = await getReward({ + contract: params.entrypointContract, + asset: params.assetContract.address, + }); + + if ( + reward.positionManager === ZERO_ADDRESS || + reward.recipient === ZERO_ADDRESS || + reward.referrer === ZERO_ADDRESS || + reward.referrerBps === 0 || + reward.tokenId === BigInt(0) + ) { + return null; + } + + return reward; + } catch { + return null; + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/unclaimed-fees.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/unclaimed-fees.ts new file mode 100644 index 00000000000..16c1284602e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/unclaimed-fees.ts @@ -0,0 +1,88 @@ +import type { ThirdwebContract } from "thirdweb"; +import { getContract, readContract } from "thirdweb"; +import { symbol } from "thirdweb/extensions/common"; + +export const maxUint128 = 2n ** 128n - 1n; + +export async function getUnclaimedFees(params: { + positionManager: ThirdwebContract; + reward: { + tokenId: bigint; + recipient: string; + }; +}) { + const collectResultPromise = readContract({ + contract: params.positionManager, + method: + "function collect((uint256 tokenId,address recipient,uint128 amount0Max,uint128 amount1Max)) returns (uint256,uint256)", + params: [ + { + tokenId: params.reward.tokenId, + recipient: params.reward.recipient, + amount0Max: maxUint128, + amount1Max: maxUint128, + }, + ], + }); + + const positionsResultPromise = readContract({ + contract: params.positionManager, + method: + "function positions(uint256 tokenId) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)", + params: [params.reward.tokenId], + }); + + const [collectResult, positionsResult] = await Promise.all([ + collectResultPromise, + positionsResultPromise, + ]); + + // 0- nonce + // 1- owner + // 2- token0 + // 3- token1 + // 4 - fee + // 5 - tickLower + // 6 - tickUpper + // 7 - liquidity + // 8 - feeGrowthInside0LastX128 + // 9 - feeGrowthInside1LastX128 + // 10 - tokensOwed0 + // 11 - tokensOwed1 + + const client = params.positionManager.client; + const chain = params.positionManager.chain; + + const token0Address = positionsResult[2]; + const token1Address = positionsResult[3]; + + const [token0Symbol, token1Symbol] = await Promise.all([ + symbol({ + contract: getContract({ + address: token0Address, + chain, + client, + }), + }), + symbol({ + contract: getContract({ + address: token1Address, + chain, + client, + }), + }), + ]); + + return { + token0: { + address: token0Address, + amount: collectResult[0], + symbol: token0Symbol, + }, + token1: { + address: token1Address, + amount: collectResult[1], + symbol: token1Symbol, + }, + }; +} diff --git a/packages/thirdweb/src/exports/assets.ts b/packages/thirdweb/src/exports/assets.ts index a2cbef1ae7a..71967483f99 100644 --- a/packages/thirdweb/src/exports/assets.ts +++ b/packages/thirdweb/src/exports/assets.ts @@ -18,5 +18,10 @@ export type { PoolConfig, TokenParams, } from "../assets/types.js"; -export { getInitBytecodeWithSalt } from "../utils/any-evm/get-init-bytecode-with-salt.js"; export { getReward } from "../extensions/assets/__generated__/ERC20AssetEntrypoint/read/getReward.js"; +export { getRewardLocker } from "../extensions/assets/__generated__/ERC20AssetEntrypoint/read/getRewardLocker.js"; +export { claimReward } from "../extensions/assets/__generated__/ERC20AssetEntrypoint/write/claimReward.js"; +export { positions } from "../extensions/assets/__generated__/RewardLocker/read/positions.js"; +export { v3PositionManager } from "../extensions/assets/__generated__/RewardLocker/read/v3PositionManager.js"; +export { v4PositionManager } from "../extensions/assets/__generated__/RewardLocker/read/v4PositionManager.js"; +export { getInitBytecodeWithSalt } from "../utils/any-evm/get-init-bytecode-with-salt.js";