Skip to content

Commit

Permalink
allow Safe signatures (#135)
Browse files Browse the repository at this point in the history
  • Loading branch information
technophile-04 authored Aug 14, 2024
1 parent 92f24a2 commit 09562e7
Show file tree
Hide file tree
Showing 11 changed files with 307 additions and 29 deletions.
10 changes: 9 additions & 1 deletion packages/nextjs/app/admin/_components/EditGrantModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ChangeEvent, forwardRef, useState } from "react";
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useNetwork, useSignTypedData } from "wagmi";
import { useAccount, useNetwork, usePublicClient, useSignTypedData } from "wagmi";
import { GrantDataWithPrivateNote } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__EDIT_GRANT } from "~~/utils/eip712";
import { isSafeContext } from "~~/utils/safe-signature";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
import { patchMutationFetcher } from "~~/utils/swr";

Expand All @@ -19,6 +20,8 @@ type ReqBody = {
signature?: `0x${string}`;
signer?: string;
private_note?: string;
isSafeSignature?: boolean;
chainId?: number;
};

export const EditGrantModal = forwardRef<HTMLDialogElement, EditGrantModalProps>(({ grant, closeModal }, ref) => {
Expand All @@ -31,6 +34,7 @@ export const EditGrantModal = forwardRef<HTMLDialogElement, EditGrantModalProps>

const { address } = useAccount();
const { chain: connectedChain } = useNetwork();
const publiClient = usePublicClient({ chainId: connectedChain?.id });
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();

const { trigger: editGrant, isMutating } = useSWRMutation(`/api/grants/${grant.id}`, patchMutationFetcher<ReqBody>);
Expand Down Expand Up @@ -69,11 +73,15 @@ export const EditGrantModal = forwardRef<HTMLDialogElement, EditGrantModalProps>
},
});
notificationId = notification.loading("Updating grant");

const isSafeSignature = await isSafeContext(publiClient, address);
await editGrant({
signer: address,
signature,
...formData,
askAmount: parseFloat(formData.askAmount),
isSafeSignature,
chainId: connectedChain.id,
});
await mutate("/api/grants/review");
notification.remove(notificationId);
Expand Down
8 changes: 7 additions & 1 deletion packages/nextjs/app/admin/hooks/useBatchReviewGrants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useNetwork, useSignTypedData } from "wagmi";
import { useAccount, useNetwork, usePublicClient, useSignTypedData } from "wagmi";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT_BATCH } from "~~/utils/eip712";
import { ProposalStatusType } from "~~/utils/grants";
import { isSafeContext } from "~~/utils/safe-signature";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

Expand All @@ -15,13 +16,15 @@ type BatchReqBody = {
txHash: string;
txChainId: string;
}[];
isSafeSignature?: boolean;
};

export const useBatchReviewGrants = () => {
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { mutate } = useSWRConfig();
const { address: connectedAddress } = useAccount();
const { chain: connectedChain } = useNetwork();
const publicClient = usePublicClient({ chainId: connectedChain?.id });
const { trigger: postBatchReviewGrant, isMutating: isPostingBatchReviewGrant } = useSWRMutation(
`/api/grants/review`,
postMutationFetcher<BatchReqBody>,
Expand Down Expand Up @@ -51,10 +54,13 @@ export const useBatchReviewGrants = () => {
message: message,
});

const isSafeSignature = await isSafeContext(publicClient, connectedAddress);

await postBatchReviewGrant({
signature: signature,
reviews: grantReviews,
signer: connectedAddress,
isSafeSignature: isSafeSignature,
});
await mutate("/api/grants/review");
notification.success(`Grants reviews successfully submitted!`);
Expand Down
7 changes: 6 additions & 1 deletion packages/nextjs/app/admin/hooks/useReviewGrant.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useNetwork, useSignTypedData } from "wagmi";
import { useAccount, useNetwork, usePublicClient, useSignTypedData } from "wagmi";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT, EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { isSafeContext } from "~~/utils/safe-signature";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

Expand All @@ -14,11 +15,13 @@ type ReqBody = {
txHash: string;
txChainId: string;
note?: string;
isSafeSignature?: boolean;
};

