Skip to content

Commit

Permalink
Fund / Approve workflow (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
technophile-04 authored Mar 5, 2024
1 parent d574a29 commit 61ecfaf
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 73 deletions.
46 changes: 46 additions & 0 deletions packages/nextjs/app/admin/_components/ActionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { forwardRef, useRef } from "react";
import { useReviewGrant } from "../hooks/useReviewGrant";
import { GrantData } from "~~/services/database/schema";
import { PROPOSAL_STATUS } from "~~/utils/grants";

type ActionModalProps = {
grant: GrantData;
initialTxLink?: string;
};

export const ActionModal = forwardRef<HTMLDialogElement, ActionModalProps>(({ grant, initialTxLink }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);

const { handleReviewGrant, isLoading } = useReviewGrant(grant);

const acceptStatus = grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED;
const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
return (
<dialog id="action_modal" className="modal" ref={ref}>
<div className="modal-box flex flex-col space-y-3">
<form method="dialog">
{/* if there is a button in form, it will close the modal */}
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<p className="font-bold text-lg m-0">{acceptLabel} this grant</p>
<input
type="text"
ref={inputRef}
defaultValue={initialTxLink ?? ""}
placeholder="Transaction hash"
className="input input-bordered"
/>
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={() => handleReviewGrant(acceptStatus, inputRef.current?.value)}
disabled={isLoading}
>
{isLoading && <span className="loading loading-spinner"></span>}
{acceptLabel}
</button>
</div>
</dialog>
);
});

ActionModal.displayName = "ActionModal";
107 changes: 40 additions & 67 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { useRef } from "react";
import { useReviewGrant } from "../hooks/useReviewGrant";
import { ActionModal } from "./ActionModal";
import { parseEther } from "viem";
import { useSendTransaction } from "wagmi";
import TelegramIcon from "~~/components/assets/TelegramIcon";
import TwitterIcon from "~~/components/assets/TwitterIcon";
import { Address } from "~~/components/scaffold-eth";
import { GrantData, GrantDataWithBuilder, SocialLinks } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

type ReqBody = {
signer: string;
signature: `0x${string}`;
action: ProposalStatusType;
};
import { useTransactor } from "~~/hooks/scaffold-eth";
import { GrantDataWithBuilder, SocialLinks } from "~~/services/database/schema";
import { PROPOSAL_STATUS } from "~~/utils/grants";

const BuilderSocials = ({ socialLinks }: { socialLinks?: SocialLinks }) => {
if (!socialLinks) return null;
Expand Down Expand Up @@ -46,55 +40,18 @@ const BuilderSocials = ({ socialLinks }: { socialLinks?: SocialLinks }) => {
};

export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => {
const { address } = useAccount();
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { trigger: postReviewGrant, isMutating: isPostingNewGrant } = useSWRMutation(
`/api/grants/${grant.id}/review`,
postMutationFetcher<ReqBody>,
);
const { mutate } = useSWRConfig();
const isLoading = isSigningMessage || isPostingNewGrant;

const handleReviewGrant = async (grant: GrantData, action: ProposalStatusType) => {
if (!address) {
notification.error("Please connect your wallet");
return;
}
const modalRef = useRef<HTMLDialogElement>(null);

let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
},
});
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
return;
}
const { data: txnHash, sendTransactionAsync } = useSendTransaction({
to: grant.builder,
value: parseEther((grant.askAmount / 2).toString()),
});
const sendTx = useTransactor();

let notificationId;
try {
notificationId = notification.loading("Submitting review");
await postReviewGrant({ signer: address, signature, action });
await mutate("/api/grants/review");
notification.remove(notificationId);
notification.success(`Grant reviewed: ${action}`);
} catch (error) {
notification.error("Error reviewing grant");
} finally {
if (notificationId) notification.remove(notificationId);
}
};
const { handleReviewGrant, isLoading } = useReviewGrant(grant);

if (grant.status !== PROPOSAL_STATUS.PROPOSED && grant.status !== PROPOSAL_STATUS.SUBMITTED) return null;

const acceptStatus = grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED;
const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
return (
<div className="border p-4 my-4">
Expand All @@ -112,22 +69,38 @@ export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => {
<BuilderSocials socialLinks={grant.builderData?.socialLinks} />
</div>
<p>{grant.description}</p>
<div className="flex gap-4 mt-4 justify-end">
<div className="flex gap-4 mt-4 justify-between">
<button
className={`btn btn-sm btn-error ${isLoading ? "opacity-50" : ""}`}
onClick={() => handleReviewGrant(grant, PROPOSAL_STATUS.REJECTED)}
onClick={() => handleReviewGrant(PROPOSAL_STATUS.REJECTED)}
disabled={isLoading}
>
Reject
</button>
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={() => handleReviewGrant(grant, acceptStatus)}
disabled={isLoading}
>
{acceptLabel}
</button>
<div className="flex gap-4">
<button
className={`btn btn-sm btn-neutral ${isLoading ? "opacity-50" : ""}`}
onClick={async () => {
const resHash = await sendTx(sendTransactionAsync);
// Transactor eats the error, so we need to handle by checking resHash
if (resHash && modalRef.current) modalRef.current.showModal();
}}
disabled={isLoading}
>
Send 50%
</button>
<button
className={`btn btn-sm btn-success ${isLoading ? "opacity-50" : ""}`}
onClick={() => {
if (modalRef.current) modalRef.current.showModal();
}}
disabled={isLoading}
>
{acceptLabel}
</button>
</div>
</div>
<ActionModal ref={modalRef} grant={grant} initialTxLink={txnHash?.hash} />
</div>
);
};
67 changes: 67 additions & 0 deletions packages/nextjs/app/admin/hooks/useReviewGrant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { ProposalStatusType } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

