+
-
+
{props.tokenSymbol || "Tokens"}
-
-
-
-
- {distributionError && (
-
- {distributionError}
-
- )}
-
-
+
+
+
+
+
+
+ {distributionError && (
+
+ {distributionError}
+
+ )}
+
@@ -137,16 +144,19 @@ export function TokenDistributionBarChart(props: {
color: "hsl(var(--chart-1))",
label: "Owner",
percent: ownerPercentage,
+ value: `${ownerPercentage}%`,
},
{
color: "hsl(var(--chart-3))",
label: "Airdrop",
percent: airdropPercentage,
+ value: `${airdropPercentage}%`,
},
{
color: "hsl(var(--chart-4))",
label: "Sale",
percent: salePercentage,
+ value: `${salePercentage}%`,
},
];
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-sale.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-sale.tsx
index 67a4c9938d6..1c9cd420cde 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-sale.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-sale.tsx
@@ -1,11 +1,26 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
+import { DollarSignIcon, XIcon } from "lucide-react";
+import { useEffect, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
+import { defineChain } from "thirdweb";
+import { isPoolRouterEnabled } from "thirdweb/tokens";
+import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
-import { TokenSelector } from "@/components/blocks/TokenSelector";
+import { Badge } from "@/components/ui/badge";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { DecimalInput } from "@/components/ui/decimal-input";
+import { Spinner } from "@/components/ui/Spinner/Spinner";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
+import { useAllChainsData } from "@/hooks/chains/allChains";
import type { TokenDistributionForm } from "../_common/form";
export function TokenSaleSection(props: {
@@ -13,98 +28,162 @@ export function TokenSaleSection(props: {
chainId: string;
client: ThirdwebClient;
}) {
- const totalSupply = Number(props.form.watch("supply"));
- const sellSupply = Math.floor(
- (totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
- );
+ const saleMode = props.form.watch("saleMode");
+ const { idToChain } = useAllChainsData();
+ const chainMeta = idToChain.get(Number(props.chainId));
+ const [hasUserUpdatedSaleMode, setHasUserUpdatedSaleMode] = useState(false);
+
+ const isRouterEnabledQuery = useQuery({
+ queryFn: async () => {
+ try {
+ return await isPoolRouterEnabled({
+ // eslint-disable-next-line no-restricted-syntax
+ chain: defineChain(Number(props.chainId)),
+ client: props.client,
+ });
+ } catch {
+ return false;
+ }
+ },
+ queryKey: ["isRouterEnabled", props.chainId],
+ });
+
+ const isRouterEnabledValue = isRouterEnabledQuery.data === true;
+
+ const isSaleEnabled = saleMode !== "disabled";
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (isRouterEnabledValue === false && isSaleEnabled) {
+ props.form.setValue("saleMode", "disabled", {
+ shouldValidate: true,
+ });
+ }
+ }, [isRouterEnabledValue, isSaleEnabled, props.form]);
+
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (
+ isRouterEnabledValue === true &&
+ !hasUserUpdatedSaleMode &&
+ !isSaleEnabled
+ ) {
+ props.form.setValue("saleMode", "pool", {
+ shouldValidate: true,
+ });
+ }
+ }, [isRouterEnabledValue, props.form, hasUserUpdatedSaleMode, isSaleEnabled]);
+
+ const protocolFee = 20;
+ const leftOverFee = 100 - protocolFee;
+ const convenienceFee = (12.5 * leftOverFee) / 100;
+ const deployerFee = leftOverFee - convenienceFee;
- const isEnabled = props.form.watch("saleEnabled");
return (
-
-
-
-
Sale
-
- Make your coin available for purchase by setting a price
-
-
+
+
+
+
+
Sale
+
+ List your coin on a decentralized exchange and earn rewards on
+ every trade
+
+
-
{
- props.form.setValue("saleEnabled", checked);
- if (!checked) {
- props.form.setValue("saleAllocationPercentage", "0");
- props.form.setValue("salePrice", "0");
- }
- }}
- />
-
+
+ {isRouterEnabledQuery.isPending ? (
+
+ ) : (
+
{
+ if (!isRouterEnabledValue) {
+ return;
+ }
- {isEnabled && (
-
-
-
- {
- props.form.setValue("saleAllocationPercentage", value);
+ setHasUserUpdatedSaleMode(true);
+
+ props.form.setValue(
+ "saleMode",
+ checked ? "pool" : "disabled",
+ );
+
+ if (checked && !props.form.getValues("airdropEnabled")) {
+ props.form.setValue("saleAllocationPercentage", "100", {
+ shouldValidate: true,
+ });
+ } else {
+ props.form.setValue("saleAllocationPercentage", "0", {
+ shouldValidate: true,
+ });
+ }
}}
- value={props.form.watch("saleAllocationPercentage")}
/>
-
- %
-
+ )}
+
+
+
+ {isRouterEnabledQuery.data === false && (
+
+
+
-
-
-
-
-
- {
- props.form.setValue("salePrice", value);
- }}
- value={props.form.watch("salePrice")}
- />
+
+ Not Available on {chainMeta?.name || props.chainId}
+
+
+ )}
+
+
+ {saleMode === "pool" && isRouterEnabledQuery.data === true && (
+
+
+
+
+
+
+
-
-
-
- {
- props.form.setValue("saleTokenAddress", value.address);
- }}
- selectedToken={{
- address: props.form.watch("saleTokenAddress"),
- chainId: Number(props.chainId),
- }}
- showCheck={true}
+
+
Sale Rewards
+
+ All trades on the market are subjected to{" "}
+ 1% {" "}
+ fee distributed as:
+
+
+
)}
@@ -112,3 +191,105 @@ export function TokenSaleSection(props: {
);
}
+
+function PoolConfig(props: {
+ form: TokenDistributionForm;
+ chainId: string;
+ client: ThirdwebClient;
+}) {
+ const { idToChain } = useAllChainsData();
+ const chainMeta = idToChain.get(Number(props.chainId));
+
+ const totalSupply = Number(props.form.watch("supply"));
+ const sellSupply = Math.floor(
+ (totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
+ );
+
+ return (
+
+ {/* Pricing Strategy */}
+
+
+
+
+
+ {/* supply % */}
+
+
+ {
+ props.form.setValue("saleAllocationPercentage", value, {
+ shouldValidate: true,
+ });
+ }}
+ value={props.form.watch("saleAllocationPercentage")}
+ />
+
+ %
+
+
+
+
+ {/* starting price */}
+
+
+ {
+ props.form.setValue("pool.startingPricePerToken", value, {
+ shouldValidate: true,
+ });
+ }}
+ value={props.form.watch("pool.startingPricePerToken")}
+ />
+
+ {chainMeta?.nativeCurrency.symbol || "ETH"}
+
+
+
+
+
+ );
+}
+
+const compactNumberFormatter = new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 10,
+ notation: "compact",
+});
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
index c3c552351ed..bd408cf9575 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx
@@ -39,6 +39,7 @@ import { TokenDistributionBarChart } from "../distribution/token-distribution";
const stepIds = {
"airdrop-tokens": "airdrop-tokens",
+ "approve-airdrop-tokens": "approve-airdrop-tokens",
"deploy-contract": "deploy-contract",
"mint-tokens": "mint-tokens",
"set-claim-conditions": "set-claim-conditions",
@@ -51,7 +52,10 @@ export function LaunchTokenStatus(props: {
values: CreateAssetFormValues;
onPrevious: () => void;
client: ThirdwebClient;
- onLaunchSuccess: () => void;
+ onLaunchSuccess: (params: {
+ chainId: number;
+ contractAddress: string;
+ }) => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
@@ -60,7 +64,7 @@ export function LaunchTokenStatus(props: {
const { createTokenFunctions } = props;
const [steps, setSteps] = useState
[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
- const [contractLink, setContractLink] = useState(null);
+ const [contractAddress, setContractAddress] = useState(null);
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
@@ -91,19 +95,15 @@ export function LaunchTokenStatus(props: {
label: "Deploy contract",
status: { type: "idle" },
},
- {
- id: stepIds["set-claim-conditions"],
- label: "Set claim conditions",
- status: { type: "idle" },
- },
- {
- id: stepIds["mint-tokens"],
- label: "Mint tokens",
- status: { type: "idle" },
- },
];
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
+ initialSteps.push({
+ id: stepIds["approve-airdrop-tokens"],
+ label: "Approve spending tokens for airdrop",
+ status: { type: "idle" },
+ });
+
initialSteps.push({
id: stepIds["airdrop-tokens"],
label: "Airdrop tokens",
@@ -127,15 +127,11 @@ export function LaunchTokenStatus(props: {
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(params);
- setContractLink(
- `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
- );
- } else if (stepId === "set-claim-conditions") {
- await createTokenFunctions.setClaimConditions(params);
- } else if (stepId === "mint-tokens") {
- await createTokenFunctions.mintTokens(params);
+ setContractAddress(result.contractAddress);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(params);
+ } else if (stepId === "approve-airdrop-tokens") {
+ await createTokenFunctions.approveAirdropTokens(params);
}
}
@@ -184,7 +180,12 @@ export function LaunchTokenStatus(props: {
contractType: "DropERC20",
});
- props.onLaunchSuccess();
+ if (contractAddress) {
+ props.onLaunchSuccess({
+ chainId: Number(formValues.chain),
+ contractAddress,
+ });
+ }
}
async function handleRetry(step: MultiStepState, gasless: boolean) {
@@ -196,6 +197,10 @@ export function LaunchTokenStatus(props: {
await executeSteps(steps, startIndex, gasless);
}
+ const contractLink = contractAddress
+ ? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${contractAddress}`
+ : null;
+
return (
form.setValue("image", file, {
- shouldTouch: true,
+ shouldValidate: true,
})
}
value={form.watch("image")}
@@ -59,7 +60,7 @@ export function TokenInfoFieldset(props: {
{/* name + symbol */}
-
+ }>
{
- form.setValue("chain", chain.toString());
+ form.setValue("chain", chain.toString(), {
+ shouldValidate: true,
+ });
props.onChainUpdated();
}}
/>
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/utils/calculate-tick.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/utils/calculate-tick.ts
new file mode 100644
index 00000000000..97e53335146
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/utils/calculate-tick.ts
@@ -0,0 +1,17 @@
+const MIN_TICK = -887200;
+const MAX_TICK = 887200;
+const TICK_SPACING = 200;
+
+export function getInitialTickValue(params: { startingPricePerToken: number }) {
+ const calculatedTick =
+ Math.log(params.startingPricePerToken) / Math.log(1.0001);
+
+ // Round to nearest tick spacing
+ const tick = Math.round(calculatedTick / TICK_SPACING) * TICK_SPACING;
+
+ return tick;
+}
+
+export function isValidTickValue(tick: number) {
+ return tick >= MIN_TICK && tick <= MAX_TICK;
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.stories.tsx
new file mode 100644
index 00000000000..e324f36bf65
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.stories.tsx
@@ -0,0 +1,96 @@
+import type { Meta } from "@storybook/nextjs";
+import { toUnits } from "thirdweb";
+import { base } from "thirdweb/chains";
+import { ThirdwebProvider } from "thirdweb/react";
+import { storybookThirdwebClient } from "@/storybook/utils";
+import { ClaimRewardsPageUI } from "./claim-rewards-page";
+
+const meta = {
+ component: ClaimRewardsPageUI,
+ title: "contracts/extensions/claim-rewards",
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+const recipient = "0x8C00f3F231c88CcAc2382AaC6e09A78D4F42129d";
+const referrer = "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94";
+
+function unclaimedFeesStub(token0Amount: bigint, token1Amount: bigint) {
+ return {
+ token0: {
+ address: "0x1234567890123456789012345678901234567890",
+ amount: token0Amount,
+ symbol: "FOO",
+ },
+ token1: {
+ address: "0x0987654321098765432109876543210987654321",
+ amount: token1Amount,
+ symbol: "BAR",
+ },
+ };
+}
+
+export function LargeAmounts() {
+ return (
+
+ );
+}
+
+export function SmallAmounts() {
+ return (
+
+ );
+}
+
+export function ZeroAmount() {
+ return (
+
+ );
+}
+
+export function ZeroAmountNoChainExplorer() {
+ return (
+
+ );
+}
+
+function Variant(props: {
+ token0Amount: bigint;
+ token1Amount: bigint;
+ includeChainExplorer?: boolean;
+}) {
+ return (
+ {}}
+ isClaimPending={false}
+ chain={base}
+ unclaimedFees={unclaimedFeesStub(props.token0Amount, props.token1Amount)}
+ />
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.tsx
new file mode 100644
index 00000000000..173b6834822
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/components/claim-rewards-page.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { DollarSignIcon, ExternalLinkIcon, SplitIcon } from "lucide-react";
+import Link from "next/link";
+import { toast } from "sonner";
+import {
+ type Chain,
+ type ThirdwebClient,
+ type ThirdwebContract,
+ toTokens,
+} from "thirdweb";
+import { TokenIcon, TokenProvider, useSendTransaction } from "thirdweb/react";
+import { claimRewards } from "thirdweb/tokens";
+import { DistributionBarChart } from "@/components/blocks/distribution-chart";
+import { WalletAddress } from "@/components/blocks/wallet-address";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/Spinner/Spinner";
+import { useDashboardRouter } from "@/lib/DashboardRouter";
+import { parseError } from "@/utils/errorParser";
+import { tryCatch } from "@/utils/try-catch";
+import type { getValidReward } from "../utils/rewards";
+
+export function ClaimRewardsPage(props: {
+ assetContractClient: ThirdwebContract;
+ entrypointContractClient: ThirdwebContract;
+ reward: NonNullable>>;
+ unclaimedFees: {
+ token0: {
+ address: string;
+ amount: bigint;
+ symbol: string;
+ };
+ token1: {
+ address: string;
+ amount: bigint;
+ symbol: string;
+ };
+ };
+ chainSlug: string;
+}) {
+ const sendTx = useSendTransaction();
+ const router = useDashboardRouter();
+
+ async function handleClaim() {
+ const claimRewardsTx = claimRewards({
+ asset: props.assetContractClient.address,
+ contract: props.entrypointContractClient,
+ });
+
+ const claimRewardsResult = await tryCatch(
+ sendTx.mutateAsync(claimRewardsTx),
+ );
+
+ if (claimRewardsResult.error) {
+ toast.error("Failed to distribute rewards", {
+ description: parseError(claimRewardsResult.error),
+ });
+ } else {
+ toast.success("Rewards distributed successfully");
+ router.refresh();
+ }
+ }
+
+ return (
+
+ );
+}
+
+function calculateFees(referrerBps: number) {
+ // 20% of is protocol fees
+ // remaining is split between referrer and recipient
+
+ const protocolFees = 20;
+ const remaining = 100 - protocolFees;
+
+ const referrerPercentageFinal = (remaining * referrerBps) / 10000;
+
+ return {
+ protocolFees,
+ referrerPercentage: referrerPercentageFinal,
+ recipientPercentage: 100 - protocolFees - referrerPercentageFinal,
+ };
+}
+
+export function ClaimRewardsPageUI(props: {
+ unclaimedFees: {
+ token0: {
+ address: string;
+ amount: bigint;
+ symbol: string;
+ };
+ token1: {
+ address: string;
+ amount: bigint;
+ symbol: string;
+ };
+ };
+ recipient: string;
+ referrer: string;
+ referrerBps: number;
+ handleClaim: () => void;
+ isClaimPending: boolean;
+ client: ThirdwebClient;
+ chain: Chain;
+ chainSlug: string;
+}) {
+ const fees = calculateFees(props.referrerBps);
+
+ const recipientColor = `hsl(var(--chart-1))`;
+ const referrerColor = `hsl(var(--chart-2))`;
+ const protocolFeesColor = `hsl(var(--chart-3))`;
+
+ const hasUnclaimedRewards =
+ props.unclaimedFees.token0.amount > 0 ||
+ props.unclaimedFees.token1.amount > 0;
+
+ return (
+
+
+
+
+
+
+ Rewards
+
+
+ Earnings received by Liquidity Providers (LPs) in exchange for
+ depositing tokens into {props.unclaimedFees.token0.symbol} /{" "}
+ {props.unclaimedFees.token1.symbol} Uniswap liquidity pool
+
+
+
+
+
+
Unclaimed Rewards
+
+ The rewards that are earned but haven't been distributed yet
+
+
+
+
+
+
+
+
+
+
Reward Distribution
+
+ The unclaimed rewards will be distributed as:
+
+
+
+
+
+
+
+
+
+
+ {hasUnclaimedRewards && (
+
+ Click on "Distribute Rewards" to distribute unclaimed rewards
+
+ )}
+
+ {!hasUnclaimedRewards && (
+
+ There are no unclaimed rewards available for distribution
+
+ )}
+
+
+
+
+ );
+}
+
+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..367dc97932f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/page.tsx
@@ -0,0 +1,74 @@
+import { notFound, redirect } from "next/navigation";
+import { getContract } from "thirdweb";
+import {
+ getDeployedEntrypointERC20,
+} from "thirdweb/tokens";
+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,
+ });
+
+ if (!reward) {
+ 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.positionId,
+ 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..5774ac9fc5d
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards.ts
@@ -0,0 +1,23 @@
+import { type ThirdwebContract } from "thirdweb";
+import { getRewards } from "thirdweb/tokens";
+
+export async function getValidReward(params: {
+ assetContract: ThirdwebContract;
+ entrypointContract: ThirdwebContract;
+}) {
+ try {
+ const rewards = await getRewards({
+ contract: params.entrypointContract,
+ asset: params.assetContract.address,
+ });
+
+ if (rewards.length === 0) {
+ return null;
+ }
+
+ // It's potentially possible to have multiple rewards locked up, but it's not the default use case.
+ return rewards[0];
+ } 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..3e0adcb6e9e
--- /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";
+
+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,
+ },
+ };
+}