diff --git a/app/profile/[address]/page.tsx b/app/profile/[address]/page.tsx index 0ceaac68..45a41194 100644 --- a/app/profile/[address]/page.tsx +++ b/app/profile/[address]/page.tsx @@ -9,6 +9,7 @@ import { CollectionsTabContent } from "@/app/profile/[address]/collections-tab-c import { MarketplaceTabContent } from "@/app/profile/[address]/marketplace-tab-content"; import { BlueprintsTabContent } from "@/app/profile/[address]/blueprint-tab-content"; +import { ContractAccountBanner } from "@/components/profile/contract-accounts-banner"; export default function ProfilePage({ params, searchParams, @@ -22,6 +23,7 @@ export default function ProfilePage({ return (
+

Profile diff --git a/components/allowlist/create-allowlist-dialog.tsx b/components/allowlist/create-allowlist-dialog.tsx index e70b17bc..59b050ac 100644 --- a/components/allowlist/create-allowlist-dialog.tsx +++ b/components/allowlist/create-allowlist-dialog.tsx @@ -35,11 +35,15 @@ const defaultValues = [ export default function Component({ setAllowlistEntries, + setAllowlistURL, + allowlistURL, setOpen, open, initialValues, }: { setAllowlistEntries: (allowlistEntries: AllowlistEntry[]) => void; + setAllowlistURL: (allowlistURL: string) => void; + allowlistURL: string | undefined; setOpen: (open: boolean) => void; initialValues?: AllowListItem[]; open: boolean; @@ -55,6 +59,16 @@ export default function Component({ initialValues?.length ? initialValues : defaultValues, ); + useEffect(() => { + if (open && !allowList[0].address && !allowList[0].percentage) { + if (initialValues && initialValues.length > 0) { + setAllowList(initialValues); + } else { + setAllowList(defaultValues); + } + } + }, [open]); + useEffect(() => { if (validateAllowlistResponse?.success) { (async () => { @@ -141,6 +155,7 @@ export default function Component({ throw new Error("Allow list is empty"); } validateAllowlist({ allowList: parsedAllowList, totalUnits }); + setAllowlistURL(""); } catch (e) { if (errorHasMessage(e)) { toast({ @@ -264,6 +279,12 @@ export default function Component({ + {allowlistURL && ( +

+ If you edit an original allowlist imported via URL, the original + allowlist will be deleted. +

+ )}
+ + + ({ address: entry.address, percentage: calculatePercentageBigInt( - entry.units, + BigInt(entry.units), ).toString(), }))} /> @@ -610,7 +775,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { {formatNumber( - calculatePercentageBigInt(entry.units), + calculatePercentageBigInt(BigInt(entry.units)), )} % diff --git a/components/profile/contract-accounts-banner.tsx b/components/profile/contract-accounts-banner.tsx new file mode 100644 index 00000000..28c5113d --- /dev/null +++ b/components/profile/contract-accounts-banner.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useIsContract } from "@/hooks/useIsContract"; + +export function ContractAccountBanner({ address }: { address: string }) { + const { isContract, isLoading } = useIsContract(address); + + if (!isContract || isLoading) return null; + + return ( +
+
+ This is a smart contract address. Contract ownership may vary across + networks. Please verify ownership details for each network. +
+
+ ); +} diff --git a/components/profile/unclaimed-hypercert-claim-button.tsx b/components/profile/unclaimed-hypercert-claim-button.tsx index 8e0e87ec..ceb7c111 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -2,15 +2,12 @@ import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByClaimed"; import { Button } from "../ui/button"; -import { useHypercertClient } from "@/hooks/use-hypercert-client"; -import { waitForTransactionReceipt } from "viem/actions"; -import { useAccount, useSwitchChain, useWalletClient } from "wagmi"; +import { useAccount, useSwitchChain } from "wagmi"; import { useRouter } from "next/navigation"; import { Row } from "@tanstack/react-table"; -import { useStepProcessDialogContext } from "../global/step-process-dialog"; -import { createExtraContent } from "../global/extra-content"; -import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; import { useState } from "react"; +import { useAccountStore } from "@/lib/account-store"; +import { useClaimHypercert } from "@/hypercerts/hooks/useClaimHypercert"; interface UnclaimedHypercertClaimButtonProps { allowListRecord: Row; @@ -19,118 +16,62 @@ interface UnclaimedHypercertClaimButtonProps { export default function UnclaimedHypercertClaimButton({ allowListRecord, }: UnclaimedHypercertClaimButtonProps) { - const { client } = useHypercertClient(); - const { data: walletClient } = useWalletClient(); - const account = useAccount(); + const { address, chain: currentChain } = useAccount(); + const { selectedAccount } = useAccountStore(); const { refresh } = useRouter(); const [isLoading, setIsLoading] = useState(false); - const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = - useStepProcessDialogContext(); const { switchChain } = useSwitchChain(); const selectedHypercert = allowListRecord.original; const hypercertChainId = selectedHypercert?.hypercert_id?.split("-")[0]; + const activeAddress = selectedAccount?.address || (address as `0x${string}`); + const { mutateAsync: claimHypercert } = useClaimHypercert(); - const claimHypercert = async () => { + const handleClaim = async () => { setIsLoading(true); - setOpen(true); - setSteps([ - { id: "preparing", description: "Preparing to claim fraction..." }, - { id: "claiming", description: "Claiming fraction on-chain..." }, - { id: "confirming", description: "Waiting for on-chain confirmation" }, - { id: "route", description: "Creating your new fraction's link..." }, - { id: "done", description: "Claiming complete!" }, - ]); - - setTitle("Claim fraction from Allowlist"); - if (!client) { - throw new Error("No client found"); - } - - if (!walletClient) { - throw new Error("No wallet client found"); - } - - if (!account) { - throw new Error("No address found"); - } - - if ( - !selectedHypercert?.units || - !selectedHypercert?.proof || - !selectedHypercert?.token_id - ) { - throw new Error("Invalid allow list record"); - } - await setDialogStep("preparing, active"); - try { - await setDialogStep("claiming", "active"); - const tx = await client.mintClaimFractionFromAllowlist( - BigInt(selectedHypercert?.token_id), - BigInt(selectedHypercert?.units), - selectedHypercert?.proof as `0x${string}`[], - undefined, - ); - - if (!tx) { - await setDialogStep("claiming", "error"); - throw new Error("Failed to claim fraction"); + if ( + !selectedHypercert.token_id || + !selectedHypercert.units || + !selectedHypercert.proof + ) { + throw new Error("Invalid allow list record"); } - await setDialogStep("confirming", "active"); - const receipt = await waitForTransactionReceipt(walletClient, { - hash: tx, + await claimHypercert({ + tokenId: BigInt(selectedHypercert.token_id), + units: BigInt(selectedHypercert.units), + proof: selectedHypercert.proof as `0x${string}`[], }); - - if (receipt.status == "success") { - await setDialogStep("route", "active"); - const extraContent = createExtraContent({ - receipt: receipt, - hypercertId: selectedHypercert?.hypercert_id!, - chain: account.chain!, - }); - setExtraContent(extraContent); - await setDialogStep("done", "completed"); - await revalidatePathServerAction([ - `/hypercerts/${selectedHypercert?.hypercert_id}`, - `/profile/${account.address}?tab=hypercerts-claimable`, - `/profile/${account.address}?tab=hypercerts-owned`, - ]); - } else if (receipt.status == "reverted") { - await setDialogStep("confirming", "error", "Transaction reverted"); - } - setTimeout(() => { - refresh(); - }, 5000); } catch (error) { console.error(error); } finally { setIsLoading(false); + setTimeout(() => refresh(), 5000); } }; return ( ); } diff --git a/components/profile/unclaimed-table/unclaimed-fraction-table.tsx b/components/profile/unclaimed-table/unclaimed-fraction-table.tsx index dcfb1db3..2d4f5060 100644 --- a/components/profile/unclaimed-table/unclaimed-fraction-table.tsx +++ b/components/profile/unclaimed-table/unclaimed-fraction-table.tsx @@ -29,6 +29,8 @@ import UnclaimedHypercertBatchClaimButton from "../unclaimed-hypercert-butchClai import { TableToolbar } from "./table-toolbar"; import { useMediaQuery } from "@/hooks/use-media-query"; import { UnclaimedFraction } from "../unclaimed-hypercerts-list"; +import { useAccountStore } from "@/lib/account-store"; +import { useRouter } from "next/navigation"; export interface DataTableProps { columns: ColumnDef[]; @@ -36,6 +38,8 @@ export interface DataTableProps { } export function UnclaimedFractionTable({ columns, data }: DataTableProps) { + const { selectedAccount } = useAccountStore(); + const router = useRouter(); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -139,6 +143,11 @@ export function UnclaimedFractionTable({ columns, data }: DataTableProps) { setSelectedRecords(getSelectedRecords()); }, [rowSelection, getSelectedRecords]); + // Refresh the entire route when account changes + useEffect(() => { + router.refresh(); + }, [selectedAccount?.address, router]); + return (
diff --git a/hooks/useIsContract.ts b/hooks/useIsContract.ts new file mode 100644 index 00000000..b74492a9 --- /dev/null +++ b/hooks/useIsContract.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from "react"; + +import { ChainFactory } from "../lib/chainFactory"; +import { EvmClientFactory } from "../lib/evmClient"; + +const contractCache = new Map(); + +export function useIsContract(address: string) { + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (isLoading || contractCache.has(address)) return; + + async function checkContract() { + setIsLoading(true); + try { + const supportedChains = ChainFactory.getSupportedChains(); + const clients = supportedChains.map((chainId) => + EvmClientFactory.createClient(chainId), + ); + + const results = await Promise.allSettled( + clients.map((client) => + client.getCode({ address: address as `0x${string}` }), + ), + ); + + const result = results.some( + (result) => + result.status === "fulfilled" && + result.value !== undefined && + result.value !== "0x", + ); + + contractCache.set(address, result); + } finally { + setIsLoading(false); + } + } + + checkContract(); + }, [address]); + + return { + isContract: contractCache.get(address) ?? null, + isLoading, + }; +} diff --git a/hypercerts/ClaimHypercertStrategy.ts b/hypercerts/ClaimHypercertStrategy.ts new file mode 100644 index 00000000..f3346ffa --- /dev/null +++ b/hypercerts/ClaimHypercertStrategy.ts @@ -0,0 +1,23 @@ +import { Address, Chain } from "viem"; +import { HypercertClient } from "@hypercerts-org/sdk"; +import { UseWalletClientReturnType } from "wagmi"; + +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; + +export interface ClaimHypercertParams { + tokenId: bigint; + units: bigint; + proof: `0x${string}`[]; +} + +export abstract class ClaimHypercertStrategy { + constructor( + protected address: Address, + protected chain: Chain, + protected client: HypercertClient, + protected dialogContext: ReturnType, + protected walletClient: UseWalletClientReturnType, + ) {} + + abstract execute(params: ClaimHypercertParams): Promise; +} diff --git a/hypercerts/EOAClaimHypercertStrategy.ts b/hypercerts/EOAClaimHypercertStrategy.ts new file mode 100644 index 00000000..c3c05ce1 --- /dev/null +++ b/hypercerts/EOAClaimHypercertStrategy.ts @@ -0,0 +1,69 @@ +import { waitForTransactionReceipt } from "viem/actions"; + +import { createExtraContent } from "@/components/global/extra-content"; +import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; + +import { + ClaimHypercertStrategy, + ClaimHypercertParams, +} from "./ClaimHypercertStrategy"; + +export class EOAClaimHypercertStrategy extends ClaimHypercertStrategy { + async execute({ tokenId, units, proof }: ClaimHypercertParams) { + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + this.dialogContext; + const { data: walletClient } = this.walletClient; + + if (!this.client) throw new Error("No client found"); + if (!walletClient) throw new Error("No wallet client found"); + + setOpen(true); + setSteps([ + { id: "preparing", description: "Preparing to claim fraction..." }, + { id: "claiming", description: "Claiming fraction on-chain..." }, + { id: "confirming", description: "Waiting for on-chain confirmation" }, + { id: "route", description: "Creating your new fraction's link..." }, + { id: "done", description: "Claiming complete!" }, + ]); + setTitle("Claim fraction from Allowlist"); + + try { + await setDialogStep("claiming", "active"); + const tx = await this.client.mintClaimFractionFromAllowlist( + tokenId, + units, + proof, + undefined, + ); + + if (!tx) throw new Error("Failed to claim fraction"); + + await setDialogStep("confirming", "active"); + const receipt = await waitForTransactionReceipt(walletClient, { + hash: tx, + }); + + if (receipt.status === "success") { + await setDialogStep("route", "active"); + const extraContent = createExtraContent({ + receipt, + hypercertId: `${this.chain.id}-${tokenId}`, + chain: this.chain, + }); + setExtraContent(extraContent); + await setDialogStep("done", "completed"); + + await revalidatePathServerAction([ + `/hypercerts/${this.chain.id}-${tokenId}`, + `/profile/${this.address}?tab=hypercerts-claimable`, + `/profile/${this.address}?tab=hypercerts-owned`, + ]); + } else { + await setDialogStep("confirming", "error", "Transaction reverted"); + } + } catch (error) { + console.error(error); + throw error; + } + } +} diff --git a/hypercerts/EOAMintHypercertStrategy.ts b/hypercerts/EOAMintHypercertStrategy.ts new file mode 100644 index 00000000..02288534 --- /dev/null +++ b/hypercerts/EOAMintHypercertStrategy.ts @@ -0,0 +1,169 @@ +import { track } from "@vercel/analytics"; +import { waitForTransactionReceipt } from "viem/actions"; + +import { createExtraContent } from "@/components/global/extra-content"; +import { generateHypercertIdFromReceipt } from "@/lib/generateHypercertIdFromReceipt"; +import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; + +import { + MintHypercertParams, + MintHypercertStrategy, +} from "./MintHypercertStrategy"; +import { Address, Chain } from "viem"; +import { HypercertClient } from "@hypercerts-org/sdk"; +import { UseWalletClientReturnType } from "wagmi"; +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; +import { useQueueMintBlueprint } from "@/blueprints/hooks/queueMintBlueprint"; + +export class EOAMintHypercertStrategy extends MintHypercertStrategy { + constructor( + protected address: Address, + protected chain: Chain, + protected client: HypercertClient, + protected dialogContext: ReturnType, + protected queueMintBlueprint: ReturnType, + protected walletClient: UseWalletClientReturnType, + ) { + super(address, chain, client, dialogContext, walletClient); + } + + // FIXME: this is a long ass method. Break it down into smaller ones. + async execute({ + metaData, + units, + transferRestrictions, + allowlistRecords, + blueprintId, + }: MintHypercertParams) { + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + this.dialogContext; + const { mutateAsync: queueMintBlueprint } = this.queueMintBlueprint; + const { data: walletClient } = this.walletClient; + + if (!this.client) { + setOpen(false); + throw new Error("No client found"); + } + + const isBlueprint = !!blueprintId; + setOpen(true); + setSteps([ + { id: "preparing", description: "Preparing to mint hypercert..." }, + { id: "minting", description: "Minting hypercert on-chain..." }, + ...(isBlueprint + ? [{ id: "blueprint", description: "Queueing blueprint mint..." }] + : []), + { id: "confirming", description: "Waiting for on-chain confirmation" }, + { id: "route", description: "Creating your new hypercert's link..." }, + { id: "done", description: "Minting complete!" }, + ]); + setTitle("Minting hypercert"); + await setDialogStep("preparing", "active"); + console.log("preparing..."); + + let hash; + try { + await setDialogStep("minting", "active"); + console.log("minting..."); + hash = await this.client.mintHypercert({ + metaData, + totalUnits: units, + transferRestriction: transferRestrictions, + allowList: allowlistRecords, + }); + } catch (error: unknown) { + console.error("Error minting hypercert:", error); + throw new Error( + `Failed to mint hypercert: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + + if (!hash) { + throw new Error("No transaction hash returned"); + } + + if (blueprintId) { + try { + await setDialogStep("blueprint", "active"); + await queueMintBlueprint({ + blueprintId, + txHash: hash, + }); + } catch (error: unknown) { + console.error("Error queueing blueprint mint:", error); + throw new Error( + `Failed to queue blueprint mint: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + await setDialogStep("confirming", "active"); + console.log("Mint submitted", { + hash, + }); + track("Mint submitted", { + hash, + }); + let receipt; + + try { + receipt = await waitForTransactionReceipt(walletClient!, { + confirmations: 3, + hash, + }); + console.log({ receipt }); + } catch (error: unknown) { + console.error("Error waiting for transaction receipt:", error); + await setDialogStep( + "confirming", + "error", + error instanceof Error ? error.message : "Unknown error", + ); + throw new Error( + `Failed to confirm transaction: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + + if (receipt?.status === "reverted") { + throw new Error("Transaction reverted: Minting failed"); + } + + await setDialogStep("route", "active"); + + let hypercertId; + try { + hypercertId = generateHypercertIdFromReceipt(receipt, this.chain.id); + console.log("Mint completed", { + hypercertId: hypercertId || "not found", + }); + track("Mint completed", { + hypercertId: hypercertId || "not found", + }); + console.log({ hypercertId }); + } catch (error) { + console.error("Error generating hypercert ID:", error); + await setDialogStep( + "route", + "error", + error instanceof Error ? error.message : "Unknown error", + ); + } + + const extraContent = createExtraContent({ + receipt, + hypercertId, + chain: this.chain, + }); + setExtraContent(extraContent); + + await setDialogStep("done", "completed"); + + // TODO: Clean up these revalidations. + // https://github.com/hypercerts-org/hypercerts-app/pull/484#discussion_r2011898721 + await revalidatePathServerAction([ + "/collections", + "/collections/edit/[collectionId]", + `/profile/${this.address}`, + { path: `/`, type: "layout" }, + ]); + } +} diff --git a/hypercerts/MintHypercertStrategy.ts b/hypercerts/MintHypercertStrategy.ts new file mode 100644 index 00000000..46de6616 --- /dev/null +++ b/hypercerts/MintHypercertStrategy.ts @@ -0,0 +1,30 @@ +import { Address, Chain } from "viem"; +import { + HypercertClient, + HypercertMetadata, + TransferRestrictions, + AllowlistEntry, +} from "@hypercerts-org/sdk"; +import { UseWalletClientReturnType } from "wagmi"; + +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; + +export interface MintHypercertParams { + metaData: HypercertMetadata; + units: bigint; + transferRestrictions: TransferRestrictions; + allowlistRecords?: AllowlistEntry[] | string; + blueprintId?: number; +} + +export abstract class MintHypercertStrategy { + constructor( + protected address: Address, + protected chain: Chain, + protected client: HypercertClient, + protected dialogContext: ReturnType, + protected walletClient: UseWalletClientReturnType, + ) {} + + abstract execute(params: MintHypercertParams): Promise; +} diff --git a/hypercerts/SafeClaimHypercertStrategy.tsx b/hypercerts/SafeClaimHypercertStrategy.tsx new file mode 100644 index 00000000..f5314f9f --- /dev/null +++ b/hypercerts/SafeClaimHypercertStrategy.tsx @@ -0,0 +1,88 @@ +import { Chain } from "viem"; +import { ExternalLink } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { generateSafeAppLink } from "@/lib/utils"; + +import { + ClaimHypercertStrategy, + ClaimHypercertParams, +} from "./ClaimHypercertStrategy"; + +export class SafeClaimHypercertStrategy extends ClaimHypercertStrategy { + async execute({ tokenId, units, proof }: ClaimHypercertParams) { + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + this.dialogContext; + + if (!this.client) { + setOpen(false); + throw new Error("No client found"); + } + + setOpen(true); + setTitle("Claim fraction from Allowlist"); + setSteps([ + { id: "preparing", description: "Preparing to claim fraction..." }, + { id: "submitting", description: "Submitting to Safe..." }, + { id: "queued", description: "Transaction queued in Safe" }, + ]); + + await setDialogStep("preparing", "active"); + + try { + await setDialogStep("submitting", "active"); + await this.client.claimFractionFromAllowlist({ + hypercertTokenId: tokenId, + units, + proof, + overrides: { + safeAddress: this.address as `0x${string}`, + }, + }); + + await setDialogStep("queued", "completed"); + + setExtraContent(() => ( + + )); + } catch (error) { + console.error(error); + await setDialogStep( + "submitting", + "error", + error instanceof Error ? error.message : "Unknown error", + ); + throw error; + } + } +} + +function DialogFooter({ + chain, + safeAddress, +}: { + chain: Chain; + safeAddress: string; +}) { + return ( +
+

Success

+

+ We've submitted the claim request to the connected Safe. +

+
+ {chain && ( + + )} +
+
+ ); +} diff --git a/hypercerts/SafeMintHypercertStrategy.tsx b/hypercerts/SafeMintHypercertStrategy.tsx new file mode 100644 index 00000000..1f13daeb --- /dev/null +++ b/hypercerts/SafeMintHypercertStrategy.tsx @@ -0,0 +1,93 @@ +import { + MintHypercertStrategy, + MintHypercertParams, +} from "./MintHypercertStrategy"; + +import { Button } from "@/components/ui/button"; +import { generateSafeAppLink } from "@/lib/utils"; +import { ExternalLink } from "lucide-react"; +import { Chain } from "viem"; + +export class SafeMintHypercertStrategy extends MintHypercertStrategy { + async execute({ + metaData, + units, + transferRestrictions, + allowlistRecords, + }: MintHypercertParams) { + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + this.dialogContext; + + if (!this.client) { + setOpen(false); + throw new Error("No client found"); + } + + setOpen(true); + setTitle("Minting hypercert"); + setSteps([ + { id: "preparing", description: "Preparing to mint hypercert..." }, + { id: "submitting", description: "Submitting to Safe..." }, + { id: "queued", description: "Transaction queued in Safe" }, + ]); + + await setDialogStep("preparing", "active"); + + try { + await setDialogStep("submitting", "active"); + await this.client.mintHypercert({ + metaData, + totalUnits: units, + transferRestriction: transferRestrictions, + allowList: allowlistRecords, + overrides: { + safeAddress: this.address as `0x${string}`, + }, + }); + + await setDialogStep("queued", "completed"); + + setExtraContent(() => ( + + )); + } catch (error) { + console.error(error); + await setDialogStep( + "submitting", + "error", + error instanceof Error ? error.message : "Unknown error", + ); + throw error; + } + } +} + +function DialogFooter({ + chain, + safeAddress, +}: { + chain: Chain; + safeAddress: string; +}) { + return ( +
+

Success

+

+ We've submitted the transaction requests to the connected Safe. +

+
+ {chain && ( + + )} +
+
+ ); +} diff --git a/hypercerts/hooks/useClaimHypercert.ts b/hypercerts/hooks/useClaimHypercert.ts new file mode 100644 index 00000000..15bef239 --- /dev/null +++ b/hypercerts/hooks/useClaimHypercert.ts @@ -0,0 +1,28 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "@/components/ui/use-toast"; +import { useClaimHypercertStrategy } from "./useClaimHypercertStrategy"; + +interface ClaimHypercertParams { + tokenId: bigint; + units: bigint; + proof: `0x${string}`[]; +} + +export const useClaimHypercert = () => { + const getStrategy = useClaimHypercertStrategy(); + + return useMutation({ + mutationKey: ["CLAIM_HYPERCERT"], + onError: (e: Error) => { + console.error(e); + toast({ + title: "Error", + description: e.message, + duration: 5000, + }); + }, + mutationFn: async (params: ClaimHypercertParams) => { + return getStrategy().execute(params); + }, + }); +}; diff --git a/hypercerts/hooks/useClaimHypercertStrategy.ts b/hypercerts/hooks/useClaimHypercertStrategy.ts new file mode 100644 index 00000000..207c64fd --- /dev/null +++ b/hypercerts/hooks/useClaimHypercertStrategy.ts @@ -0,0 +1,46 @@ +import { isAddress } from "viem"; +import { useAccount, useWalletClient } from "wagmi"; + +import { useAccountStore } from "@/lib/account-store"; +import { useHypercertClient } from "@/hooks/use-hypercert-client"; +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; + +import { ClaimHypercertStrategy } from "../ClaimHypercertStrategy"; +import { EOAClaimHypercertStrategy } from "../EOAClaimHypercertStrategy"; +import { SafeClaimHypercertStrategy } from "../SafeClaimHypercertStrategy"; + +export const useClaimHypercertStrategy = (): (() => ClaimHypercertStrategy) => { + const { address, chain } = useAccount(); + const { client } = useHypercertClient(); + const { selectedAccount } = useAccountStore(); + const dialogContext = useStepProcessDialogContext(); + const walletClient = useWalletClient(); + + return () => { + const activeAddress = + selectedAccount?.address || (address as `0x${string}`); + + if (!activeAddress || !isAddress(activeAddress)) + throw new Error("No address found"); + if (!chain) throw new Error("No chain found"); + if (!client) throw new Error("No HypercertClient found"); + if (!walletClient) throw new Error("No walletClient found"); + if (!dialogContext) throw new Error("No dialogContext found"); + + return selectedAccount?.type === "safe" + ? new SafeClaimHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + ) + : new EOAClaimHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + ); + }; +}; diff --git a/hypercerts/hooks/useCreateAllowLists.ts b/hypercerts/hooks/useCreateAllowLists.ts index ef8d1744..c8e6925b 100644 --- a/hypercerts/hooks/useCreateAllowLists.ts +++ b/hypercerts/hooks/useCreateAllowLists.ts @@ -31,10 +31,11 @@ export const useValidateAllowlist = () => { }), }, ); + const jsonRes = await res.json(); if (!res.ok || !(res.status === 200 || res.status === 201)) { + console.error("Errors: ", jsonRes.errors); throw new Error("Failed to validate allowlist"); } - const jsonRes = await res.json(); return { ...jsonRes, values: allowList, diff --git a/hypercerts/hooks/useMintHypercert.ts b/hypercerts/hooks/useMintHypercert.ts index e56857f8..6b0688fc 100644 --- a/hypercerts/hooks/useMintHypercert.ts +++ b/hypercerts/hooks/useMintHypercert.ts @@ -1,27 +1,15 @@ -import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; -import { toast } from "@/components/ui/use-toast"; -import { useHypercertClient } from "@/hooks/use-hypercert-client"; -import { generateHypercertIdFromReceipt } from "@/lib/generateHypercertIdFromReceipt"; -import { - AllowlistEntry, - HypercertMetadata, - TransferRestrictions, -} from "@hypercerts-org/sdk"; import { useMutation } from "@tanstack/react-query"; -import { waitForTransactionReceipt } from "viem/actions"; -import { useAccount, useWalletClient } from "wagmi"; -import { useQueueMintBlueprint } from "@/blueprints/hooks/queueMintBlueprint"; -import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; -import { track } from "@vercel/analytics"; -import { createExtraContent } from "@/components/global/extra-content"; + +import { toast } from "@/components/ui/use-toast"; +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; + +import { MintHypercertParams } from "../MintHypercertStrategy"; + +import { useMintHypercertStrategy } from "./useMintHypercertStrategy"; export const useMintHypercert = () => { - const { client } = useHypercertClient(); - const { data: walletClient } = useWalletClient(); - const { chain, address } = useAccount(); - const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = - useStepProcessDialogContext(); - const { mutateAsync: queueMintBlueprint } = useQueueMintBlueprint(); + const { setDialogStep } = useStepProcessDialogContext(); + const getStrategy = useMintHypercertStrategy(); return useMutation({ mutationKey: ["MINT_HYPERCERT"], @@ -34,147 +22,9 @@ export const useMintHypercert = () => { duration: 5000, }); }, - onSuccess: async (hash) => { - await setDialogStep("confirming", "active"); - console.log("Mint submitted", { - hash, - }); - track("Mint submitted", { - hash, - }); - let receipt; - - try { - receipt = await waitForTransactionReceipt(walletClient!, { - confirmations: 3, - hash, - }); - console.log({ receipt }); - } catch (error: unknown) { - console.error("Error waiting for transaction receipt:", error); - await setDialogStep( - "confirming", - "error", - error instanceof Error ? error.message : "Unknown error", - ); - throw new Error( - `Failed to confirm transaction: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - - if (receipt?.status === "reverted") { - throw new Error("Transaction reverted: Minting failed"); - } - - await setDialogStep("route", "active"); - - let hypercertId; - try { - hypercertId = generateHypercertIdFromReceipt(receipt, chain?.id!); - console.log("Mint completed", { - hypercertId: hypercertId || "not found", - }); - track("Mint completed", { - hypercertId: hypercertId || "not found", - }); - console.log({ hypercertId }); - } catch (error) { - console.error("Error generating hypercert ID:", error); - await setDialogStep( - "route", - "error", - error instanceof Error ? error.message : "Unknown error", - ); - } - - const extraContent = createExtraContent({ - receipt, - hypercertId, - chain: chain!, - }); - setExtraContent(extraContent); - - await setDialogStep("done", "completed"); - - await revalidatePathServerAction([ - "/collections", - "/collections/edit/[collectionId]", - `/profile/${address}`, - { path: `/`, type: "layout" }, - ]); - return { hypercertId, receipt, chain }; - }, - mutationFn: async ({ - metaData, - units, - transferRestrictions, - allowlistRecords, - blueprintId, - }: { - metaData: HypercertMetadata; - units: bigint; - transferRestrictions: TransferRestrictions; - allowlistRecords?: AllowlistEntry[] | string; - blueprintId?: number; - }) => { - if (!client) { - setOpen(false); - throw new Error("No client found"); - } - - const isBlueprint = !!blueprintId; - setOpen(true); - setSteps([ - { id: "preparing", description: "Preparing to mint hypercert..." }, - { id: "minting", description: "Minting hypercert on-chain..." }, - ...(isBlueprint - ? [{ id: "blueprint", description: "Queueing blueprint mint..." }] - : []), - { id: "confirming", description: "Waiting for on-chain confirmation" }, - { id: "route", description: "Creating your new hypercert's link..." }, - { id: "done", description: "Minting complete!" }, - ]); - setTitle("Minting hypercert"); - await setDialogStep("preparing", "active"); - console.log("preparing..."); - - let hash; - try { - await setDialogStep("minting", "active"); - console.log("minting..."); - hash = await client.mintHypercert({ - metaData, - totalUnits: units, - transferRestriction: transferRestrictions, - allowList: allowlistRecords, - }); - } catch (error: unknown) { - console.error("Error minting hypercert:", error); - throw new Error( - `Failed to mint hypercert: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - - if (!hash) { - throw new Error("No transaction hash returned"); - } - - if (blueprintId) { - try { - await setDialogStep("blueprint", "active"); - await queueMintBlueprint({ - blueprintId, - txHash: hash, - }); - } catch (error: unknown) { - console.error("Error queueing blueprint mint:", error); - throw new Error( - `Failed to queue blueprint mint: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - } - - return hash; + mutationFn: async (params: MintHypercertParams) => { + const strategy = getStrategy(params.blueprintId); + return strategy.execute(params); }, }); }; diff --git a/hypercerts/hooks/useMintHypercertStrategy.ts b/hypercerts/hooks/useMintHypercertStrategy.ts new file mode 100644 index 00000000..776afdbc --- /dev/null +++ b/hypercerts/hooks/useMintHypercertStrategy.ts @@ -0,0 +1,52 @@ +import { isAddress } from "viem"; +import { useAccount, useWalletClient } from "wagmi"; + +import { useAccountStore } from "@/lib/account-store"; +import { useHypercertClient } from "@/hooks/use-hypercert-client"; +import { useQueueMintBlueprint } from "@/blueprints/hooks/queueMintBlueprint"; +import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; + +import { EOAMintHypercertStrategy } from "../EOAMintHypercertStrategy"; +import { MintHypercertStrategy } from "../MintHypercertStrategy"; +import { SafeMintHypercertStrategy } from "../SafeMintHypercertStrategy"; + +export const useMintHypercertStrategy = () => { + const { address, chain } = useAccount(); + const { client } = useHypercertClient(); + const { selectedAccount } = useAccountStore(); + const dialogContext = useStepProcessDialogContext(); + const queueMintBlueprint = useQueueMintBlueprint(); + const walletClient = useWalletClient(); + + return (blueprintId?: number): MintHypercertStrategy => { + const activeAddress = + selectedAccount?.address || (address as `0x${string}`); + + if (!activeAddress || !isAddress(activeAddress)) + throw new Error("No address found"); + if (!chain) throw new Error("No chain found"); + if (!client) throw new Error("No HypercertClient found"); + if (!walletClient) throw new Error("No walletClient found"); + if (!dialogContext) throw new Error("No dialogContext found"); + + // If there's a blueprintId in the search params, we can't use the Safe strategy. + if (selectedAccount?.type === "safe" && !blueprintId) { + return new SafeMintHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + ); + } + + return new EOAMintHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + queueMintBlueprint, + walletClient, + ); + }; +}; diff --git a/marketplace/SafeBuyFractionalStrategy.tsx b/marketplace/SafeBuyFractionalStrategy.tsx index ac20e18e..887e2665 100644 --- a/marketplace/SafeBuyFractionalStrategy.tsx +++ b/marketplace/SafeBuyFractionalStrategy.tsx @@ -1,27 +1,23 @@ import { Currency, Taker } from "@hypercerts-org/marketplace-sdk"; import { zeroAddress } from "viem"; -import { SUPPORTED_CHAINS } from "@/configs/constants"; import { decodeContractError } from "@/lib/decodeContractError"; - import { ExtraContent } from "@/components/global/extra-content"; +import { SUPPORTED_CHAINS } from "@/configs/constants"; + import { BuyFractionalStrategy } from "./BuyFractionalStrategy"; -import { MarketplaceOrder } from "./types"; import { getCurrencyByAddress } from "./utils"; +import { MarketplaceOrder } from "./types"; export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { async execute({ order, unitAmount, pricePerUnit, - hypercertName, - totalUnitsInHypercert, }: { order: MarketplaceOrder; unitAmount: bigint; pricePerUnit: string; - hypercertName?: string | null; - totalUnitsInHypercert?: bigint; }) { const { setDialogStep: setStep, @@ -30,21 +26,20 @@ export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { setExtraContent, } = this.dialogContext; if (!this.exchangeClient) { - this.dialogContext.setOpen(false); + setOpen(false); throw new Error("No client"); } if (!this.chainId) { - this.dialogContext.setOpen(false); + setOpen(false); throw new Error("No chain id"); } if (!this.walletClient.data) { - this.dialogContext.setOpen(false); + setOpen(false); throw new Error("No wallet client data"); } - // TODO: we might have to change some steps here setSteps([ { id: "Setting up order execution", @@ -111,8 +106,6 @@ export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { order.currency as `0x${string}`, ); - // TODO: if this is not approved yet, we need to create a Safe TX and drop out of this - // dialog early, so that the next invocation runs through this check without stopping. if (currentAllowance < totalPrice) { console.debug("Approving ERC20"); await this.exchangeClient.approveErc20Safe( @@ -132,12 +125,11 @@ export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { throw new Error("Approval error"); } - // TODO: this whole step should probably not be here try { await setStep("Transfer manager"); const isTransferManagerApproved = await this.exchangeClient.isTransferManagerApprovedSafe(this.address); - // FIXME: this shouldn't be here, unless we're missing something + if (!isTransferManagerApproved) { console.debug("Approving transfer manager"); await this.exchangeClient.grantTransferManagerApprovalSafe( diff --git a/marketplace/useBuyFractionalStrategy.ts b/marketplace/useBuyFractionalStrategy.ts index b1e10b0a..711122aa 100644 --- a/marketplace/useBuyFractionalStrategy.ts +++ b/marketplace/useBuyFractionalStrategy.ts @@ -8,7 +8,7 @@ import { useStepProcessDialogContext } from "@/components/global/step-process-di import { BuyFractionalStrategy } from "./BuyFractionalStrategy"; import { EOABuyFractionalStrategy } from "./EOABuyFractionalStrategy"; import { SafeBuyFractionalStrategy } from "./SafeBuyFractionalStrategy"; -import { Address, getAddress, isAddress } from "viem"; +import { getAddress, isAddress } from "viem"; export const useBuyFractionalStrategy = (): (() => BuyFractionalStrategy) => { const { address, chainId } = useAccount(); diff --git a/package.json b/package.json index ba54a68e..beb7b85a 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@ethersproject/bignumber": "^5.7.0", "@hookform/resolvers": "^3.3.4", "@hypercerts-org/contracts": "2.0.0-alpha.12", - "@hypercerts-org/marketplace-sdk": " 0.5.0-alpha.0", - "@hypercerts-org/sdk": "2.5.0-beta.6", + "@hypercerts-org/marketplace-sdk": "0.5.0-alpha.0", + "@hypercerts-org/sdk": "2.6.0", "@next/env": "^14.2.10", "@openzeppelin/merkle-tree": "^1.0.6", "@radix-ui/react-accordion": "^1.2.3", @@ -46,6 +46,7 @@ "@tanstack/react-query-devtools": "4", "@tanstack/react-table": "^8.17.3", "@types/lodash": "^4.17.15", + "@types/papaparse": "^5.3.15", "@types/validator": "^13.12.2", "@uiw/react-md-editor": "^4.0.5", "@vercel/analytics": "^1.5.0", @@ -64,6 +65,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.373.0", "next": "^14.2.18", + "papaparse": "^5.5.2", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c201ec2..04d7fb11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: 2.0.0-alpha.12 version: 2.0.0-alpha.12(bufferutil@4.0.9)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10) '@hypercerts-org/marketplace-sdk': - specifier: ' 0.5.0-alpha.0' + specifier: 0.5.0-alpha.0 version: 0.5.0-alpha.0(@safe-global/api-kit@2.5.9(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@safe-global/protocol-kit@5.2.2(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@swc/helpers@0.5.5)(bufferutil@4.0.9)(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10))(graphql@16.10.0)(rollup@4.31.0)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1) '@hypercerts-org/sdk': - specifier: 2.5.0-beta.6 - version: 2.5.0-beta.6(@swc/helpers@0.5.5)(bufferutil@4.0.9)(graphql@16.10.0)(rollup@4.31.0)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10) + specifier: 2.6.0 + version: 2.6.0(@safe-global/api-kit@2.5.9(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@safe-global/protocol-kit@5.2.2(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@safe-global/types-kit@1.0.2(typescript@5.7.3)(zod@3.24.1))(@swc/helpers@0.5.5)(bufferutil@4.0.9)(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10))(graphql@16.10.0)(rollup@4.31.0)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10) '@next/env': specifier: ^14.2.10 version: 14.2.23 @@ -107,6 +107,9 @@ importers: '@types/lodash': specifier: ^4.17.15 version: 4.17.15 + '@types/papaparse': + specifier: ^5.3.15 + version: 5.3.15 '@types/validator': specifier: ^13.12.2 version: 13.12.2 @@ -161,6 +164,9 @@ importers: next: specifier: ^14.2.18 version: 14.2.23(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + papaparse: + specifier: ^5.5.2 + version: 5.5.2 react: specifier: ^18.3.1 version: 18.3.1 @@ -829,8 +835,13 @@ packages: '@hypercerts-org/sdk@2.4.0': resolution: {integrity: sha512-9vxQW3zBwi3WCOUBMwU1fWEk3z29eyxtDWlaIS7jdUlGwnCcN9IkPzIk7w/jHO96yiH8+vcL/EWFvOp06mtXAw==} - '@hypercerts-org/sdk@2.5.0-beta.6': - resolution: {integrity: sha512-v24hjmCwkL2/lkbQbYxzepLAJOc2SwfHVBoADNcdcT+/s7Fvpq5I+MddlWHYDcBLacPhyF3k+F9O/tkwvofY1g==} + '@hypercerts-org/sdk@2.6.0': + resolution: {integrity: sha512-uq+9WzgW+GWazEKTUEhUPZr8sTxhORaNI6DfRfDTZ8w0FJtPEXSSLt5mr9x5VN8EghM1NsPvKzf38jkO8wBkZg==} + peerDependencies: + '@safe-global/api-kit': ^2.5.7 + '@safe-global/protocol-kit': ^5.2.0 + '@safe-global/types-kit': ^1.0.4 + ethers: ^6.6.2 '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} @@ -2257,6 +2268,9 @@ packages: '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/papaparse@5.3.15': + resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==} + '@types/pbkdf2@3.1.2': resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} @@ -4971,6 +4985,9 @@ packages: pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.2: + resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7276,17 +7293,21 @@ snapshots: - typescript - utf-8-validate - '@hypercerts-org/sdk@2.5.0-beta.6(@swc/helpers@0.5.5)(bufferutil@4.0.9)(graphql@16.10.0)(rollup@4.31.0)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10)': + '@hypercerts-org/sdk@2.6.0(@safe-global/api-kit@2.5.9(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@safe-global/protocol-kit@5.2.2(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1))(@safe-global/types-kit@1.0.2(typescript@5.7.3)(zod@3.24.1))(@swc/helpers@0.5.5)(bufferutil@4.0.9)(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10))(graphql@16.10.0)(rollup@4.31.0)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.10.0) '@hypercerts-org/contracts': 2.0.0-alpha.12(bufferutil@4.0.9)(ts-node@10.9.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(@types/node@20.17.14)(typescript@5.7.3))(typescript@5.7.3)(utf-8-validate@5.0.10) '@openzeppelin/merkle-tree': 1.0.7 + '@safe-global/api-kit': 2.5.9(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1) + '@safe-global/protocol-kit': 5.2.2(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1) + '@safe-global/types-kit': 1.0.2(typescript@5.7.3)(zod@3.24.1) '@swc/core': 1.10.9(@swc/helpers@0.5.5) ajv: 8.17.1 axios: 1.7.9 dotenv: 16.4.7 + ethers: 6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) rollup-plugin-swc3: 0.11.2(@swc/core@1.10.9(@swc/helpers@0.5.5))(rollup@4.31.0) - viem: 2.23.0(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1) zod: 3.24.1 transitivePeerDependencies: - '@swc/helpers' @@ -8826,6 +8847,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/papaparse@5.3.15': + dependencies: + '@types/node': 20.17.14 + '@types/pbkdf2@3.1.2': dependencies: '@types/node': 20.17.14 @@ -12544,6 +12569,8 @@ snapshots: pako@2.1.0: {} + papaparse@5.5.2: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/test/lib/parseAllowList.test.ts b/test/lib/parseAllowList.test.ts new file mode 100644 index 00000000..2a6f40b8 --- /dev/null +++ b/test/lib/parseAllowList.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { parseAllowList } from "@/components/hypercert/hypercert-minting-form/form-steps"; +import { zeroAddress } from "viem"; +import { DEFAULT_NUM_UNITS } from "@/configs/hypercerts"; +import fs from "fs"; +import path from "path"; + +describe("parseAllowList", () => { + it("should read allowlist.csv file and return the parsed data", async () => { + const defaultAllowList = path.resolve( + __dirname, + "../../public/allowlist.csv", + ); + const allowlistCsvContent = fs.readFileSync(defaultAllowList, "utf-8"); + const parsedData = await parseAllowList(allowlistCsvContent); + + // expect units as scaled values + expect(parsedData[0].address).toBe(zeroAddress); + expect(parsedData[0].units).toBe(BigInt(50000000)); + expect(parsedData[1].address).toBe(zeroAddress); + expect(parsedData[1].units).toBe(BigInt(50000000)); + const totalUnits = parsedData[0].units + parsedData[1].units; + expect(totalUnits).toBe(DEFAULT_NUM_UNITS); + }); + + // Test for CSV with comma delimiter + it("should correctly parse CSV with comma delimiter", async () => { + const csvContent = `address,units +0x1111111111111111111111111111111111111111,30 +0x2222222222222222222222222222222222222222,70`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData.length).toBe(2); + expect(parsedData[0].address).toBe( + "0x1111111111111111111111111111111111111111", + ); + expect(parsedData[1].address).toBe( + "0x2222222222222222222222222222222222222222", + ); + + // Check scaling (30:70 ratio) + const totalUnits = parsedData[0].units + parsedData[1].units; + expect(totalUnits).toBe(DEFAULT_NUM_UNITS); + }); + + // Test for CSV with semicolon delimiter + it("should correctly parse CSV with semicolon delimiter", async () => { + const csvContent = `address;units +0x3333333333333333333333333333333333333333;25 +0x4444444444444444444444444444444444444444;75`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData.length).toBe(2); + expect(parsedData[0].address).toBe( + "0x3333333333333333333333333333333333333333", + ); + expect(parsedData[1].address).toBe( + "0x4444444444444444444444444444444444444444", + ); + + // Check scaling (25:75 ratio) + const totalUnits = parsedData[0].units + parsedData[1].units; + expect(totalUnits).toBe(DEFAULT_NUM_UNITS); + }); + + // Test for CSV with mixed values that don't scale evenly + it("should handle rounding errors correctly when scaling units", async () => { + const csvContent = `address,units +0x5555555555555555555555555555555555555555,3333 +0x6666666666666666666666666666666666666666,3333 +0x7777777777777777777777777777777777777777,3334`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData.length).toBe(3); + + // Total should be exactly DEFAULT_NUM_UNITS + const totalUnits = parsedData.reduce( + (sum, entry) => sum + entry.units, + BigInt(0), + ); + expect(totalUnits).toBe(DEFAULT_NUM_UNITS); + }); + + // Test for edge case where rounding would result in 99.999999% allocation + it("should correct rounding errors to ensure exactly 100% allocation", async () => { + const csvContent = `address,units +0x8888888888888888888888888888888888888888,33330 +0x9999999999999999999999999999999999999999,33330 +0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,33340`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData.length).toBe(3); + + // Sum of units should be EXACTLY DEFAULT_NUM_UNITS (not 99.99999%) + const totalUnits = parsedData.reduce( + (sum, entry) => sum + entry.units, + BigInt(0), + ); + expect(totalUnits).toBe(DEFAULT_NUM_UNITS); + }); + + it("should throw error for an invalid Ethereum addresses", async () => { + const csvContent = `address,units + 0x123Invalid,50`; + await expect(parseAllowList(csvContent)).rejects.toThrowError(); + }); + + it("should return valid Ethereum Address with viem", async () => { + const csvContent = `address,units +0x627d54b88b519a2915b6a5a76fa9530fd085ce26,100`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData[0].address).toBe( + "0x627D54B88b519A2915B6A5A76fA9530FD085cE26", + ); + }); + + it("should ignore empty lines in the CSV", async () => { + const csvContent = `address,units +0x, +0x, +0x1234567890123456789012345678901234567890,50 +0x1234567890123456789012345678901234567890,50`; + + const parsedData = await parseAllowList(csvContent); + + expect(parsedData.length).toBe(2); + expect(parsedData[0].address).toBe( + "0x1234567890123456789012345678901234567890", + ); + expect(parsedData[1].address).toBe( + "0x1234567890123456789012345678901234567890", + ); + }); +});