type ReqBody = {
signer: string;
signature: `0x${string}`;
action: ProposalStatusType;
txHash: string;
};

export const useReviewGrant = (grant: GrantData) => {
const { address } = useAccount();
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { trigger: postReviewGrant, isMutating: isPostingNewGrant } = useSWRMutation(
`/api/grants/${grant.id}/review`,
postMutationFetcher<ReqBody>,
);
const { mutate } = useSWRConfig();

const isLoading = isSigningMessage || isPostingNewGrant;

const handleReviewGrant = async (action: ProposalStatusType, txnHash = "") => {
if (!address) {
notification.error("Please connect your wallet");
return;
}

let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
txHash: txnHash,
},
});
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
return;
}

let notificationId;
try {
notificationId = notification.loading("Submitting review");
await postReviewGrant({ signer: address, signature, action, txHash: txnHash });
await mutate("/api/grants/review");
notification.remove(notificationId);
notification.success(`Grant reviewed: ${action}`);
} catch (error) {
notification.error("Error reviewing grant");
} finally {
if (notificationId) notification.remove(notificationId);
}
};

return { handleReviewGrant, isLoading };
};
14 changes: 11 additions & 3 deletions packages/nextjs/app/api/grants/[grantId]/review/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { recoverTypedDataAddress } from "viem";
import { reviewGrant } from "~~/services/database/grants";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

type ReqBody = {
signer: string;
signature: `0x${string}`;
action: ProposalStatusType;
txHash: string;
};

export async function POST(req: NextRequest, { params }: { params: { grantId: string } }) {
const { grantId } = params;
const { signature, signer, action } = await req.json();
const { signature, signer, action, txHash } = (await req.json()) as ReqBody;

// Validate action is valid
const validActions = Object.values(PROPOSAL_STATUS);
Expand All @@ -24,6 +31,7 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
message: {
grantId: grantId,
action: action,
txHash,
},
signature,
});
Expand All @@ -42,7 +50,7 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
}

try {
await reviewGrant(grantId, action);
await reviewGrant(grantId, action, txHash);
} catch (error) {
console.error("Error approving grant", error);
return NextResponse.json({ error: "Error approving grant" }, { status: 500 });
Expand Down
15 changes: 13 additions & 2 deletions packages/nextjs/services/database/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,26 @@ export const getAllActiveGrants = async () => {
}
};

export const reviewGrant = async (grantId: string, action: ProposalStatusType) => {
export const reviewGrant = async (grantId: string, action: ProposalStatusType, txHash: string) => {
try {
const validActions = Object.values(PROPOSAL_STATUS);
if (!validActions.includes(action)) {
throw new Error(`Invalid action: ${action}`);
}

const updateTxHash: Record<string, string> = {};
if (action === PROPOSAL_STATUS.APPROVED) {
updateTxHash["approvedTx"] = txHash;
}
if (action === PROPOSAL_STATUS.COMPLETED) {
updateTxHash["completedTx"] = txHash;
}

const grantActionTimeStamp = new Date().getTime();
const grantActionTimeStampKey = (action + "At") as `${typeof action}At`;
await grantsCollection.doc(grantId).update({ status: action, [grantActionTimeStampKey]: grantActionTimeStamp });
await grantsCollection
.doc(grantId)
.update({ status: action, [grantActionTimeStampKey]: grantActionTimeStamp, ...updateTxHash });
} catch (error) {
console.error("Error approving the grant:", error);
throw error;
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/services/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type GrantWithoutTimestamps = {
builder: string;
link?: string;
status: "proposed" | "approved" | "submitted" | "completed" | "rejected";
approvedTx?: string;
completedTx?: string;
};

export type GrantData = Simplify<
Expand Down
5 changes: 4 additions & 1 deletion packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import scaffoldConfig from "~~/scaffold.config";

export const EIP_712_DOMAIN = {
name: "BuidlGuidl Grants",
version: "1",
chainId: 10,
chainId: scaffoldConfig.targetNetworks[0].id,
} as const;

export const EIP_712_TYPES__APPLY_FOR_GRANT = {
Expand All @@ -17,6 +19,7 @@ export const EIP_712_TYPES__REVIEW_GRANT = {
Message: [
{ name: "grantId", type: "string" },
{ name: "action", type: "string" },
{ name: "txHash", type: "string" },
],
} as const;

Expand Down

0 comments on commit 61ecfaf

Please sign in to comment.