Skip to content

Commit 20c0848

Browse files
committed
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.
1 parent c67223e commit 20c0848

File tree

9 files changed

+316
-108
lines changed

9 files changed

+316
-108
lines changed

components/profile/unclaimed-hypercert-claim-button.tsx

Lines changed: 26 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
"use client";
22

3-
import { AllowListRecord } from "@/allowlists/actions/getAllowListRecordsForAddressByClaimed";
3+
import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByClaimed";
44
import { Button } from "../ui/button";
5-
import { useHypercertClient } from "@/hooks/use-hypercert-client";
6-
import { waitForTransactionReceipt } from "viem/actions";
7-
import { useAccount, useSwitchChain, useWalletClient } from "wagmi";
8-
import { useRouter } from "next/navigation";
5+
import { useAccount, useSwitchChain } from "wagmi";
96
import { Row } from "@tanstack/react-table";
10-
import { useStepProcessDialogContext } from "../global/step-process-dialog";
11-
import { createExtraContent } from "../global/extra-content";
12-
import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction";
137
import { useState } from "react";
14-
import { getAddress } from "viem";
8+
import { useAccountStore } from "@/lib/account-store";
9+
import { useClaimHypercert } from "@/hypercerts/hooks/useClaimHypercert";
1510

1611
interface UnclaimedHypercertClaimButtonProps {
1712
allowListRecord: Row<AllowListRecord>;
@@ -20,102 +15,31 @@ interface UnclaimedHypercertClaimButtonProps {
2015
export default function UnclaimedHypercertClaimButton({
2116
allowListRecord,
2217
}: UnclaimedHypercertClaimButtonProps) {
23-
const { client } = useHypercertClient();
24-
const { data: walletClient } = useWalletClient();
25-
const account = useAccount();
26-
const { refresh } = useRouter();
18+
const { address, chain: currentChain } = useAccount();
19+
const { selectedAccount } = useAccountStore();
2720
const [isLoading, setIsLoading] = useState(false);
28-
const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } =
29-
useStepProcessDialogContext();
3021
const { switchChain } = useSwitchChain();
31-
const router = useRouter();
32-
3322
const selectedHypercert = allowListRecord.original;
3423
const hypercertChainId = selectedHypercert?.hypercert_id?.split("-")[0];
24+
const activeAddress = selectedAccount?.address || (address as `0x${string}`);
25+
const { mutateAsync: claimHypercert } = useClaimHypercert();
3526

36-
const refreshData = async (address: string) => {
37-
await revalidatePathServerAction([
38-
`/profile/${address}`,
39-
`/profile/${address}?tab`,
40-
`/profile/${address}?tab=hypercerts-claimable`,
41-
`/profile/${address}?tab=hypercerts-owned`,
42-
`/hypercerts/${selectedHypercert?.hypercert_id}`,
43-
]).then(() => {
44-
setTimeout(() => {
45-
// refresh after 5 seconds
46-
router.refresh();
47-
// push to the profile page with the hypercerts-claimable tab
48-
// because revalidatePath will revalidate on the next page visit.
49-
router.push(`/profile/${address}?tab=hypercerts-claimable`);
50-
}, 5000);
51-
});
52-
};
53-
54-
const claimHypercert = async () => {
27+
const handleClaim = async () => {
5528
setIsLoading(true);
56-
setOpen(true);
57-
setSteps([
58-
{ id: "preparing", description: "Preparing to claim fraction..." },
59-
{ id: "claiming", description: "Claiming fraction on-chain..." },
60-
{ id: "confirming", description: "Waiting for on-chain confirmation" },
61-
{ id: "route", description: "Creating your new fraction's link..." },
62-
{ id: "done", description: "Claiming complete!" },
63-
]);
64-
65-
setTitle("Claim fraction from Allowlist");
66-
if (!client) {
67-
throw new Error("No client found");
68-
}
69-
70-
if (!walletClient) {
71-
throw new Error("No wallet client found");
72-
}
73-
74-
if (!account) {
75-
throw new Error("No address found");
76-
}
77-
78-
if (
79-
!selectedHypercert?.units ||
80-
!selectedHypercert?.proof ||
81-
!selectedHypercert?.token_id
82-
) {
83-
throw new Error("Invalid allow list record");
84-
}
85-
await setDialogStep("preparing, active");
86-
8729
try {
88-
await setDialogStep("claiming", "active");
89-
const tx = await client.mintClaimFractionFromAllowlist(
90-
BigInt(selectedHypercert?.token_id),
91-
BigInt(selectedHypercert?.units),
92-
selectedHypercert?.proof as `0x${string}`[],
93-
undefined,
94-
);
95-
96-
if (!tx) {
97-
await setDialogStep("claiming", "error");
98-
throw new Error("Failed to claim fraction");
30+
if (
31+
!selectedHypercert.token_id ||
32+
!selectedHypercert.units ||
33+
!selectedHypercert.proof
34+
) {
35+
throw new Error("Invalid allow list record");
9936
}
10037

101-
await setDialogStep("confirming", "active");
102-
const receipt = await waitForTransactionReceipt(walletClient, {
103-
hash: tx,
38+
await claimHypercert({
39+
tokenId: BigInt(selectedHypercert.token_id),
40+
units: BigInt(selectedHypercert.units),
41+
proof: selectedHypercert.proof as `0x${string}`[],
10442
});
105-
106-
if (receipt.status == "success") {
107-
await setDialogStep("route", "active");
108-
const extraContent = createExtraContent({
109-
receipt: receipt,
110-
hypercertId: selectedHypercert?.hypercert_id!,
111-
chain: account.chain!,
112-
});
113-
setExtraContent(extraContent);
114-
await setDialogStep("done", "completed");
115-
await refreshData(getAddress(account.address!));
116-
} else if (receipt.status == "reverted") {
117-
await setDialogStep("confirming", "error", "Transaction reverted");
118-
}
11943
} catch (error) {
12044
console.error(error);
12145
} finally {
@@ -126,23 +50,23 @@ export default function UnclaimedHypercertClaimButton({
12650
return (
12751
<Button
12852
variant={
129-
hypercertChainId === account.chainId?.toString() ? "default" : "outline"
53+
hypercertChainId === currentChain?.id?.toString()
54+
? "default"
55+
: "outline"
13056
}
13157
size={"sm"}
13258
onClick={() => {
133-
if (hypercertChainId === account.chainId?.toString()) {
134-
claimHypercert();
59+
if (hypercertChainId === currentChain?.id?.toString()) {
60+
handleClaim();
13561
} else {
13662
switchChain({
13763
chainId: Number(hypercertChainId),
13864
});
13965
}
14066
}}
141-
disabled={
142-
selectedHypercert?.user_address !== account.address || isLoading
143-
}
67+
disabled={selectedHypercert?.user_address !== activeAddress || isLoading}
14468
>
145-
{hypercertChainId === account.chainId?.toString()
69+
{hypercertChainId === currentChain?.id?.toString()
14670
? "Claim"
14771
: `Switch chain`}
14872
</Button>

components/profile/unclaimed-table/unclaimed-fraction-table.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ import UnclaimedHypercertBatchClaimButton from "../unclaimed-hypercert-batchClai
2929
import { TableToolbar } from "./table-toolbar";
3030
import { useMediaQuery } from "@/hooks/use-media-query";
3131
import { UnclaimedFraction } from "../unclaimed-hypercerts-list";
32+
import { useAccountStore } from "@/lib/account-store";
33+
import { useRouter } from "next/navigation";
3234

3335
export interface DataTableProps {
3436
columns: ColumnDef<UnclaimedFraction>[];
3537
data: UnclaimedFraction[];
3638
}
3739

3840
export function UnclaimedFractionTable({ columns, data }: DataTableProps) {
41+
const { selectedAccount } = useAccountStore();
42+
const router = useRouter();
3943
const [sorting, setSorting] = useState<SortingState>([]);
4044
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
4145
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@@ -139,6 +143,11 @@ export function UnclaimedFractionTable({ columns, data }: DataTableProps) {
139143
setSelectedRecords(getSelectedRecords());
140144
}, [rowSelection, getSelectedRecords]);
141145

146+
// Refresh the entire route when account changes
147+
useEffect(() => {
148+
router.refresh();
149+
}, [selectedAccount?.address, router]);
150+
142151
return (
143152
<div className="w-full">
144153
<div className="flex gap-2 py-4 flex-col lg:flex-row">
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Address, Chain } from "viem";
2+
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
3+
import { HypercertClient } from "@hypercerts-org/sdk";
4+
import { UseWalletClientReturnType } from "wagmi";
5+
6+
import { useStepProcessDialogContext } from "@/components/global/step-process-dialog";
7+
8+
export interface ClaimHypercertParams {
9+
tokenId: bigint;
10+
units: bigint;
11+
proof: `0x${string}`[];
12+
}
13+
14+
export abstract class ClaimHypercertStrategy {
15+
constructor(
16+
protected address: Address,
17+
protected chain: Chain,
18+
protected client: HypercertClient,
19+
protected dialogContext: ReturnType<typeof useStepProcessDialogContext>,
20+
protected walletClient: UseWalletClientReturnType,
21+
protected router: AppRouterInstance,
22+
) {}
23+
24+
abstract execute(params: ClaimHypercertParams): Promise<void>;
25+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { waitForTransactionReceipt } from "viem/actions";
2+
3+
import { createExtraContent } from "@/components/global/extra-content";
4+
import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction";
5+
6+
import {
7+
ClaimHypercertStrategy,
8+
ClaimHypercertParams,
9+
} from "./ClaimHypercertStrategy";
10+
11+
export class EOAClaimHypercertStrategy extends ClaimHypercertStrategy {
12+
async execute({ tokenId, units, proof }: ClaimHypercertParams) {
13+
const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } =
14+
this.dialogContext;
15+
const { data: walletClient } = this.walletClient;
16+
17+
if (!this.client) throw new Error("No client found");
18+
if (!walletClient) throw new Error("No wallet client found");
19+
20+
setOpen(true);
21+
setSteps([
22+
{ id: "preparing", description: "Preparing to claim fraction..." },
23+
{ id: "claiming", description: "Claiming fraction on-chain..." },
24+
{ id: "confirming", description: "Waiting for on-chain confirmation" },
25+
{ id: "route", description: "Creating your new fraction's link..." },
26+
{ id: "done", description: "Claiming complete!" },
27+
]);
28+
setTitle("Claim fraction from Allowlist");
29+
30+
try {
31+
await setDialogStep("preparing", "active");
32+
await setDialogStep("claiming", "active");
33+
const tx = await this.client.mintClaimFractionFromAllowlist(
34+
tokenId,
35+
units,
36+
proof,
37+
undefined,
38+
);
39+
40+
if (!tx) {
41+
await setDialogStep("claiming", "error");
42+
throw new Error("Failed to claim fraction");
43+
}
44+
45+
await setDialogStep("confirming", "active");
46+
const receipt = await waitForTransactionReceipt(walletClient, {
47+
hash: tx,
48+
});
49+
50+
if (receipt.status === "success") {
51+
await setDialogStep("route", "active");
52+
const extraContent = createExtraContent({
53+
receipt,
54+
hypercertId: `${this.chain.id}-${tokenId}`,
55+
chain: this.chain,
56+
});
57+
setExtraContent(extraContent);
58+
await setDialogStep("done", "completed");
59+
60+
// Revalidate all relevant paths
61+
await revalidatePathServerAction([
62+
`/hypercerts/${this.chain.id}-${tokenId}`,
63+
`/profile/${this.address}`,
64+
`/profile/${this.address}?tab`,
65+
`/profile/${this.address}?tab=hypercerts-claimable`,
66+
`/profile/${this.address}?tab=hypercerts-owned`,
67+
]);
68+
69+
// Wait 5 seconds before refreshing and navigating
70+
setTimeout(() => {
71+
this.router.refresh();
72+
this.router.push(`/profile/${this.address}?tab=hypercerts-claimable`);
73+
}, 5000);
74+
} else {
75+
await setDialogStep("confirming", "error", "Transaction reverted");
76+
}
77+
} catch (error) {
78+
console.error(error);
79+
throw error;
80+
}
81+
}
82+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Chain } from "viem";
2+
import { ExternalLink } from "lucide-react";
3+
4+
import { Button } from "@/components/ui/button";
5+
import { generateSafeAppLink } from "@/lib/utils";
6+
7+
import {
8+
ClaimHypercertStrategy,
9+
ClaimHypercertParams,
10+
} from "./ClaimHypercertStrategy";
11+
12+
export class SafeClaimHypercertStrategy extends ClaimHypercertStrategy {
13+
async execute({ tokenId, units, proof }: ClaimHypercertParams) {
14+
const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } =
15+
this.dialogContext;
16+
17+
if (!this.client) {
18+
setOpen(false);
19+
throw new Error("No client found");
20+
}
21+
22+
setOpen(true);
23+
setTitle("Claim fraction from Allowlist");
24+
setSteps([
25+
{ id: "preparing", description: "Preparing to claim fraction..." },
26+
{ id: "submitting", description: "Submitting to Safe..." },
27+
{ id: "queued", description: "Transaction queued in Safe" },
28+
]);
29+
30+
await setDialogStep("preparing", "active");
31+
32+
try {
33+
await setDialogStep("submitting", "active");
34+
await this.client.claimFractionFromAllowlist({
35+
hypercertTokenId: tokenId,
36+
units,
37+
proof,
38+
overrides: {
39+
safeAddress: this.address as `0x${string}`,
40+
},
41+
});
42+
43+
await setDialogStep("queued", "completed");
44+
45+
setExtraContent(() => (
46+
<DialogFooter chain={this.chain} safeAddress={this.address} />
47+
));
48+
} catch (error) {
49+
console.error(error);
50+
await setDialogStep(
51+
"submitting",
52+
"error",
53+
error instanceof Error ? error.message : "Unknown error",
54+
);
55+
throw error;
56+
}
57+
}
58+
}
59+
60+
function DialogFooter({
61+
chain,
62+
safeAddress,
63+
}: {
64+
chain: Chain;
65+
safeAddress: string;
66+
}) {
67+
return (
68+
<div className="flex flex-col space-y-2">
69+
<p className="text-lg font-medium">Success</p>
70+
<p className="text-sm font-medium">
71+
We&apos;ve submitted the claim request to the connected Safe.
72+
</p>
73+
<div className="flex space-x-4 py-4 justify-center">
74+
{chain && (
75+
<Button asChild>
76+
<a
77+
href={generateSafeAppLink(chain, safeAddress as `0x${string}`)}
78+
target="_blank"
79+
rel="noopener noreferrer"
80+
>
81+
View Safe <ExternalLink size={14} className="ml-2" />
82+
</a>
83+
</Button>
84+
)}
85+
</div>
86+
</div>
87+
);
88+
}

0 commit comments

Comments
 (0)