export const useReviewGrant = (grant: GrantData) => {
const { address } = useAccount();
const { chain: connectedChain } = useNetwork();
const publicClient = usePublicClient({ chainId: connectedChain?.id });
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { trigger: postReviewGrant, isMutating: isPostingNewGrant } = useSWRMutation(
`/api/grants/${grant.id}/review`,
Expand Down Expand Up @@ -71,13 +74,15 @@ export const useReviewGrant = (grant: GrantData) => {
let notificationId;
try {
notificationId = notification.loading("Submitting review");
const isSafeSignature = await isSafeContext(publicClient, address);
await postReviewGrant({
signer: address,
signature,
action,
txHash: txnHash,
txChainId: connectedChain.id.toString(),
note,
isSafeSignature,
});
await mutate("/api/grants/review");
notification.remove(notificationId);
Expand Down
18 changes: 16 additions & 2 deletions packages/nextjs/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import useSWR from "swr";
import useSWRMutation from "swr/mutation";
import { useLocalStorage } from "usehooks-ts";
import { parseEther } from "viem";
import { useAccount, useSignTypedData } from "wagmi";
import { useAccount, useNetwork, usePublicClient, useSignTypedData } from "wagmi";
import { useScaffoldContractWrite } from "~~/hooks/scaffold-eth";
import { GrantDataWithBuilder } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__ADMIN_SIGN_IN } from "~~/utils/eip712";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { isSafeContext } from "~~/utils/safe-signature";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

Expand All @@ -38,6 +39,8 @@ const fetcherWithHeader = async (url: string, headers: { address: string; apiKey
const AdminPage = () => {
const { address } = useAccount();
const [selectedApproveGrants, setSelectedApproveGrants] = useState<string[]>([]);
const { chain: connectedChain } = useNetwork();
const publicClient = usePublicClient({ chainId: connectedChain?.id });
const [selectedCompleteGrants, setSelectedCompleteGrants] = useState<string[]>([]);
const [modalBtnLabel, setModalBtnLabel] = useState<"Approve" | "Complete">("Approve");
const modalRef = useRef<HTMLDialogElement>(null);
Expand All @@ -48,6 +51,8 @@ const AdminPage = () => {
postMutationFetcher<{
signer?: string;
signature?: `0x${string}`;
isSafeSignature?: boolean;
chainId?: number;
}>,
);

Expand Down Expand Up @@ -130,7 +135,16 @@ const AdminPage = () => {
message: { action: "Sign In", description: "I authorize myself as admin" },
});

const resData = (await postAdminSignIn({ signer: address, signature })) as { data: { apiKey: string } };
const isSafeSignature = await isSafeContext(publicClient, address);

const resData = (await postAdminSignIn({
signer: address,
signature,
isSafeSignature,
chainId: connectedChain?.id,
})) as {
data: { apiKey: string };
};
setApiKey(resData.data.apiKey);
} catch (error) {
console.error("Error signing in", error);
Expand Down
24 changes: 19 additions & 5 deletions packages/nextjs/app/api/admin/signin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__ADMIN_SIGN_IN } from "~~/utils/eip712";
import { validateSafeSignature } from "~~/utils/safe-signature";

type AdminSignInBody = {
signer?: string;
signature?: `0x${string}`;
isSafeSignature?: boolean;
chainId?: number;
};
export async function POST(req: Request) {
try {
const { signer, signature } = (await req.json()) as AdminSignInBody;
const { signer, signature, isSafeSignature, chainId } = (await req.json()) as AdminSignInBody;

if (!signer || !signature) {
return new Response("Missing signer or signature", { status: 400 });
Expand All @@ -21,15 +24,26 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const recoveredAddress = await recoverTypedDataAddress({
let isValidSignature = false;

const typedData = {
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__ADMIN_SIGN_IN,
primaryType: "Message",
message: { action: "Sign In", description: "I authorize myself as admin" },
signature,
});
if (recoveredAddress !== signer) {
console.error("Signer and Recovered address does not match", recoveredAddress, signer);
} as const;

if (isSafeSignature) {
if (!chainId) return new Response("Missing chainId", { status: 400 });
isValidSignature = await validateSafeSignature({ chainId, typedData, safeAddress: signer, signature });
} else {
const recoveredAddress = await recoverTypedDataAddress(typedData);
isValidSignature = recoveredAddress === signer;
}

if (!isValidSignature) {
console.error("Signer and Recovered address does not match");
return NextResponse.json({ error: "Unauthorized in batch" }, { status: 401 });
}

Expand Down
41 changes: 33 additions & 8 deletions packages/nextjs/app/api/grants/[grantId]/review/route.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { EIP712TypedData } from "@safe-global/safe-core-sdk-types";
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, EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { validateSafeSignature } from "~~/utils/safe-signature";

type ReqBody = {
signer: string;
Expand All @@ -12,11 +14,12 @@ type ReqBody = {
txHash: string;
txChainId: string;
note?: string;
isSafeSignature?: boolean;
};

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

// Validate action is valid
const validActions = Object.values(PROPOSAL_STATUS);
Expand All @@ -25,11 +28,11 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}

let recoveredAddress: string;
let isValidSignature: boolean;

// If action is approved or rejected, include note in signature
if (action === PROPOSAL_STATUS.APPROVED || action === PROPOSAL_STATUS.REJECTED) {
recoveredAddress = await recoverTypedDataAddress({
const typedData = {
domain: { ...EIP_712_DOMAIN, chainId: Number(txChainId) },
types: EIP_712_TYPES__REVIEW_GRANT_WITH_NOTE,
primaryType: "Message",
Expand All @@ -41,10 +44,21 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
note: note ?? "",
},
signature,
});
} as const;
if (isSafeSignature) {
isValidSignature = await validateSafeSignature({
chainId: Number(txChainId),
typedData: typedData as unknown as EIP712TypedData,
signature,
safeAddress: signer,
});
} else {
const recoveredAddress = await recoverTypedDataAddress(typedData);
isValidSignature = recoveredAddress === signer;
}
} else {
// Validate Signature
recoveredAddress = await recoverTypedDataAddress({
const typedData = {
domain: { ...EIP_712_DOMAIN, chainId: Number(txChainId) },
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
Expand All @@ -55,11 +69,22 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st
txChainId,
},
signature,
});
} as const;
if (isSafeSignature) {
isValidSignature = await validateSafeSignature({
chainId: Number(txChainId),
typedData: typedData as unknown as EIP712TypedData,
signature,
safeAddress: signer,
});
} else {
const recoveredAddress = await recoverTypedDataAddress(typedData);
isValidSignature = recoveredAddress === signer;
}
}

if (recoveredAddress !== signer) {
console.error("Signature error", recoveredAddress, signer);
if (!isValidSignature) {
console.error("Invalid signature", signer);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

Expand Down
29 changes: 25 additions & 4 deletions packages/nextjs/app/api/grants/[grantId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { EIP712TypedData } from "@safe-global/safe-core-sdk-types";
import { recoverTypedDataAddress } from "viem";
import { updateGrant } from "~~/services/database/grants";
import { findUserByAddress } from "~~/services/database/users";
import { EIP_712_DOMAIN, EIP_712_TYPES__EDIT_GRANT } from "~~/utils/eip712";
import { validateSafeSignature } from "~~/utils/safe-signature";

type ReqBody = {
title?: string;
Expand All @@ -11,25 +13,44 @@ type ReqBody = {
signature?: `0x${string}`;
signer?: string;
private_note?: string;
isSafeSignature?: boolean;
chainId?: number;
};

export async function PATCH(req: NextRequest, { params }: { params: { grantId: string } }) {
try {
const { grantId } = params;
const { title, description, signature, signer, askAmount, private_note } = (await req.json()) as ReqBody;
const { title, description, signature, signer, askAmount, private_note, isSafeSignature, chainId } =
(await req.json()) as ReqBody;

if (!title || !description || !askAmount || typeof askAmount !== "number" || !signature || !signer) {
return NextResponse.json({ error: "Invalid form details submited" }, { status: 400 });
}

const recoveredAddress = await recoverTypedDataAddress({
let isValidSignature: boolean;

const typedData = {
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__EDIT_GRANT,
primaryType: "Message",
message: { title, description, askAmount: askAmount.toString(), grantId, private_note: private_note ?? "" },
signature: signature,
});
if (recoveredAddress !== signer) {
} as const;

if (isSafeSignature) {
if (!chainId) return new Response("Missing chainId", { status: 400 });
isValidSignature = await validateSafeSignature({
chainId: Number(chainId),
typedData: typedData as unknown as EIP712TypedData,
safeAddress: signer,
signature,
});
} else {
const recoveredAddress = await recoverTypedDataAddress(typedData);
isValidSignature = recoveredAddress === signer;
}

if (!isValidSignature) {
return NextResponse.json({ error: "Recovered address did not match signer" }, { status: 401 });
}

Expand Down
Loading

0 comments on commit 09562e7

Please sign in to comment.