From 76b00ab3653fe6806a1b74a26ffff189ac23cbd5 Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 25 Mar 2025 18:48:16 +0200 Subject: [PATCH 1/3] 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 | 126 ++++-------------- .../unclaimed-fraction-table.tsx | 9 ++ hypercerts/ClaimHypercertStrategy.ts | 25 ++++ hypercerts/EOAClaimHypercertStrategy.ts | 82 ++++++++++++ hypercerts/SafeClaimHypercertStrategy.tsx | 88 ++++++++++++ hypercerts/hooks/useClaimHypercert.ts | 28 ++++ hypercerts/hooks/useClaimHypercertStrategy.ts | 52 ++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +- 9 files changed, 315 insertions(+), 107 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 d0df0254..5c9f3d68 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -2,16 +2,11 @@ import { AllowListRecord } from "@/allowlists/actions/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 { useRouter } from "next/navigation"; +import { useAccount, useSwitchChain } from "wagmi"; 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 { getAddress } from "viem"; +import { useAccountStore } from "@/lib/account-store"; +import { useClaimHypercert } from "@/hypercerts/hooks/useClaimHypercert"; interface UnclaimedHypercertClaimButtonProps { allowListRecord: Row; @@ -20,102 +15,31 @@ interface UnclaimedHypercertClaimButtonProps { export default function UnclaimedHypercertClaimButton({ allowListRecord, }: UnclaimedHypercertClaimButtonProps) { - const { client } = useHypercertClient(); - const { data: walletClient } = useWalletClient(); - const account = useAccount(); - const { refresh } = useRouter(); + const { address, chain: currentChain } = useAccount(); + const { selectedAccount } = useAccountStore(); const [isLoading, setIsLoading] = useState(false); - const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = - useStepProcessDialogContext(); const { switchChain } = useSwitchChain(); - const router = useRouter(); - const selectedHypercert = allowListRecord.original; const hypercertChainId = selectedHypercert?.hypercert_id?.split("-")[0]; + const activeAddress = selectedAccount?.address || (address as `0x${string}`); + const { mutateAsync: claimHypercert } = useClaimHypercert(); - const refreshData = async (address: string) => { - await revalidatePathServerAction([ - `/profile/${address}`, - `/profile/${address}?tab`, - `/profile/${address}?tab=hypercerts-claimable`, - `/profile/${address}?tab=hypercerts-owned`, - `/hypercerts/${selectedHypercert?.hypercert_id}`, - ]).then(() => { - setTimeout(() => { - // refresh after 5 seconds - router.refresh(); - // push to the profile page with the hypercerts-claimable tab - // because revalidatePath will revalidate on the next page visit. - router.push(`/profile/${address}?tab=hypercerts-claimable`); - }, 5000); - }); - }; - - 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 refreshData(getAddress(account.address!)); - } else if (receipt.status == "reverted") { - await setDialogStep("confirming", "error", "Transaction reverted"); - } } catch (error) { console.error(error); } finally { @@ -126,23 +50,23 @@ export default function UnclaimedHypercertClaimButton({ return ( diff --git a/components/profile/unclaimed-table/unclaimed-fraction-table.tsx b/components/profile/unclaimed-table/unclaimed-fraction-table.tsx index f010117f..ac264617 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-batchClai 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..84f17fdf --- /dev/null +++ b/hypercerts/ClaimHypercertStrategy.ts @@ -0,0 +1,25 @@ +import { Address, Chain } from "viem"; +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +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, + protected router: AppRouterInstance, + ) {} + + abstract execute(params: ClaimHypercertParams): Promise; +} diff --git a/hypercerts/EOAClaimHypercertStrategy.ts b/hypercerts/EOAClaimHypercertStrategy.ts new file mode 100644 index 00000000..2c2acae7 --- /dev/null +++ b/hypercerts/EOAClaimHypercertStrategy.ts @@ -0,0 +1,82 @@ +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("preparing", "active"); + await setDialogStep("claiming", "active"); + const tx = await this.client.mintClaimFractionFromAllowlist( + tokenId, + units, + proof, + undefined, + ); + + if (!tx) { + await setDialogStep("claiming", "error"); + 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"); + + // Revalidate all relevant paths + await revalidatePathServerAction([ + `/hypercerts/${this.chain.id}-${tokenId}`, + `/profile/${this.address}`, + `/profile/${this.address}?tab`, + `/profile/${this.address}?tab=hypercerts-claimable`, + `/profile/${this.address}?tab=hypercerts-owned`, + ]); + + // Wait 5 seconds before refreshing and navigating + setTimeout(() => { + this.router.refresh(); + this.router.push(`/profile/${this.address}?tab=hypercerts-claimable`); + }, 5000); + } 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..9d84e54d --- /dev/null +++ b/hypercerts/hooks/useClaimHypercertStrategy.ts @@ -0,0 +1,52 @@ +import { isAddress } from "viem"; +import { useAccount, useWalletClient } from "wagmi"; +import { useRouter } from "next/navigation"; + +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(); + const router = useRouter(); + + 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( + router, + router, + activeAddress, + chain, + client, + dialogContext, + walletClient, + router, + ) + : new EOAClaimHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + router, + ); + }; +}; diff --git a/package.json b/package.json index beb7b85a..5001cc6e 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.6.0", + "@hypercerts-org/sdk": "2.7.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 04d7fb11..ce83dc0f 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.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) + specifier: 2.7.0 + version: 2.7.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 @@ -835,8 +835,8 @@ packages: '@hypercerts-org/sdk@2.4.0': resolution: {integrity: sha512-9vxQW3zBwi3WCOUBMwU1fWEk3z29eyxtDWlaIS7jdUlGwnCcN9IkPzIk7w/jHO96yiH8+vcL/EWFvOp06mtXAw==} - '@hypercerts-org/sdk@2.6.0': - resolution: {integrity: sha512-uq+9WzgW+GWazEKTUEhUPZr8sTxhORaNI6DfRfDTZ8w0FJtPEXSSLt5mr9x5VN8EghM1NsPvKzf38jkO8wBkZg==} + '@hypercerts-org/sdk@2.7.0': + resolution: {integrity: sha512-ciTZxuoHYgLPiXnAx65wTkURQVUk6QbJx6XKsfLV16i+i/ZxwxSx+Kk9AsDYoa0LkQzIFcJwnv+CroFyzL0cUQ==} peerDependencies: '@safe-global/api-kit': ^2.5.7 '@safe-global/protocol-kit': ^5.2.0 @@ -7293,7 +7293,7 @@ snapshots: - typescript - utf-8-validate - '@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)': + '@hypercerts-org/sdk@2.7.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) From 89dc20224139defc11541554efa21f83d60d44bb Mon Sep 17 00:00:00 2001 From: pheuberger Date: Tue, 25 Mar 2025 19:21:07 +0200 Subject: [PATCH 2/3] 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 5c9f3d68..b07733a0 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -66,9 +66,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"} ); } From d9ed59d581bd30bf7a1f7c545b4cc7b692484d5b Mon Sep 17 00:00:00 2001 From: pheuberger Date: Fri, 4 Apr 2025 21:40:05 +0300 Subject: [PATCH 3/3] feat: add safe support for batch claiming hypercerts In the previous commit 5ca3a2c02024 I missed adding support for batch claiming hypercerts. This patch adds another set of strategies for this case, nicely abstracting all these code paths into their own strategy. --- .../unclaimed-hypercert-batchClaim-button.tsx | 111 ++++-------------- .../unclaimed-hypercert-claim-button.tsx | 12 +- hypercerts/ClaimHypercertStrategy.ts | 2 +- hypercerts/EOABatchClaimHypercertStrategy.ts | 89 ++++++++++++++ hypercerts/EOAClaimHypercertStrategy.ts | 6 +- .../SafeBatchClaimHypercertStrategy.tsx | 103 ++++++++++++++++ hypercerts/SafeClaimHypercertStrategy.tsx | 6 +- hypercerts/hooks/useClaimHypercert.ts | 12 +- hypercerts/hooks/useClaimHypercertStrategy.ts | 41 +++++-- 9 files changed, 269 insertions(+), 113 deletions(-) create mode 100644 hypercerts/EOABatchClaimHypercertStrategy.ts create mode 100644 hypercerts/SafeBatchClaimHypercertStrategy.tsx diff --git a/components/profile/unclaimed-hypercert-batchClaim-button.tsx b/components/profile/unclaimed-hypercert-batchClaim-button.tsx index 8c7a9b39..c61d72c1 100644 --- a/components/profile/unclaimed-hypercert-batchClaim-button.tsx +++ b/components/profile/unclaimed-hypercert-batchClaim-button.tsx @@ -1,18 +1,14 @@ "use client"; import { AllowListRecord } from "@/allowlists/actions/getAllowListRecordsForAddressByClaimed"; -import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; -import { useHypercertClient } from "@/hooks/use-hypercert-client"; -import { ChainFactory } from "@/lib/chainFactory"; -import { errorToast } from "@/lib/errorToast"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { ByteArray, getAddress, Hex } from "viem"; -import { waitForTransactionReceipt } from "viem/actions"; -import { useAccount, useSwitchChain, useWalletClient } from "wagmi"; -import { createExtraContent } from "../global/extra-content"; -import { useStepProcessDialogContext } from "../global/step-process-dialog"; import { Button } from "../ui/button"; +import { useAccount, useSwitchChain } from "wagmi"; +import { useState } from "react"; +import { getAddress, Hex, ByteArray } from "viem"; +import { errorToast } from "@/lib/errorToast"; +import { ChainFactory } from "@/lib/chainFactory"; +import { useClaimHypercertStrategy } from "@/hypercerts/hooks/useClaimHypercertStrategy"; +import { useAccountStore } from "@/lib/account-store"; interface TransformedClaimData { hypercertTokenIds: bigint[]; @@ -39,104 +35,39 @@ export default function UnclaimedHypercertBatchClaimButton({ allowListRecords: AllowListRecord[]; selectedChainId: number | null; }) { - const router = useRouter(); - const { client } = useHypercertClient(); - const { data: walletClient } = useWalletClient(); const account = useAccount(); const [isLoading, setIsLoading] = useState(false); - const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = - useStepProcessDialogContext(); const { switchChain } = useSwitchChain(); + const getStrategy = useClaimHypercertStrategy(); + const { selectedAccount } = useAccountStore(); + const selectedChain = selectedChainId ? ChainFactory.getChain(selectedChainId) : null; - const refreshData = async (address: string) => { - const hypercertIds = allowListRecords.map((record) => record.hypercert_id); - - const hypercertViewInvalidationPaths = hypercertIds.map((id) => { - return `/hypercerts/${id}`; - }); - - await revalidatePathServerAction([ - `/profile/${address}`, - `/profile/${address}?tab`, - `/profile/${address}?tab=hypercerts-claimable`, - `/profile/${address}?tab=hypercerts-owned`, - ...hypercertViewInvalidationPaths, - ]).then(async () => { - setTimeout(() => { - // refresh after 5 seconds - router.refresh(); - - // push to the profile page with the hypercerts-claimable tab - // because revalidatePath will revalidate on the next page visit. - router.push(`/profile/${address}?tab=hypercerts-claimable`); - }, 5000); - }); - }; - const claimHypercert = async () => { setIsLoading(true); - setOpen(true); - setSteps([ - { id: "preparing", description: "Preparing to claim fractions..." }, - { id: "claiming", description: "Claiming fractions on-chain..." }, - { id: "confirming", description: "Waiting for on-chain confirmation" }, - { id: "done", description: "Claiming complete!" }, - ]); - setTitle("Claim fractions 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"); - } - - const claimData = transformAllowListRecords(allowListRecords); - await setDialogStep("preparing, active"); try { - await setDialogStep("claiming", "active"); - const tx = await client.batchClaimFractionsFromAllowlists(claimData); - - if (!tx) { - await setDialogStep("claiming", "error"); - throw new Error("Failed to claim fractions"); - } - - await setDialogStep("confirming", "active"); - const receipt = await waitForTransactionReceipt(walletClient, { - hash: tx, - }); - - if (receipt.status == "success") { - await setDialogStep("done", "completed"); - const extraContent = createExtraContent({ - receipt, - chain: account?.chain!, - }); - setExtraContent(extraContent); - refreshData(getAddress(account.address!)); - } else if (receipt.status == "reverted") { - await setDialogStep("confirming", "error", "Transaction reverted"); - } + const claimData = transformAllowListRecords(allowListRecords); + const params = claimData.hypercertTokenIds.map((tokenId, index) => ({ + tokenId, + units: claimData.units[index], + proof: claimData.proofs[index] as `0x${string}`[], + })); + await getStrategy(params).execute(params); } catch (error) { - console.error("Claim error:", error); - await setDialogStep("claiming", "error", "Transaction failed"); + console.error(error); } finally { setIsLoading(false); } }; + const activeAddress = selectedAccount?.address || account.address; const isBatchClaimDisabled = isLoading || !allowListRecords.length || - !account || - !client || - account.address !== getAddress(allowListRecords[0].user_address as string); + !activeAddress || + activeAddress !== getAddress(allowListRecords[0].user_address as string); return ( <> diff --git a/components/profile/unclaimed-hypercert-claim-button.tsx b/components/profile/unclaimed-hypercert-claim-button.tsx index b07733a0..b6bec4aa 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -35,11 +35,13 @@ export default function UnclaimedHypercertClaimButton({ throw new Error("Invalid allow list record"); } - await claimHypercert({ - tokenId: BigInt(selectedHypercert.token_id), - units: BigInt(selectedHypercert.units), - proof: selectedHypercert.proof as `0x${string}`[], - }); + await claimHypercert([ + { + tokenId: BigInt(selectedHypercert.token_id), + units: BigInt(selectedHypercert.units), + proof: selectedHypercert.proof as `0x${string}`[], + }, + ]); } catch (error) { console.error(error); } finally { diff --git a/hypercerts/ClaimHypercertStrategy.ts b/hypercerts/ClaimHypercertStrategy.ts index 84f17fdf..be7cd25e 100644 --- a/hypercerts/ClaimHypercertStrategy.ts +++ b/hypercerts/ClaimHypercertStrategy.ts @@ -21,5 +21,5 @@ export abstract class ClaimHypercertStrategy { protected router: AppRouterInstance, ) {} - abstract execute(params: ClaimHypercertParams): Promise; + abstract execute(params: ClaimHypercertParams[]): Promise; } diff --git a/hypercerts/EOABatchClaimHypercertStrategy.ts b/hypercerts/EOABatchClaimHypercertStrategy.ts new file mode 100644 index 00000000..7449b463 --- /dev/null +++ b/hypercerts/EOABatchClaimHypercertStrategy.ts @@ -0,0 +1,89 @@ +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 EOABatchClaimHypercertStrategy extends ClaimHypercertStrategy { + async execute(params: ClaimHypercertParams[]): Promise { + 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 fractions..." }, + { id: "claiming", description: "Claiming fractions on-chain..." }, + { id: "confirming", description: "Waiting for on-chain confirmation" }, + { id: "done", description: "Claiming complete!" }, + ]); + setTitle("Claim fractions from Allowlist"); + + try { + await setDialogStep("preparing", "active"); + await setDialogStep("claiming", "active"); + + const tx = await this.client.batchClaimFractionsFromAllowlists( + mapClaimParams(params), + ); + if (!tx) { + await setDialogStep("claiming", "error"); + throw new Error("Failed to claim fractions"); + } + + await setDialogStep("confirming", "active"); + const receipt = await waitForTransactionReceipt(walletClient, { + hash: tx, + }); + + if (receipt.status === "success") { + await setDialogStep("done", "completed"); + const extraContent = createExtraContent({ + receipt, + chain: this.chain, + }); + setExtraContent(extraContent); + + const hypercertViewInvalidationPaths = params.map((param) => { + return `/hypercerts/${param.tokenId}`; + }); + + // Revalidate all relevant paths + await revalidatePathServerAction([ + `/profile/${this.address}`, + `/profile/${this.address}?tab`, + `/profile/${this.address}?tab=hypercerts-claimable`, + `/profile/${this.address}?tab=hypercerts-owned`, + ...hypercertViewInvalidationPaths, + ]); + + // Wait 5 seconds before refreshing and navigating + setTimeout(() => { + this.router.refresh(); + this.router.push(`/profile/${this.address}?tab=hypercerts-claimable`); + }, 5000); + } else if (receipt.status === "reverted") { + await setDialogStep("confirming", "error", "Transaction reverted"); + } + } catch (error) { + console.error("Claim error:", error); + await setDialogStep("claiming", "error", "Transaction failed"); + throw error; + } + } +} + +function mapClaimParams(params: ClaimHypercertParams[]) { + return { + hypercertTokenIds: params.map((p) => p.tokenId), + units: params.map((p) => p.units), + proofs: params.map((p) => p.proof), + }; +} diff --git a/hypercerts/EOAClaimHypercertStrategy.ts b/hypercerts/EOAClaimHypercertStrategy.ts index 2c2acae7..f567ee51 100644 --- a/hypercerts/EOAClaimHypercertStrategy.ts +++ b/hypercerts/EOAClaimHypercertStrategy.ts @@ -1,4 +1,5 @@ import { waitForTransactionReceipt } from "viem/actions"; +import assert from "assert"; import { createExtraContent } from "@/components/global/extra-content"; import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; @@ -9,7 +10,10 @@ import { } from "./ClaimHypercertStrategy"; export class EOAClaimHypercertStrategy extends ClaimHypercertStrategy { - async execute({ tokenId, units, proof }: ClaimHypercertParams) { + async execute(params: ClaimHypercertParams[]) { + assert(params.length === 1, "Only one claim params object allowed"); + + const { tokenId, units, proof } = params[0]; const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = this.dialogContext; const { data: walletClient } = this.walletClient; diff --git a/hypercerts/SafeBatchClaimHypercertStrategy.tsx b/hypercerts/SafeBatchClaimHypercertStrategy.tsx new file mode 100644 index 00000000..11c6acba --- /dev/null +++ b/hypercerts/SafeBatchClaimHypercertStrategy.tsx @@ -0,0 +1,103 @@ +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"; + +function DialogFooter({ + chain, + safeAddress, +}: { + chain: Chain; + safeAddress: string; +}) { + return ( +
+

