From c1308610979a76523be239a61f50d1ba1bad0edc Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Sun, 23 Feb 2025 17:16:21 +0900 Subject: [PATCH 01/23] feat: add reset button for allowlist entries - Add a 'Delete' button for the created/uploaded allowlist - When the allow list is provided via manual creation, disable the URL input --- .../hypercert-minting-form/form-steps.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index c905e5bb..4a8eff56 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -559,8 +559,8 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { @@ -578,6 +578,18 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { {allowlistEntries ? "Edit allowlist" : "Create allowlist"} + + Date: Sun, 23 Feb 2025 23:09:59 +0900 Subject: [PATCH 02/23] feat: edit allowlist via url When an URL was input, allow for editing the allowlist in the creation modal. onChange will handle the validation for provided link, and if validation is succeeded, call form.setValue("allowlistEntries", ...) to update allowlist data --- .../hypercert-minting-form/form-steps.tsx | 125 ++++++++++++++++-- hypercerts/hooks/useCreateAllowLists.ts | 3 +- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index 4a8eff56..0ff30cd7 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -17,10 +17,11 @@ import { ArrowRightIcon, CalendarIcon, Loader2, + LoaderCircle, Trash2Icon, X, } from "lucide-react"; -import { RefObject, useMemo, useState } from "react"; +import { RefObject, useEffect, useMemo, useState } from "react"; import rehypeSanitize from "rehype-sanitize"; import CreateAllowlistDialog from "@/components/allowlist/create-allowlist-dialog"; @@ -74,7 +75,11 @@ import Link from "next/link"; import { UseFormReturn } from "react-hook-form"; import { useAccount, useChainId } from "wagmi"; import { ImageUploader, readAsBase64 } from "@/components/image-uploader"; - +import { useValidateAllowlist } from "@/hypercerts/hooks/useCreateAllowLists"; +import Papa from "papaparse"; +import { getAddress, parseUnits } from "viem"; +import { errorHasMessage } from "@/lib/errorHasMessage"; +import { errorToast } from "@/lib/errorToast"; // import Image from "next/image"; interface FormStepsProps { @@ -433,6 +438,12 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { setOpen, setTitle, } = useStepProcessDialogContext(); + const { + mutate: validateAllowlist, + data: validateAllowlistResponse, + isPending: isPendingValidateAllowlist, + error: createAllowListError, + } = useValidateAllowlist(); const [isUploading, setIsUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); @@ -442,13 +453,27 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { }; const allowlistEntries = form.watch("allowlistEntries"); - const errorToast = (message: string | undefined) => { - toast({ - title: message, - variant: "destructive", - duration: 2000, - }); - }; + useEffect(() => { + if (createAllowListError) { + toast({ + title: "Error", + description: createAllowListError.message, + variant: "destructive", + }); + } + }, [createAllowListError]); + + useEffect(() => { + if (validateAllowlistResponse?.success) { + const bigintUnits = validateAllowlistResponse.values.map( + (entry: AllowlistEntry) => ({ + ...entry, + units: BigInt(entry.units), + }), + ); + form.setValue("allowlistEntries", bigintUnits); + } + }, [validateAllowlistResponse]); async function validateFile(file: File) { if (file.size > MAX_FILE_SIZE) { @@ -560,6 +585,70 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { { + const value = e.target.value; + + let data; + let allowList; + const url = value.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` + : value.startsWith("https://") + ? value + : null; + + if (!url) return errorToast("Invalid Url type"); + data = await fetch(url); + + const contentType = data.headers.get("content-type"); + + if ( + contentType?.includes("text/csv") || + contentType?.includes("text/plain") || + value.endsWith(".csv") + ) { + const text = await data.text(); + const parsedData = Papa.parse(text, { + header: true, + skipEmptyLines: true, + }); + // check if address isAddress, and units is a bigint type + // if not, throw error + allowList = parsedData.data + .filter((entry) => entry.address && entry.units) + .map((entry) => { + const address = getAddress(entry.address); + return { + address: address, + units: entry.units, + }; + }); + } else { + return errorToast("Invalid file type."); + } + + const totalUnits = DEFAULT_NUM_UNITS; + + // validateAllowlist + try { + validateAllowlist({ + allowList, + totalUnits, + }); + } catch (e) { + if (errorHasMessage(e)) { + toast({ + title: "Error", + description: e.message, + variant: "destructive", + }); + } else { + toast({ + title: "Error", + description: "Failed to upload allow list", + }); + } + } + }} disabled={!!allowlistEntries} /> @@ -571,16 +660,28 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => {
{ - const value = e.target.value; - - let data; - let allowList; - const url = value.startsWith("ipfs://") - ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` - : value.startsWith("https://") - ? value - : null; - - if (!url) return errorToast("Invalid Url type"); - data = await fetch(url); - - const contentType = data.headers.get("content-type"); - - if ( - contentType?.includes("text/csv") || - contentType?.includes("text/plain") || - value.endsWith(".csv") - ) { - const text = await data.text(); - const parsedData = Papa.parse(text, { - header: true, - skipEmptyLines: true, - }); - // check if address isAddress, and units is a bigint type - // if not, throw error - allowList = parsedData.data - .filter((entry) => entry.address && entry.units) - .map((entry) => { - const address = getAddress(entry.address); - return { - address: address, - units: entry.units, - }; - }); - } else { - return errorToast("Invalid file type."); - } - - const totalUnits = DEFAULT_NUM_UNITS; - - // validateAllowlist - try { - validateAllowlist({ - allowList, - totalUnits, - }); - } catch (e) { - if (errorHasMessage(e)) { - toast({ - title: "Error", - description: e.message, - variant: "destructive", - }); - } else { - toast({ - title: "Error", - description: "Failed to upload allow list", - }); - } - } - }} disabled={!!allowlistEntries} /> From e9282400f796e1eb3887a9fecc7348045b035613 Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Mon, 17 Mar 2025 22:00:35 +0900 Subject: [PATCH 05/23] feat: scale units Improved the allowlist entry validation and scaling logic by calculating total units and proportionally scaling each entry's units based on the total. --- .../hypercert-minting-form/form-steps.tsx | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index 0f39c652..34e563b6 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -448,9 +448,13 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { const [selectedFile, setSelectedFile] = useState(null); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const setAllowlistURL = (allowlistURL: string) => { + form.setValue("allowlistURL", allowlistURL); + }; const setAllowlistEntries = (allowlistEntries: AllowlistEntry[]) => { form.setValue("allowlistEntries", allowlistEntries); }; + const allowlistEntries = form.watch("allowlistEntries"); useEffect(() => { @@ -554,19 +558,31 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { header: true, skipEmptyLines: true, }); - // check if address isAddress, and units is a bigint type - // if not, throw error - allowList = parsedData.data - .filter((entry) => entry.address && entry.units) - .map((entry) => { - const address = getAddress(entry.address); - return { - address: address, - units: BigInt(entry.units), - }; - }); + + const validEntries = parsedData.data.filter( + (entry) => entry.address && entry.units, + ); + + // Calculate total units + const total = validEntries.reduce( + (sum, entry) => sum + BigInt(entry.units), + BigInt(0), + ); + + allowList = validEntries.map((entry) => { + const address = getAddress(entry.address); + const originalUnits = BigInt(entry.units); + // Scale units proportionally to DEFAULT_NUM_UNITS + const scaledUnits = + total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0); + + return { + address: address, + units: scaledUnits, + }; + }); } else { - return errorToast("Invalid file type."); + return errorToast("Invalid allowlist."); } const totalUnits = DEFAULT_NUM_UNITS; @@ -706,6 +722,8 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { ({ From 5e73039905a8f64a252700909d4e51dc083ba25c Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Mon, 17 Mar 2025 22:03:10 +0900 Subject: [PATCH 06/23] feat: improve allowlist creation dialog change intended to allow user to edit allowlist on dialog if user uploaded via url. if user updated allowlist, then delete original url to avoid confliction in a minting process --- .../allowlist/create-allowlist-dialog.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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. +

+ )}
+
+ Allowlist (optional) +
{ allowlist.
+ @@ -717,7 +715,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { }} > - Reset + Delete Date: Thu, 20 Mar 2025 15:19:20 +0900 Subject: [PATCH 09/23] feat: refactor allowlist fetching logic Extracted the allowlist fetching and parsing logic into a separate function --- .../hypercert-minting-form/form-steps.tsx | 114 ++++++++++-------- 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index 0c9846ca..0292537d 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -423,6 +423,69 @@ const DatesAndPeople = ({ form }: FormStepsProps) => { ); }; +const parseAllowlist = async (value: string) => { + let data; + let allowList: AllowlistEntry[] = []; + const url = value.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` + : value.startsWith("https://") + ? value + : null; + + if (!url) { + errorToast("Invalid URL. URL must start with 'https://' or 'ipfs://'"); + return; + } + data = await fetch(url); + + const contentType = data.headers.get("content-type"); + + if ( + contentType?.includes("text/csv") || + contentType?.includes("text/plain") || + value.endsWith(".csv") + ) { + try { + const text = await data.text(); + const parsedData = Papa.parse(text, { + header: true, + skipEmptyLines: true, + }); + + const validEntries = parsedData.data.filter( + (entry) => entry.address && entry.units, + ); + + // Calculate total units + const total = validEntries.reduce( + (sum, entry) => sum + BigInt(entry.units), + BigInt(0), + ); + + allowList = validEntries.map((entry) => { + const address = getAddress(entry.address); + const originalUnits = BigInt(entry.units); + // Scale units proportionally to DEFAULT_NUM_UNITS + const scaledUnits = + total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0); + + return { + address: address, + units: scaledUnits, + }; + }); + + return allowList; + } catch (e) { + e instanceof Error + ? errorToast(e.message) + : errorToast("Failed to parse allowlist."); + } + } else { + errorToast("Invalid file type."); + } +}; + const calculatePercentageBigInt = ( units: bigint, total: bigint = DEFAULT_NUM_UNITS, @@ -535,55 +598,8 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { }; const fetchAllowlist = async (value: string) => { - let data; - let allowList; - const url = value.startsWith("ipfs://") - ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` - : value.startsWith("https://") - ? value - : null; - - if (!url) return errorToast("Invalid URL"); - data = await fetch(url); - - const contentType = data.headers.get("content-type"); - - if ( - contentType?.includes("text/csv") || - contentType?.includes("text/plain") || - value.endsWith(".csv") - ) { - const text = await data.text(); - const parsedData = Papa.parse(text, { - header: true, - skipEmptyLines: true, - }); - - const validEntries = parsedData.data.filter( - (entry) => entry.address && entry.units, - ); - - // Calculate total units - const total = validEntries.reduce( - (sum, entry) => sum + BigInt(entry.units), - BigInt(0), - ); - - allowList = validEntries.map((entry) => { - const address = getAddress(entry.address); - const originalUnits = BigInt(entry.units); - // Scale units proportionally to DEFAULT_NUM_UNITS - const scaledUnits = - total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0); - - return { - address: address, - units: scaledUnits, - }; - }); - } else { - return errorToast("Invalid allowlist."); - } + const allowList = await parseAllowlist(value); + if (!allowList || allowList.length === 0) return; const totalUnits = DEFAULT_NUM_UNITS; From 3234cec414aef59dc6150a78786307a83bd16ef2 Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Thu, 20 Mar 2025 15:20:39 +0900 Subject: [PATCH 10/23] chore: typo --- components/hypercert/hypercert-minting-form/form-steps.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index 0292537d..26861a2f 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -423,7 +423,7 @@ const DatesAndPeople = ({ form }: FormStepsProps) => { ); }; -const parseAllowlist = async (value: string) => { +const parseAllowList = async (value: string) => { let data; let allowList: AllowlistEntry[] = []; const url = value.startsWith("ipfs://") @@ -598,7 +598,7 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { }; const fetchAllowlist = async (value: string) => { - const allowList = await parseAllowlist(value); + const allowList = await parseAllowList(value); if (!allowList || allowList.length === 0) return; const totalUnits = DEFAULT_NUM_UNITS; From 224becc781bdbf6842ea1d62964aff2b0bff7a09 Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Thu, 20 Mar 2025 17:19:25 +0900 Subject: [PATCH 11/23] refactor: allowlist parsing and error handling changes: - split parseAllowList and forocused it to parse only. - Add to throw errors. --- .../hypercert-minting-form/form-steps.tsx | 157 +++++++++--------- 1 file changed, 82 insertions(+), 75 deletions(-) diff --git a/components/hypercert/hypercert-minting-form/form-steps.tsx b/components/hypercert/hypercert-minting-form/form-steps.tsx index 26861a2f..86d10905 100644 --- a/components/hypercert/hypercert-minting-form/form-steps.tsx +++ b/components/hypercert/hypercert-minting-form/form-steps.tsx @@ -423,66 +423,47 @@ const DatesAndPeople = ({ form }: FormStepsProps) => { ); }; -const parseAllowList = async (value: string) => { - let data; - let allowList: AllowlistEntry[] = []; - const url = value.startsWith("ipfs://") - ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` - : value.startsWith("https://") - ? value - : null; +export const parseAllowList = async (data: Response | string) => { + let allowList: AllowlistEntry[]; + try { + const text = typeof data === "string" ? data : await data.text(); + const parsedData = Papa.parse(text, { + header: true, + skipEmptyLines: true, + }); - if (!url) { - errorToast("Invalid URL. URL must start with 'https://' or 'ipfs://'"); - return; - } - data = await fetch(url); - - const contentType = data.headers.get("content-type"); - - if ( - contentType?.includes("text/csv") || - contentType?.includes("text/plain") || - value.endsWith(".csv") - ) { - try { - const text = await data.text(); - const parsedData = Papa.parse(text, { - header: true, - skipEmptyLines: true, - }); - - const validEntries = parsedData.data.filter( - (entry) => entry.address && entry.units, - ); + const validEntries = parsedData.data.filter( + (entry) => entry.address && entry.units, + ); - // Calculate total units - const total = validEntries.reduce( - (sum, entry) => sum + BigInt(entry.units), - BigInt(0), - ); + // Calculate total units + const total = validEntries.reduce( + (sum, entry) => sum + BigInt(entry.units), + BigInt(0), + ); - allowList = validEntries.map((entry) => { - const address = getAddress(entry.address); - const originalUnits = BigInt(entry.units); - // Scale units proportionally to DEFAULT_NUM_UNITS - const scaledUnits = - total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0); + allowList = validEntries.map((entry) => { + const address = getAddress(entry.address); + const originalUnits = BigInt(entry.units); + // Scale units proportionally to DEFAULT_NUM_UNITS + const scaledUnits = + total > 0 ? (originalUnits * DEFAULT_NUM_UNITS) / total : BigInt(0); - return { - address: address, - units: scaledUnits, - }; - }); + return { + address: address, + units: scaledUnits, + }; + }); - return allowList; - } catch (e) { - e instanceof Error - ? errorToast(e.message) - : errorToast("Failed to parse allowlist."); + return allowList; + } catch (e) { + if (errorHasMessage(e)) { + errorToast(e.message); + throw new Error(e.message); + } else { + errorToast("Failed to parse allowlist."); + throw new Error("Failed to parse allowlist."); } - } else { - errorToast("Invalid file type."); } }; @@ -598,31 +579,57 @@ const AdvancedAndSubmit = ({ form, isBlueprint }: FormStepsProps) => { }; const fetchAllowlist = async (value: string) => { - const allowList = await parseAllowList(value); - if (!allowList || allowList.length === 0) return; + let data: Response; + const url = value.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${value.replace("ipfs://", "")}` + : value.startsWith("https://") + ? value + : null; - const totalUnits = DEFAULT_NUM_UNITS; + if (!url) { + errorToast("Invalid URL. URL must start with 'https://' or 'ipfs://'"); + throw new Error( + "Invalid URL. URL must start with 'https://' or 'ipfs://'", + ); + } + data = await fetch(url); - // validateAllowlist - try { - validateAllowlist({ - allowList, - totalUnits, - }); - form.setValue("allowlistEntries", allowList); - } catch (e) { - if (errorHasMessage(e)) { - toast({ - title: "Error", - description: e.message, - variant: "destructive", - }); - } else { - toast({ - title: "Error", - description: "Failed to upload allow list", + const contentType = data.headers.get("content-type"); + + if ( + contentType?.includes("text/csv") || + contentType?.includes("text/plain") || + value.endsWith(".csv") + ) { + const allowList = await parseAllowList(data); + if (!allowList || allowList.length === 0) return; + + const totalUnits = DEFAULT_NUM_UNITS; + + // validateAllowlist + try { + validateAllowlist({ + allowList, + totalUnits, }); + form.setValue("allowlistEntries", allowList); + } catch (e) { + if (errorHasMessage(e)) { + toast({ + title: "Error", + description: e.message, + variant: "destructive", + }); + } else { + toast({ + title: "Error", + description: "Failed to upload allow list", + }); + } } + } else { + errorToast("Invalid file type."); + throw new Error("Invalid file type."); } }; From a72973f915f06dad5bd413f31fde890c74ac0421 Mon Sep 17 00:00:00 2001 From: shuhei tanaka Date: Thu, 20 Mar 2025 17:20:02 +0900 Subject: [PATCH 12/23] test: add unit tests for parseAllowList function - Implemented tests to validate the parsing of allowlist CSV files with various delimiters (comma, semicolon). - Added checks for correct handling of rounding errors and invalid Ethereum addresses. - Ensured that empty lines in the CSV are ignored and that the total units match the expected DEFAULT_NUM_UNITS. --- test/lib/parseAllowList.test.ts | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/lib/parseAllowList.test.ts 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", + ); + }); +}); From 847ea9cb93784beb4284dca9b58b56f6de37fc18 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 7 Mar 2025 19:31:20 +0200 Subject: [PATCH 13/23] chore: remove unused import --- marketplace/useBuyFractionalStrategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 3fdfce37c1847085b0f4f3672c314e446e86b717 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 7 Mar 2025 19:35:23 +0200 Subject: [PATCH 14/23] style: reorder imports --- marketplace/SafeBuyFractionalStrategy.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/marketplace/SafeBuyFractionalStrategy.tsx b/marketplace/SafeBuyFractionalStrategy.tsx index ac20e18e..38b68110 100644 --- a/marketplace/SafeBuyFractionalStrategy.tsx +++ b/marketplace/SafeBuyFractionalStrategy.tsx @@ -1,13 +1,13 @@ 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({ From fb9457d20b2c1f06020945a40e8b747e2f2136a1 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 7 Mar 2025 19:35:56 +0200 Subject: [PATCH 15/23] chore: remove unused parameters --- marketplace/SafeBuyFractionalStrategy.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/marketplace/SafeBuyFractionalStrategy.tsx b/marketplace/SafeBuyFractionalStrategy.tsx index 38b68110..87a87548 100644 --- a/marketplace/SafeBuyFractionalStrategy.tsx +++ b/marketplace/SafeBuyFractionalStrategy.tsx @@ -14,14 +14,10 @@ export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { order, unitAmount, pricePerUnit, - hypercertName, - totalUnitsInHypercert, }: { order: MarketplaceOrder; unitAmount: bigint; pricePerUnit: string; - hypercertName?: string | null; - totalUnitsInHypercert?: bigint; }) { const { setDialogStep: setStep, From e4be4039d1a408422a34954399fdef0f3e004441 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 7 Mar 2025 19:37:26 +0200 Subject: [PATCH 16/23] chore: remove WIP TODOs The TODOs were actually part of a WIP commit, but were not thrown out as intended after finishing the implementation. --- marketplace/SafeBuyFractionalStrategy.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/marketplace/SafeBuyFractionalStrategy.tsx b/marketplace/SafeBuyFractionalStrategy.tsx index 87a87548..c419e9ac 100644 --- a/marketplace/SafeBuyFractionalStrategy.tsx +++ b/marketplace/SafeBuyFractionalStrategy.tsx @@ -40,7 +40,6 @@ export class SafeBuyFractionalStrategy extends BuyFractionalStrategy { throw new Error("No wallet client data"); } - // TODO: we might have to change some steps here setSteps([ { id: "Setting up order execution", @@ -107,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( @@ -128,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( From 3a0cd5943a43703adca8d8410e7f7fee3b38fb20 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 7 Mar 2025 19:45:17 +0200 Subject: [PATCH 17/23] refactor: use destructured function reference Since it's already available in the scope, why not use it? --- marketplace/SafeBuyFractionalStrategy.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/marketplace/SafeBuyFractionalStrategy.tsx b/marketplace/SafeBuyFractionalStrategy.tsx index c419e9ac..887e2665 100644 --- a/marketplace/SafeBuyFractionalStrategy.tsx +++ b/marketplace/SafeBuyFractionalStrategy.tsx @@ -26,17 +26,17 @@ 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"); } From 90301fc9c368320c80bb2def291b508834d8716e Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 11 Mar 2025 17:01:37 +0200 Subject: [PATCH 18/23] chore: add TODO to extra-content.tsx This feels like a reusable component but it's not. Let's improve it! --- components/global/extra-content.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/global/extra-content.tsx b/components/global/extra-content.tsx index 0a821e5a..cf468dac 100644 --- a/components/global/extra-content.tsx +++ b/components/global/extra-content.tsx @@ -13,6 +13,8 @@ interface ExtraContentProps { receipt?: TransactionReceipt; } +// TODO: not really reusable for safe. breaks when minting hypercert from safe. +// We should make this reusable for all strategies. export function ExtraContent({ message = "Your hypercert has been minted successfully!", hypercertId, From 83bc3c3444670916f3a4dacd68ed8e5e7500f7ab Mon Sep 17 00:00:00 2001 From: pheuberger Date: Sat, 15 Mar 2025 22:13:35 +0200 Subject: [PATCH 19/23] feat(profile): show warning banner contract account When viewing the profile of a smart contract address, there is a non-zero chance that the ownership across different networks varies. This could be exploitet, so it's best to prompt the users to do their own research. --- app/profile/[address]/page.tsx | 2 + .../profile/contract-accounts-banner.tsx | 18 +++++++ hooks/useIsContract.ts | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 components/profile/contract-accounts-banner.tsx create mode 100644 hooks/useIsContract.ts 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/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/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, + }; +} From 830dd85384ff515cf04ea30058e25ae1ce3ab5ae Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 11 Mar 2025 17:05:24 +0200 Subject: [PATCH 20/23] feat: add Safe support to minting form Without this patch the user can only mint a Hypercert with their connected browser wallet. By passing the Safe address into the SDK function the Safe flow is invoked. The minting dialog now shows fewer steps and proposes a Safe transaction on successful signature. --- hypercerts/EOAMintHypercertStrategy.ts | 169 ++++++++++++++++++ hypercerts/MintHypercertStrategy.ts | 30 ++++ hypercerts/SafeMintHypercertStrategy.tsx | 93 ++++++++++ hypercerts/hooks/useMintHypercert.ts | 174 ++----------------- hypercerts/hooks/useMintHypercertStrategy.ts | 52 ++++++ package.json | 2 +- pnpm-lock.yaml | 21 ++- 7 files changed, 372 insertions(+), 169 deletions(-) create mode 100644 hypercerts/EOAMintHypercertStrategy.ts create mode 100644 hypercerts/MintHypercertStrategy.ts create mode 100644 hypercerts/SafeMintHypercertStrategy.tsx create mode 100644 hypercerts/hooks/useMintHypercertStrategy.ts 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/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/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/package.json b/package.json index ba54a68e..2dd17125 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@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/sdk": "2.6.0", "@next/env": "^14.2.10", "@openzeppelin/merkle-tree": "^1.0.6", "@radix-ui/react-accordion": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c201ec2..03e50f53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: 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 @@ -829,8 +829,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==} @@ -7276,17 +7281,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' From ecf41c010924b3e202acc9edfe53e41f078090e6 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 25 Mar 2025 17:27:22 +0200 Subject: [PATCH 21/23] chore: fix leading whitespace It's not leading to an error, but it shouldn't be there anyway. --- package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2dd17125..84b2ce52 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@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/marketplace-sdk": "0.5.0-alpha.0", "@hypercerts-org/sdk": "2.6.0", "@next/env": "^14.2.10", "@openzeppelin/merkle-tree": "^1.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e50f53..8b341db3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ 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.6.0 From d5c24e48d7e866042ba97ca1e40e4589c7f8e5b1 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 25 Mar 2025 18:48:16 +0200 Subject: [PATCH 22/23] feat: add Safe support for claiming hypercerts Without this patch multisigs that have Hypercerts to be claimed have no straightforward way of claiming them. This patch introduces the same patterns as used for minting a Hypercert from a Safe. --- .../unclaimed-hypercert-claim-button.tsx | 111 ++++-------------- .../unclaimed-fraction-table.tsx | 9 ++ hypercerts/ClaimHypercertStrategy.ts | 23 ++++ hypercerts/EOAClaimHypercertStrategy.ts | 69 +++++++++++ hypercerts/SafeClaimHypercertStrategy.tsx | 88 ++++++++++++++ hypercerts/hooks/useClaimHypercert.ts | 28 +++++ hypercerts/hooks/useClaimHypercertStrategy.ts | 46 ++++++++ 7 files changed, 289 insertions(+), 85 deletions(-) create mode 100644 hypercerts/ClaimHypercertStrategy.ts create mode 100644 hypercerts/EOAClaimHypercertStrategy.ts create mode 100644 hypercerts/SafeClaimHypercertStrategy.tsx create mode 100644 hypercerts/hooks/useClaimHypercert.ts create mode 100644 hypercerts/hooks/useClaimHypercertStrategy.ts diff --git a/components/profile/unclaimed-hypercert-claim-button.tsx b/components/profile/unclaimed-hypercert-claim-button.tsx index 8e0e87ec..e73c440e 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,116 +16,60 @@ 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/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/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/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, + ); + }; +}; From 611e3ef9414252c65853bf5076de8350c07cb7a0 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 25 Mar 2025 19:21:07 +0200 Subject: [PATCH 23/23] fix: showing switch chain when disconnected When disconnected the button was showing "Switch chain". Now it shows "Claim" and only an outline. It's disabled as well until the user connects their wallet. --- components/profile/unclaimed-hypercert-claim-button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/profile/unclaimed-hypercert-claim-button.tsx b/components/profile/unclaimed-hypercert-claim-button.tsx index e73c440e..ceb7c111 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -69,9 +69,9 @@ export default function UnclaimedHypercertClaimButton({ }} disabled={selectedHypercert?.user_address !== activeAddress || isLoading} > - {hypercertChainId === currentChain?.id?.toString() - ? "Claim" - : `Switch chain`} + {hypercertChainId === activeAddress && !currentChain?.id?.toString() + ? "Switch chain" + : "Claim"} ); }