diff --git a/frontend/components/hypercert-fetcher.tsx b/frontend/components/hypercert-fetcher.tsx index e826df7f..78f0da03 100644 --- a/frontend/components/hypercert-fetcher.tsx +++ b/frontend/components/hypercert-fetcher.tsx @@ -28,6 +28,7 @@ export interface HypercertFetcherProps { useQueryString?: boolean; // Forces us to try the query string first byClaimId?: string; // Fetch by claimId byMetadataUri?: string; // Fetch by metadataUri; If both are specified, byMetadataUri will override the URI in the claim + overrideChainId?: number; // Override the chainId } export function HypercertFetcher(props: HypercertFetcherProps) { @@ -40,9 +41,12 @@ export function HypercertFetcher(props: HypercertFetcherProps) { useQueryString, byClaimId, byMetadataUri, + overrideChainId, } = props; const [data, setData] = React.useState(); - const { client } = useHypercertClient(); + const { client } = useHypercertClient({ + overrideChainId, + }); React.useEffect(() => { if (!client) { diff --git a/frontend/components/split-fraction-button.tsx b/frontend/components/split-fraction-button.tsx index c6321870..8b3dae23 100644 --- a/frontend/components/split-fraction-button.tsx +++ b/frontend/components/split-fraction-button.tsx @@ -6,6 +6,7 @@ import { PlusIcon } from "primereact/icons/plus"; import { Delete } from "@mui/icons-material"; import { useSplitFractionUnits } from "../hooks/splitClaimUnits"; import { toast } from "react-toastify"; +import { TransferFractionButton } from "./transfer-fraction-button"; const style = { position: "absolute", @@ -183,6 +184,7 @@ export function SplitFractionButton({ + ); } diff --git a/frontend/components/transfer-fraction-button.tsx b/frontend/components/transfer-fraction-button.tsx index c8ea4ee5..4235837b 100644 --- a/frontend/components/transfer-fraction-button.tsx +++ b/frontend/components/transfer-fraction-button.tsx @@ -1,5 +1,39 @@ -import { Button } from "@mui/material"; -import React from "react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Modal, + TextField, + Typography, +} from "@mui/material"; +import { Send } from "@mui/icons-material"; +import React, { useState } from "react"; +import { useTransferFraction } from "../hooks/transferFraction"; +import { Form, Formik } from "formik"; +import { isAddress } from "viem"; +import { useClaimById, useFractionById } from "../hooks/fractions"; +import { useAccountLowerCase } from "../hooks/account"; +import { formatAddress } from "../lib/formatting"; +import { TransferRestrictions } from "@hypercerts-org/sdk"; +import { useReadTransferRestrictions } from "../hooks/readTransferRestriction"; + +const style = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: 400, + bgcolor: "background.paper", + boxShadow: 24, + pt: 2, + px: 4, + pb: 3, +}; interface Props { fractionId: string; @@ -13,18 +47,199 @@ export function TransferFractionButton({ className, disabled, }: Props) { + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + const { address } = useAccountLowerCase(); + + const { write, readOnly, txPending } = useTransferFraction({ + onComplete: () => { + handleClose(); + }, + }); + + const [dialogOpen, setDialogOpen] = React.useState(false); + + const handleDialogOpen = () => { + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const { data: fractionData, isLoading: isLoadingFraction } = + useFractionById(fractionId); + + const { data: claim, isLoading: isLoadingClaim } = useClaimById( + fractionData?.claimToken?.claim.id, + ); + + const { + data: transferRestrictions, + isLoading: isLoadingTransferRestrictions, + } = useReadTransferRestrictions(claim?.claim?.tokenID); + + const determineCanTransfer = () => { + if (!address) { + return false; + } + + if (!transferRestrictions) { + return false; + } + + if (!(transferRestrictions in TransferRestrictions)) { + return false; + } + + const transferRestrictionValue = + TransferRestrictions[ + transferRestrictions as keyof typeof TransferRestrictions + ]; + + if (transferRestrictionValue === TransferRestrictions.DisallowAll) { + return false; + } + + if (transferRestrictionValue === TransferRestrictions.AllowAll) { + return true; + } + + if (transferRestrictionValue === TransferRestrictions.FromCreatorOnly) { + return claim?.claim?.creator === address; + } + + return false; + }; + + const canTransfer = determineCanTransfer(); + + const tokenId = fractionId.split("-")[1]; + const _disabled = + txPending || + readOnly || + disabled || + isLoadingFraction || + isLoadingClaim || + isLoadingTransferRestrictions; + + if (!canTransfer) { + return null; + } + return ( - + <> + { + console.log({ + fractionId, + text, + className, + disabled, + }); + handleOpen(); + }} + > + + + + + { + if (!values.to || values.to === "") { + return { to: "Required" }; + } + + if (!isAddress(values.to)) { + return { to: "Invalid address" }; + } + }} + onSubmit={() => { + handleDialogOpen(); + }} + > + {({ isSubmitting, isValid, setFieldValue, errors, values }) => { + const isDisabled = _disabled || isSubmitting; + return ( + <> +
+ + Transfer this fraction + + { + setFieldValue("to", e.target.value); + }} + /> + + {" "} + + + Are you sure you want to transfer? + + + + Transferring to {formatAddress(values.to)}. This action + cannot be reversed. + + + + + + + + + ); + }} +
+
+
+ ); } diff --git a/frontend/hooks/fractions.ts b/frontend/hooks/fractions.ts index 66b20dde..5103e39b 100644 --- a/frontend/hooks/fractions.ts +++ b/frontend/hooks/fractions.ts @@ -39,3 +39,31 @@ export const useFractionById = (fractionId: string) => { { enabled: !!fractionId }, ); }; + +export const useClaimById = (claimId?: string | null) => { + const { client } = useHypercertClient(); + + return useQuery( + ["graph", "claims", claimId], + () => { + if (!client) return null; + if (!claimId) return null; + return client.indexer.claimById(claimId); + }, + { enabled: !!claimId && !!client }, + ); +}; + +export const useClaimMetadataByUri = (uri?: string | null) => { + const { client } = useHypercertClient(); + + return useQuery( + ["graph", "claim-metadata", uri], + () => { + if (!client) return null; + if (!uri) return null; + return client.storage.getMetadata(uri); + }, + { enabled: !!uri && !!client }, + ); +}; diff --git a/frontend/hooks/hypercerts-client.ts b/frontend/hooks/hypercerts-client.ts index 727b6747..2defcd2c 100644 --- a/frontend/hooks/hypercerts-client.ts +++ b/frontend/hooks/hypercerts-client.ts @@ -4,7 +4,11 @@ import { NFT_STORAGE_TOKEN, WEB3_STORAGE_TOKEN } from "../lib/config"; import { HypercertClient, HypercertClientConfig } from "@hypercerts-org/sdk"; import { useWalletClient, useNetwork } from "wagmi"; -export const useHypercertClient = () => { +export const useHypercertClient = ({ + overrideChainId, +}: { + overrideChainId?: number; +} = {}) => { const { chain } = useNetwork(); const clientConfig = { chain, @@ -26,13 +30,14 @@ export const useHypercertClient = () => { } = useWalletClient(); useEffect(() => { - if (chain?.id && !walletClientLoading && !isError && walletClient) { + const chainId = overrideChainId || chain?.id; + if (chainId && !walletClientLoading && !isError && walletClient) { setIsLoading(true); try { const config: Partial = { ...clientConfig, - chain: { id: chain.id }, + chain: { id: chainId }, walletClient, }; diff --git a/frontend/hooks/readTransferRestriction.ts b/frontend/hooks/readTransferRestriction.ts new file mode 100644 index 00000000..8133140c --- /dev/null +++ b/frontend/hooks/readTransferRestriction.ts @@ -0,0 +1,28 @@ +import { useHypercertClient } from "./hypercerts-client"; +import { useWalletClient } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { readContract } from "viem/actions"; + +export const useReadTransferRestrictions = (tokenId?: bigint) => { + const { client } = useHypercertClient(); + const { data: walletClient } = useWalletClient(); + + return useQuery( + ["read-transfer-restrictions", tokenId], + async () => { + if (!client) return null; + if (!tokenId) return null; + if (!walletClient) return null; + const contract = client.contract; + + if (!contract) return null; + + return (await readContract(walletClient, { + ...contract, + functionName: "readTransferRestriction", + args: [tokenId], + })) as string; + }, + { enabled: !!tokenId && !!client }, + ); +}; diff --git a/frontend/hooks/transferFraction.ts b/frontend/hooks/transferFraction.ts new file mode 100644 index 00000000..e8d1509a --- /dev/null +++ b/frontend/hooks/transferFraction.ts @@ -0,0 +1,102 @@ +import { useContractModal } from "../components/contract-interaction-dialog-context"; +import { useParseBlockchainError } from "../lib/parse-blockchain-error"; +import { toast } from "react-toastify"; +import { useHypercertClient } from "./hypercerts-client"; +import { useState } from "react"; +import { waitForTransactionReceipt, writeContract } from "viem/actions"; +import { useAccount, useWalletClient } from "wagmi"; + +export const useTransferFraction = ({ + onComplete, +}: { + onComplete?: () => void; +}) => { + const [txPending, setTxPending] = useState(false); + + const { client, isLoading } = useHypercertClient(); + const { data: walletClient } = useWalletClient(); + const { address } = useAccount(); + + const stepDescriptions = { + transferring: "Transferring", + waiting: "Awaiting confirmation", + complete: "Done splitting", + }; + + const { setStep, showModal, hideModal } = useContractModal(); + const parseError = useParseBlockchainError(); + + const initializeWrite = async (fractionId: bigint, to: string) => { + if (!client) { + toast("No client found", { + type: "error", + }); + return; + } + + if (!walletClient) { + toast("No wallet client found", { + type: "error", + }); + return; + } + + if (!address) { + toast("No address found", { + type: "error", + }); + return; + } + + const hypercertMinterContract = client.contract; + + showModal({ stepDescriptions }); + setStep("transferring"); + try { + setTxPending(true); + const tx = await writeContract(walletClient, { + ...hypercertMinterContract, + functionName: "safeTransferFrom", + args: [address, to, fractionId, 1, ""], + }); + setStep("waiting"); + const receipt = await waitForTransactionReceipt(walletClient, { + hash: tx, + }); + + if (receipt?.status === "reverted") { + toast("Splitting failed", { + type: "error", + }); + console.error(receipt); + } + if (receipt?.status === "success") { + toast("Fraction successfully sent", { type: "success" }); + + setStep("complete"); + onComplete?.(); + } + } catch (error) { + toast(parseError(error, "Fraction could not be sent"), { + type: "error", + }); + console.error(error); + } finally { + hideModal(); + setTxPending(false); + } + }; + + return { + write: async (id: bigint, to: string) => { + try { + await initializeWrite(id, to); + window.location.reload(); + } catch (e) { + console.error(e); + } + }, + txPending, + readOnly: isLoading || !client || client.readonly, + }; +};