Success

+

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

+
+ {chain && ( + + )} +
+
+ ); +} + +export class SafeBatchClaimHypercertStrategy extends ClaimHypercertStrategy { + async execute(params: ClaimHypercertParams[]): Promise { + console.log("[SafeBatchClaim] Starting execution with params:", params); + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + this.dialogContext; + + if (!this.client) { + console.error("[SafeBatchClaim] No client found"); + setOpen(false); + throw new Error("No client found"); + } + + console.log("[SafeBatchClaim] Setting up dialog UI"); + setOpen(true); + setTitle("Claim fractions from Allowlist"); + setSteps([ + { id: "preparing", description: "Preparing to claim fractions..." }, + { id: "submitting", description: "Submitting to Safe..." }, + { id: "queued", description: "Transaction queued in Safe" }, + ]); + + await setDialogStep("preparing", "active"); + console.log("[SafeBatchClaim] Preparation step completed"); + + try { + console.log("[SafeBatchClaim] Starting submission to Safe"); + await setDialogStep("submitting", "active"); + const mappedParams = mapClaimParams(params); + console.log("[SafeBatchClaim] Mapped params:", mappedParams); + + await this.client.batchClaimFractionsFromAllowlists({ + ...mappedParams, + overrides: { + safeAddress: this.address as `0x${string}`, + }, + }); + + console.log("[SafeBatchClaim] Successfully queued transaction in Safe"); + await setDialogStep("queued", "completed"); + + setExtraContent(() => ( + + )); + } catch (error) { + console.error("[SafeBatchClaim] Error during execution:", error); + await setDialogStep( + "submitting", + "error", + error instanceof Error ? error.message : "Unknown error", + ); + throw error; + } + } +} + +function mapClaimParams(params: ClaimHypercertParams[]) { + return { + hypercertTokenIds: params.map((p) => p.tokenId), + units: params.map((p) => p.units), + proofs: params.map((p) => p.proof), + }; +} diff --git a/hypercerts/SafeClaimHypercertStrategy.tsx b/hypercerts/SafeClaimHypercertStrategy.tsx index f5314f9f..3afb56d2 100644 --- a/hypercerts/SafeClaimHypercertStrategy.tsx +++ b/hypercerts/SafeClaimHypercertStrategy.tsx @@ -1,5 +1,6 @@ import { Chain } from "viem"; import { ExternalLink } from "lucide-react"; +import assert from "assert"; import { Button } from "@/components/ui/button"; import { generateSafeAppLink } from "@/lib/utils"; @@ -10,7 +11,10 @@ import { } from "./ClaimHypercertStrategy"; export class SafeClaimHypercertStrategy extends ClaimHypercertStrategy { - async execute({ tokenId, units, proof }: ClaimHypercertParams) { + async execute(params: ClaimHypercertParams[]) { + assert(params.length === 1, "Only one claim params object allowed"); + + const { tokenId, units, proof } = params[0]; const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = this.dialogContext; diff --git a/hypercerts/hooks/useClaimHypercert.ts b/hypercerts/hooks/useClaimHypercert.ts index 15bef239..cfe10b91 100644 --- a/hypercerts/hooks/useClaimHypercert.ts +++ b/hypercerts/hooks/useClaimHypercert.ts @@ -1,12 +1,8 @@ import { useMutation } from "@tanstack/react-query"; import { toast } from "@/components/ui/use-toast"; -import { useClaimHypercertStrategy } from "./useClaimHypercertStrategy"; +import { ClaimHypercertParams } from "../ClaimHypercertStrategy"; -interface ClaimHypercertParams { - tokenId: bigint; - units: bigint; - proof: `0x${string}`[]; -} +import { useClaimHypercertStrategy } from "./useClaimHypercertStrategy"; export const useClaimHypercert = () => { const getStrategy = useClaimHypercertStrategy(); @@ -21,8 +17,8 @@ export const useClaimHypercert = () => { duration: 5000, }); }, - mutationFn: async (params: ClaimHypercertParams) => { - return getStrategy().execute(params); + mutationFn: async (params: ClaimHypercertParams[]) => { + return getStrategy(params).execute(params); }, }); }; diff --git a/hypercerts/hooks/useClaimHypercertStrategy.ts b/hypercerts/hooks/useClaimHypercertStrategy.ts index 9d84e54d..5e78a00f 100644 --- a/hypercerts/hooks/useClaimHypercertStrategy.ts +++ b/hypercerts/hooks/useClaimHypercertStrategy.ts @@ -6,11 +6,18 @@ 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 { + ClaimHypercertParams, + ClaimHypercertStrategy, +} from "../ClaimHypercertStrategy"; import { EOAClaimHypercertStrategy } from "../EOAClaimHypercertStrategy"; import { SafeClaimHypercertStrategy } from "../SafeClaimHypercertStrategy"; +import { EOABatchClaimHypercertStrategy } from "../EOABatchClaimHypercertStrategy"; +import { SafeBatchClaimHypercertStrategy } from "../SafeBatchClaimHypercertStrategy"; -export const useClaimHypercertStrategy = (): (() => ClaimHypercertStrategy) => { +export const useClaimHypercertStrategy = (): (( + params: ClaimHypercertParams[], +) => ClaimHypercertStrategy) => { const { address, chain } = useAccount(); const { client } = useHypercertClient(); const { selectedAccount } = useAccountStore(); @@ -18,7 +25,7 @@ export const useClaimHypercertStrategy = (): (() => ClaimHypercertStrategy) => { const walletClient = useWalletClient(); const router = useRouter(); - return () => { + return (params: ClaimHypercertParams[]) => { const activeAddress = selectedAccount?.address || (address as `0x${string}`); @@ -29,10 +36,30 @@ export const useClaimHypercertStrategy = (): (() => ClaimHypercertStrategy) => { if (!walletClient) throw new Error("No walletClient found"); if (!dialogContext) throw new Error("No dialogContext found"); - return selectedAccount?.type === "safe" - ? new SafeClaimHypercertStrategy( - router, - router, + const isBatch = params.length > 1; + + if (selectedAccount?.type === "safe") { + return isBatch + ? new SafeBatchClaimHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + router + ) + : new SafeClaimHypercertStrategy( + activeAddress, + chain, + client, + dialogContext, + walletClient, + router + ); + } + + return isBatch + ? new EOABatchClaimHypercertStrategy( activeAddress, chain, client,