diff --git a/packages/shared/src/types/bonusSubmissions.ts b/packages/shared/src/types/bonusSubmissions.ts new file mode 100644 index 000000000..629dd5066 --- /dev/null +++ b/packages/shared/src/types/bonusSubmissions.ts @@ -0,0 +1,7 @@ +export interface IClaimedIssue { + vaultAddress: string; + issueNumber: string; + claimedBy: string; + claimedAt: Date; + expiresAt: Date; +} diff --git a/packages/shared/src/types/editor.ts b/packages/shared/src/types/editor.ts index 052ee0a4e..ce2d0bdc1 100644 --- a/packages/shared/src/types/editor.ts +++ b/packages/shared/src/types/editor.ts @@ -80,6 +80,7 @@ export interface IBaseEditedVaultDescription { isPrivateAudit?: boolean; isContinuousAudit?: boolean; requireMessageSignature?: boolean; + bonusPointsEnabled?: boolean; messageToSign?: string; whitelist: { address: string }[]; endtime?: number; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1fe858e3f..07f061ed5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -4,3 +4,4 @@ export * from "./payout"; export * from "./safe"; export * from "./submissions"; export * from "./profile"; +export * from "./bonusSubmissions"; diff --git a/packages/shared/src/types/payout.ts b/packages/shared/src/types/payout.ts index ac9407c7a..8a9335b84 100644 --- a/packages/shared/src/types/payout.ts +++ b/packages/shared/src/types/payout.ts @@ -49,6 +49,10 @@ export type GithubIssue = { createdBy: number; labels: string[]; validLabels: string[]; + bonusPointsLabels: { + needsFix: boolean; + needsTest: boolean; + }; createdAt: string; body: string; txHash?: string; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index f216c5355..73b437641 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -141,6 +141,7 @@ interface IBaseVaultDescription { isPrivateAudit?: boolean; isContinuousAudit?: boolean; requireMessageSignature?: boolean; + bonusPointsEnabled?: boolean; messageToSign?: string; whitelist: { address: string }[]; endtime?: number; diff --git a/packages/web/src/components/Button/styles.ts b/packages/web/src/components/Button/styles.ts index b6b109947..fb2e4a3f0 100644 --- a/packages/web/src/components/Button/styles.ts +++ b/packages/web/src/components/Button/styles.ts @@ -36,6 +36,8 @@ const getVariableByTextColor = (textColor: ButtonProps["textColor"], styleType: return "--primary"; case "error": return "--error-red"; + case "white": + return "--white"; default: if (styleType === "invisible") return "--secondary"; return "--white"; diff --git a/packages/web/src/components/FormControls/FormSupportFilesInput/FormSupportFilesInput.tsx b/packages/web/src/components/FormControls/FormSupportFilesInput/FormSupportFilesInput.tsx index 0edb63e18..11038b700 100644 --- a/packages/web/src/components/FormControls/FormSupportFilesInput/FormSupportFilesInput.tsx +++ b/packages/web/src/components/FormControls/FormSupportFilesInput/FormSupportFilesInput.tsx @@ -20,10 +20,22 @@ type FormSupportFilesInputProps = { name: string; onChange: (data: ISavedFile[]) => void; error?: { message?: string; type: string }; + uploadTo?: "db" | "ipfs"; + noFilesAttachedInfo?: boolean; }; export const FormSupportFilesInputComponent = ( - { colorable = false, isDirty = false, name, onChange, label, error, value }: FormSupportFilesInputProps, + { + colorable = false, + isDirty = false, + name, + onChange, + label, + error, + value, + uploadTo = "db", + noFilesAttachedInfo, + }: FormSupportFilesInputProps, ref ) => { const { t } = useTranslation(); @@ -50,7 +62,7 @@ export const FormSupportFilesInputComponent = ( return alert(t("invalid-file-type")); } - filesToUploadPromises.push(FilesService.uploadFileToDB(file)); + filesToUploadPromises.push(FilesService.uploadFileToDB(file, uploadTo === "ipfs")); } const uploadedFiles = await Promise.all(filesToUploadPromises); @@ -76,17 +88,19 @@ export const FormSupportFilesInputComponent = (

{isUploadingFiles ? `${t("uploadingFiles")}...` : label ?? ""}

-
-

{t("filesAttached")}:

- -
+ {!noFilesAttachedInfo && ( +
+

{t("filesAttached")}:

+ +
+ )} {error && {error.message}} diff --git a/packages/web/src/components/FormControls/FormSupportFilesInput/supportedExtensions.ts b/packages/web/src/components/FormControls/FormSupportFilesInput/supportedExtensions.ts index 6b3206042..063113c31 100644 --- a/packages/web/src/components/FormControls/FormSupportFilesInput/supportedExtensions.ts +++ b/packages/web/src/components/FormControls/FormSupportFilesInput/supportedExtensions.ts @@ -1 +1 @@ -export const supportedExtensions = ["txt", "sol", "ts", "js"]; +export const supportedExtensions = ["txt", "sol", "ts", "js", "md", "json"]; diff --git a/packages/web/src/components/VaultCard/VaultCard.tsx b/packages/web/src/components/VaultCard/VaultCard.tsx index 926668d38..1e4e7afe7 100644 --- a/packages/web/src/components/VaultCard/VaultCard.tsx +++ b/packages/web/src/components/VaultCard/VaultCard.tsx @@ -159,6 +159,8 @@ export const VaultCard = ({ ONE_LINER_FALLBACK[vault.id] ?? "Nulla facilisi. Donec nec dictum eros. Cras et velit viverra, dapibus velit fringilla, bibendum mi aptent. Class aptent taciti sociosqu ad litora."; + const bonusPointsEnabled = vault.description?.["project-metadata"]?.bonusPointsEnabled; + const getAuditStatusPill = () => { if (!vault.description) return null; if (!vault.description["project-metadata"].endtime) return null; @@ -470,9 +472,10 @@ export const VaultCard = ({ {!reducedStyles && (
- {auditPayout ? t("paidAssets") : t("assetsInVault")} - - +
+ {auditPayout ? t("paidAssets") : t("assetsInVault")} + +
@@ -487,6 +490,17 @@ export const VaultCard = ({ {t("deposits")} )} + {isAudit && vault.dateStatus === "on_time" && !auditPayout && !hideSubmit && bonusPointsEnabled && ( + + )} {(!isAudit || (isAudit && vault.dateStatus === "on_time" && !auditPayout)) && !hideSubmit && (
diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultRewardsSection/styles.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultRewardsSection/styles.ts index b052d86f3..5dd2cf8b1 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultRewardsSection/styles.ts +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultRewardsSection/styles.ts @@ -67,6 +67,40 @@ export const StyledRewardsSection = styled.div<{ showIntended: boolean; isAudit: grid-column-start: 1; grid-column-end: 3; } + + .bonus-points-info-container { + border-top: 1px solid var(--primary-light); + width: 100%; + display: flex; + flex-direction: column; + gap: ${getSpacing(1)}; + padding-top: ${getSpacing(3)}; + + p { + font-weight: 700; + } + + .points { + display: flex; + gap: ${getSpacing(2)}; + + .point { + display: flex; + align-items: center; + gap: ${getSpacing(1)}; + + .secondary-text { + color: var(--secondary); + font-weight: 700; + } + + .primary-text { + color: var(--primary); + font-weight: 700; + } + } + } + } } .card { diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/PublicSubmissionCard.tsx b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/PublicSubmissionCard.tsx index 8d60f48dc..e1972df24 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/PublicSubmissionCard.tsx +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/PublicSubmissionCard.tsx @@ -1,16 +1,15 @@ -import { IVault, IVulnerabilitySeverity, allowedElementsMarkdown } from "@hats.finance/shared"; +import { GithubIssue, IVault, allowedElementsMarkdown, parseSeverityName } from "@hats.finance/shared"; import MDEditor from "@uiw/react-md-editor"; import { Pill } from "components"; -import { getSeveritiesColorsArray } from "hooks/severities/useSeverityRewardInfo"; import moment from "moment"; -import { IGithubIssue } from "pages/Honeypots/VaultDetailsPage/types"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { SplitPointsActions } from "./components/SplitPointsActions"; import { StyledPublicSubmissionCard } from "./styles"; type PublicSubmissionCardProps = { vault: IVault; - submission: IGithubIssue; + submission: GithubIssue; }; function PublicSubmissionCard({ vault, submission }: PublicSubmissionCardProps) { @@ -18,28 +17,41 @@ function PublicSubmissionCard({ vault, submission }: PublicSubmissionCardProps) const [isOpen, setIsOpen] = useState(false); - const severityColors = getSeveritiesColorsArray(vault); - const severityIndex = - submission.severity && - vault?.description?.severities.findIndex((sev: IVulnerabilitySeverity) => - sev.name.toLowerCase().includes(submission.severity ?? "") - ); + const showExtraInfo = submission.number !== -1; + const bonusPointsEnabled = vault.description?.["project-metadata"]?.bonusPointsEnabled; return ( -
setIsOpen((prev) => !prev)}> -
- +
+
setIsOpen((prev) => !prev)}> + {submission && submission?.validLabels.length > 0 && ( +
+ {t("labeledAs")}: + {submission.validLabels.map((label) => ( +
+ +
+ ))} +
+ )} +

{moment(submission.createdAt).format("Do MMM YYYY - hh:mma")}

+

+ {showExtraInfo ? Issue #{submission.number}: : ""} {submission.title} +

-

{moment(submission.createdAt).format("Do MMM YYYY - hh:mma")}

-

{submission.issueData.issueTitle}

+ + {showExtraInfo && + bonusPointsEnabled && + (submission.bonusPointsLabels.needsFix || submission.bonusPointsLabels.needsTest) && ( + + )}
diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/claimIssuesService.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/claimIssuesService.ts new file mode 100644 index 000000000..9c2e32d79 --- /dev/null +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/claimIssuesService.ts @@ -0,0 +1,35 @@ +import { IClaimedIssue, IVault } from "@hats.finance/shared"; +import { AxiosError } from "axios"; +import { axiosClient } from "config/axiosClient"; +import { BASE_SERVICE_URL } from "settings"; + +/** + * Claims an issue for a vault + */ +export async function claimIssue(vault: IVault, issueNumber: number): Promise { + try { + const response = await axiosClient.post(`${BASE_SERVICE_URL}/submission-bonus-points/claim/${vault.id}`, { + issueNumber, + }); + return response.data.claimedIssue; + } catch (error) { + console.log(error); + throw ((error as AxiosError).response?.data as any)?.error; + } +} + +/** + * Gets claimed issues for a vault + */ +export async function getClaimedIssuesByVault(vault: IVault): Promise { + const response = await axiosClient.get(`${BASE_SERVICE_URL}/submission-bonus-points/claim/${vault.id}`); + return response.data.claimedIssues ?? []; +} + +/** + * Gets claimed issues for a vault and a claimed by + */ +export async function getClaimedIssuesByVaultAndClaimedBy(vault: IVault, claimedBy: string): Promise { + const response = await axiosClient.get(`${BASE_SERVICE_URL}/submission-bonus-points/claim/${vault.id}/${claimedBy}`); + return response.data.claimedIssues ?? []; +} diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/SplitPointsActions.tsx b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/SplitPointsActions.tsx new file mode 100644 index 000000000..6a4ac265b --- /dev/null +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/SplitPointsActions.tsx @@ -0,0 +1,173 @@ +import { GithubIssue, IClaimedIssue } from "@hats.finance/shared"; +import { IVault } from "@hats.finance/shared"; +import UploadIcon from "@mui/icons-material/FileUploadOutlined"; +import FlagIcon from "@mui/icons-material/OutlinedFlagOutlined"; +import { Button, HackerProfileImage, Loading, Pill, WithTooltip } from "components"; +import { useSiweAuth } from "hooks/siwe/useSiweAuth"; +import useConfirm from "hooks/useConfirm"; +import moment from "moment"; +import { useProfileByAddress } from "pages/HackerProfile/hooks"; +import { useAllTimeLeaderboard } from "pages/Leaderboard/LeaderboardPage/components/AllTimeLeaderboard/useAllTimeLeaderboard"; +import { useTranslation } from "react-i18next"; +import { IS_PROD, appChains } from "settings"; +import { useAccount, useNetwork } from "wagmi"; +import { useClaimIssue, useClaimedIssuesByVault } from "../hooks"; +import { StyledSplitPointsActions } from "./styles"; + +export const getClaimedBy = (claimedIssue: IClaimedIssue | undefined) => { + if (!claimedIssue) return undefined; + + const isExpired = moment(claimedIssue.expiresAt).isBefore(moment()); + if (isExpired) return undefined; + + return claimedIssue; +}; + +type SplitPointsActionsProps = { + vault: IVault; + submission: GithubIssue; +}; + +export const SplitPointsActions = ({ vault, submission }: SplitPointsActionsProps) => { + const { t } = useTranslation(); + const { address } = useAccount(); + const { chain } = useNetwork(); + const { tryAuthentication } = useSiweAuth(); + const confirm = useConfirm(); + + const { leaderboard } = useAllTimeLeaderboard("all", "streak"); + const { data: profile } = useProfileByAddress(address); + + const connectedChain = chain ? appChains[chain.id] : null; + const isTestnet = !IS_PROD && connectedChain?.chain.testnet; + const TOP_LEADERBOARD_PERCENTAGE = 0.2; + const TOP_LEADERBOARD_MIN_REWARDS = 5000; + + const isConnected = !!address; + const isProfileCreated = !!profile; + + const { + data: claimedIssues, + isLoading: isLoadingClaimedIssues, + refetch: refetchClaimedIssues, + } = useClaimedIssuesByVault(vault); + const claimIssue = useClaimIssue(); + + const leaderboardInBoundaries = leaderboard + .slice(0, Math.floor(leaderboard.length * TOP_LEADERBOARD_PERCENTAGE)) + .filter((entry) => entry.totalAmount.usd >= TOP_LEADERBOARD_MIN_REWARDS); + const isInLeadearboardBoundaries = isTestnet + ? true + : leaderboardInBoundaries.find((entry) => entry.address.toLowerCase() === address?.toLowerCase()); + + const claimInfo = claimedIssues?.find((issue) => +issue.issueNumber === +submission.number); + const claimedByInfo = getClaimedBy(claimInfo); + const { data: claimedByProfile } = useProfileByAddress(claimedByInfo?.claimedBy); + + const isClaimedByCurrentUser = claimedByInfo?.claimedBy.toLowerCase() === address?.toLowerCase(); + + const canExecuteAction = () => { + if (isClaimedByCurrentUser) return { can: true }; + if (claimedByInfo) return { can: false, reason: t("issueAlreadyClaimed") }; + + if (!isConnected) return { can: false, reason: t("youNeedToConnectYourWallet") }; + if (!isInLeadearboardBoundaries || !isProfileCreated) return { can: false, reason: t("youAreNotInTopLeaderboardPercentage") }; + return { can: true }; + }; + + const getActionButtonContent = () => { + if (isClaimedByCurrentUser) + return ( + <> + + Submit Fix & Test + + ); + if (submission.bonusPointsLabels.needsFix && submission.bonusPointsLabels.needsTest) + return ( + <> + + Claim Fix & Test + + ); + if (submission.bonusPointsLabels.needsFix) + return ( + <> + + Claim Fix + + ); + + if (submission.bonusPointsLabels.needsTest) + return ( + <> + + Claim Test + + ); + return ""; + }; + + const getClaimedByInfo = () => { + return ( +
+

Issue claimed by:

+
+
+ + {claimedByProfile?.username} +
+ +
+
+ ); + }; + + const handleClaimIssue = async () => { + if (!canExecuteAction().can) return; + + const wantsToClaim = await confirm({ + title: t("claimIssue"), + titleIcon: , + description: t("claimIssueDescription"), + cancelText: t("no"), + confirmText: t("yes"), + }); + + if (!wantsToClaim) return; + + const signedIn = await tryAuthentication(); + if (!signedIn) return; + + await claimIssue.mutateAsync({ vault, issueNumber: submission.number }); + refetchClaimedIssues(); + }; + + if (isLoadingClaimedIssues) return null; + + return ( + + +
+ +
+
+ + {claimedByInfo &&
{getClaimedByInfo()}
} + + {claimIssue.isLoading && } +
+ ); +}; diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/styles.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/styles.ts new file mode 100644 index 000000000..365403535 --- /dev/null +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/components/styles.ts @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import { getSpacing } from "styles"; + +export const StyledSplitPointsActions = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row-reverse; + margin-top: ${getSpacing(1)}; + + .claimed-by-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${getSpacing(1)}; + + .claimed-by-info, + .profile-container { + display: flex; + align-items: center; + gap: 0.5rem; + } + } +`; diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/hooks.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/hooks.ts new file mode 100644 index 000000000..fb4b45747 --- /dev/null +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/hooks.ts @@ -0,0 +1,40 @@ +import { IClaimedIssue, IVault } from "@hats.finance/shared"; +import { useMutation } from "@tanstack/react-query"; +import { UseMutationResult, useQuery } from "@tanstack/react-query"; +import { claimIssue, getClaimedIssuesByVault, getClaimedIssuesByVaultAndClaimedBy } from "./claimIssuesService"; + +/** + * Gets claimed issues for a vault + */ +export const useClaimedIssuesByVault = (vault?: IVault) => { + return useQuery({ + queryKey: ["claimed-issues-by-vault", vault?.id, vault?.chainId], + queryFn: () => getClaimedIssuesByVault(vault!), + enabled: !!vault, + }); +}; + +/** + * Gets claimed issues for a vault and a claimed by + */ +export const useClaimedIssuesByVaultAndClaimedBy = (vault?: IVault, claimedBy?: string) => { + return useQuery({ + queryKey: ["claimed-issues-by-vault-and-claimed-by", vault?.id, vault?.chainId, claimedBy], + queryFn: () => getClaimedIssuesByVaultAndClaimedBy(vault!, claimedBy!), + enabled: !!vault && !!claimedBy, + }); +}; + +/** + * Upserts a profile + */ +export const useClaimIssue = (): UseMutationResult< + IClaimedIssue | undefined, + string, + { vault: IVault; issueNumber: number }, + unknown +> => { + return useMutation({ + mutationFn: ({ vault, issueNumber }) => claimIssue(vault, issueNumber), + }); +}; diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/styles.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/styles.ts index 965585cfe..c7b70effa 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/styles.ts +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/styles.ts @@ -20,6 +20,12 @@ export const StyledPublicSubmissionCard = styled.div<{ isOpen: boolean }>( border-color: var(--primary); } + .labels { + display: flex; + gap: ${getSpacing(1)}; + align-items: center; + } + .date { position: absolute; top: ${getSpacing(3)}; @@ -28,8 +34,13 @@ export const StyledPublicSubmissionCard = styled.div<{ isOpen: boolean }>( .submission-title { font-size: var(--small); - padding-left: ${getSpacing(1)}; - font-weight: 700; + font-weight: 400; + margin-top: ${getSpacing(1.5)}; + + span { + font-weight: 700; + color: var(--secondary-light); + } } } diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/VaultSubmissionsSection.tsx b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/VaultSubmissionsSection.tsx index 0b2d51a53..814c6b7ec 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/VaultSubmissionsSection.tsx +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/VaultSubmissionsSection.tsx @@ -1,5 +1,7 @@ import { IPayoutGraph, IVault } from "@hats.finance/shared"; +import ClockIcon from "@mui/icons-material/AccessTimeOutlined"; import OpenIcon from "@mui/icons-material/OpenInNewOutlined"; +import VerifiedIcon from "@mui/icons-material/VerifiedOutlined"; import { Alert, Button, HatSpinner } from "components"; import useConfirm from "hooks/useConfirm"; import { useTranslation } from "react-i18next"; @@ -19,7 +21,9 @@ export const VaultSubmissionsSection = ({ vault }: VaultSubmissionsSectionProps) const { data: savedSubmissions, isLoading } = useSavedSubmissions(vault); const { data: repoName } = useVaultRepoName(vault); + const isPrivateAudit = vault?.description?.["project-metadata"].isPrivateAudit; + const bonusPointsEnabled = vault.description?.["project-metadata"]?.bonusPointsEnabled; const goToGithubIssues = async () => { if (!repoName) return; @@ -38,6 +42,28 @@ export const VaultSubmissionsSection = ({ vault }: VaultSubmissionsSectionProps) window.open(githubLink, "_blank"); }; + const getBonusPointsSection = () => { + if (isPrivateAudit) return null; + if (savedSubmissions?.length === 0) return null; + + return ( +
+
+
+ +
+

{t("bonusPointsTitle")}

+
+ +
+
+ +

{t("bonusPointsReminder")}

+
+
+ ); + }; + return ( {isPrivateAudit && ( @@ -45,6 +71,7 @@ export const VaultSubmissionsSection = ({ vault }: VaultSubmissionsSectionProps) {t("privateAuditSubmissionsOnlyOnGithub")} )} + {bonusPointsEnabled && getBonusPointsSection()}

{t("submissions")} @@ -66,7 +93,7 @@ export const VaultSubmissionsSection = ({ vault }: VaultSubmissionsSectionProps) {!isPrivateAudit && savedSubmissions && savedSubmissions?.length > 0 && (
{savedSubmissions.map((submission) => ( - + ))}
)} diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/styles.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/styles.ts index 08171b6ac..13e1e67b3 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/styles.ts +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/styles.ts @@ -10,4 +10,44 @@ export const StyledSubmissionsSection = styled.div` flex-direction: column; gap: ${getSpacing(3)}; } + + .bonus-points-section { + border: 2px solid var(--primary); + padding: ${getSpacing(3)} ${getSpacing(4)}; + margin-bottom: ${getSpacing(4)}; + + .title-container { + display: flex; + align-items: center; + gap: ${getSpacing(1)}; + margin-bottom: ${getSpacing(2)}; + font-size: var(--moderate-big); + font-weight: 600; + + .icon { + display: flex; + align-items: center; + justify-content: center; + width: ${getSpacing(4.5)}; + height: ${getSpacing(4.5)}; + padding: ${getSpacing(1)}; + border-radius: 50%; + background-color: var(--primary); + } + } + + .content { + ul { + line-height: 1.5; + padding-left: ${getSpacing(3)}; + } + } + + .remember { + display: flex; + align-items: center; + margin-top: ${getSpacing(2)}; + gap: ${getSpacing(1)}; + } + } `; diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/hooks.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/hooks.ts index f6c543acb..386176bae 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/hooks.ts +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/hooks.ts @@ -1,18 +1,18 @@ -import { IVault } from "@hats.finance/shared"; +import { GithubIssue, IVault } from "@hats.finance/shared"; import { UseMutationResult, UseQueryResult, useQuery } from "@tanstack/react-query"; import { AxiosError } from "axios"; +import { getGithubIssuesFromVault } from "pages/CommitteeTools/SubmissionsTool/submissionsService.api"; import { useAccount, useMutation } from "wagmi"; import * as messageSignaturesService from "./messageSignaturesService"; import * as savedSubmissionsService from "./savedSubmissionsService"; -import { IGithubIssue } from "./types"; /** * Returns all saved submissions for a vault */ -export const useSavedSubmissions = (vault: IVault | undefined): UseQueryResult => { +export const useSavedSubmissions = (vault: IVault | undefined): UseQueryResult => { return useQuery({ queryKey: ["github-issues", vault?.id], - queryFn: () => savedSubmissionsService.getSavedSubmissions(vault?.id), + queryFn: () => getGithubIssuesFromVault(vault!), refetchOnWindowFocus: false, enabled: !!vault, }); diff --git a/packages/web/src/pages/Honeypots/VaultDetailsPage/savedSubmissionsService.ts b/packages/web/src/pages/Honeypots/VaultDetailsPage/savedSubmissionsService.ts index e32f47b00..00a085451 100644 --- a/packages/web/src/pages/Honeypots/VaultDetailsPage/savedSubmissionsService.ts +++ b/packages/web/src/pages/Honeypots/VaultDetailsPage/savedSubmissionsService.ts @@ -5,7 +5,7 @@ import { IGithubIssue } from "./types"; /** * Get all saved github issues for a vault */ -export async function getSavedSubmissions(vaultId: string | undefined): Promise { +export async function getGHSubmissions(vaultId: string | undefined): Promise { if (!vaultId) return []; try { diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/SubmissionDescriptions.tsx b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/SubmissionDescriptions.tsx index f9e8735ba..71a1d680e 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/SubmissionDescriptions.tsx +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/SubmissionDescriptions.tsx @@ -1,7 +1,9 @@ -import { ISubmissionMessageObject, IVulnerabilitySeverity } from "@hats.finance/shared"; +import { GithubIssue, ISubmissionMessageObject, IVulnerabilitySeverity } from "@hats.finance/shared"; import { yupResolver } from "@hookform/resolvers/yup"; import AddIcon from "@mui/icons-material/AddOutlined"; +import CloseIcon from "@mui/icons-material/CloseOutlined"; import RemoveIcon from "@mui/icons-material/DeleteOutlined"; +import FlagIcon from "@mui/icons-material/OutlinedFlagOutlined"; import { Alert, Button, @@ -10,13 +12,25 @@ import { FormSelectInput, FormSelectInputOption, FormSupportFilesInput, + Loading, + Pill, WithTooltip, } from "components"; import download from "downloadjs"; import { getCustomIsDirty, useEnhancedForm } from "hooks/form"; +import moment from "moment"; +import { getGithubIssuesFromVault } from "pages/CommitteeTools/SubmissionsTool/submissionsService.api"; +import { useProfileByAddress } from "pages/HackerProfile/hooks"; +import { useClaimedIssuesByVaultAndClaimedBy } from "pages/Honeypots/VaultDetailsPage/Sections/VaultSubmissionsSection/PublicSubmissionCard/hooks"; +import { getVaultRepoName } from "pages/Honeypots/VaultDetailsPage/savedSubmissionsService"; +import { HoneypotsRoutePaths } from "pages/Honeypots/router"; import { useContext, useEffect, useState } from "react"; import { Controller, useFieldArray, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { searchFileInHatsRepo } from "utils/github.utils"; +import { slugify } from "utils/slug.utils"; +import { useAccount } from "wagmi"; import { encryptWithHatsKey, encryptWithKeys } from "../../encrypt"; import { SUBMISSION_INIT_DATA, SubmissionFormContext } from "../../store"; import { ISubmissionsDescriptionsData } from "../../types"; @@ -26,14 +40,33 @@ import { getAuditSubmissionTexts, getBountySubmissionTexts } from "./utils"; export function SubmissionDescriptions() { const { t } = useTranslation(); + const { address } = useAccount(); + const navigate = useNavigate(); + + const { data: hackerProfile } = useProfileByAddress(address); const { submissionData, setSubmissionData, vault, allFormDisabled } = useContext(SubmissionFormContext); const [severitiesOptions, setSeveritiesOptions] = useState(); const isAuditSubmission = vault?.description?.["project-metadata"].type === "audit"; const isPrivateAudit = vault?.description?.["project-metadata"].isPrivateAudit; + const bonusPointsEnabled = vault?.description?.["project-metadata"].bonusPointsEnabled; + + const { data: claimedIssues, isLoading: isLoadingClaimedIssues } = useClaimedIssuesByVaultAndClaimedBy(vault, address); + + const [vaultGithubIssuesOpts, setVaultGithubIssuesOpts] = useState(); + const [vaultGithubIssues, setVaultGithubIssues] = useState(undefined); + const [isLoadingGH, setIsLoadingGH] = useState(false); - const { register, handleSubmit, control, reset, setValue } = useEnhancedForm({ + const { + register, + handleSubmit, + control, + reset, + setValue, + getValues, + formState: { errors }, + } = useEnhancedForm({ resolver: yupResolver(getCreateDescriptionSchema(t)), mode: "onChange", }); @@ -42,7 +75,6 @@ export function SubmissionDescriptions() { append: appendSubmissionDescription, remove: removeSubmissionDescription, } = useFieldArray({ control, name: `descriptions` }); - const watchDescriptions = useWatch({ control, name: `descriptions` }); const controlledDescriptions = fields.map((field, index) => { return { @@ -75,6 +107,11 @@ export function SubmissionDescriptions() { if (!vault || !vault.description || !vault.description.severities) return; for (const [idx, description] of controlledDescriptions.entries()) { + if (description.type === "complement") { + if (description.isEncrypted === true) setValue(`descriptions.${idx}.isEncrypted`, false); + continue; + } + const severitySelected = vault.description?.severities && (vault.description.severities as IVulnerabilitySeverity[]).find((sev) => sev.name.toLowerCase() === description.severity); @@ -97,6 +134,38 @@ export function SubmissionDescriptions() { } }, [controlledDescriptions, vault, setValue, isAuditSubmission]); + // Get information from github + const someComplementSubmission = controlledDescriptions.some((desc) => desc.type === "complement"); + useEffect(() => { + if (!someComplementSubmission) return; + if (!vault) return; + if (!claimedIssues) return; + if (vaultGithubIssues !== undefined || isLoadingGH) return; + const loadGhIssues = async () => { + setIsLoadingGH(true); + const ghIssues = await getGithubIssuesFromVault(vault); + const ghIssuesOpts = ghIssues + .filter((ghIssue) => + claimedIssues?.some((ci) => +ci.issueNumber === +ghIssue.number && !moment(ci.expiresAt).isBefore(moment())) + ) + .filter((ghIssue) => ghIssue.bonusPointsLabels.needsFix || ghIssue.bonusPointsLabels.needsTest) + .map((ghIssue) => ({ + label: `[#${ghIssue.number}] ${ghIssue.title}`, + value: `${ghIssue.number}`, + })); + + setVaultGithubIssuesOpts(ghIssuesOpts); + setVaultGithubIssues(ghIssues); + setIsLoadingGH(false); + }; + loadGhIssues(); + }, [vault, vaultGithubIssues, isLoadingGH, someComplementSubmission, claimedIssues, address]); + + useEffect(() => { + setVaultGithubIssuesOpts(undefined); + setVaultGithubIssues(undefined); + }, [address]); + const handleSaveAndDownloadDescription = async (formData: ISubmissionsDescriptionsData) => { if (!vault) return; if (!submissionData) return alert("Please fill previous steps first."); @@ -118,7 +187,7 @@ export function SubmissionDescriptions() { if (keyOrKeys.length === 0) return alert("This project has no keys to encrypt the description. Please contact HATS team."); const getSubmissionTextsFunction = isAuditSubmission ? getAuditSubmissionTexts : getBountySubmissionTexts; - const submissionTexts = getSubmissionTextsFunction(submissionData, formData.descriptions); + const submissionTexts = getSubmissionTextsFunction(submissionData, formData.descriptions, hackerProfile); const toEncrypt = submissionTexts.toEncrypt; const decrypted = submissionTexts.decrypted; @@ -165,81 +234,405 @@ export function SubmissionDescriptions() { if (!vault) return {t("Submissions.firstYouNeedToSelectAProject")}; - return ( - - {controlledDescriptions.map((submissionDescription, index) => { - return ( - -

- - {t("issue")} #{index + 1} - - - - {submissionDescription.isEncrypted - ? t("Submissions.encryptedSubmission") - : t("Submissions.decryptedSubmission")} - - -

-

{t("Submissions.provideExplanation")}

+ const getSubmissionsRoute = () => { + const mainRoute = `/${isAuditSubmission ? HoneypotsRoutePaths.audits : HoneypotsRoutePaths.bugBounties}`; + const vaultSlug = slugify(vault.description?.["project-metadata"].name ?? ""); -
- { + return ( + <> +

{t("Submissions.provideExplanation")}

+
+ + ( + (field.name, dirtyFields, defaultValues)} + error={error} + label={t("severity")} + placeholder={t("severityPlaceholder")} colorable + options={severitiesOptions ?? []} + {...field} /> - ( - (field.name, dirtyFields, defaultValues)} - error={error} - label={t("severity")} - placeholder={t("severityPlaceholder")} - colorable - options={severitiesOptions ?? []} - {...field} - /> - )} - /> + )} + /> +
+ + ( + (field.name, dirtyFields, defaultValues)} + error={error} + colorable + {...field} + /> + )} + /> + + {/* {!submissionDescription.isEncrypted && !allFormDisabled && ( + ( + + )} + /> + )} */} + +

Does this issue needs a fix?

+
+
setValue(`descriptions.${index}.isTestApplicable`, true, { shouldValidate: true })} + > +
+
+

{t("Submissions.pocIsApplicable")}

+
+
+ +
setValue(`descriptions.${index}.isTestApplicable`, false, { shouldValidate: true })} + > +
+
+

{t("Submissions.pocIsNotApplicable")}

+
+
+ + ); + }; + + const getComplementIssueForm = (submissionDescription: (typeof controlledDescriptions)[number], index: number) => { + const { complementGhIssue, needsFix, needsTest } = submissionDescription; + + return ( + <> +

{t("Submissions.selectIssueToComplement")}

+ {(!vaultGithubIssuesOpts || vaultGithubIssuesOpts?.length === 0) && ( + <> + + {t("Submissions.firstYouNeedToClaimSomeIssues")} + + + + + )} + {vaultGithubIssuesOpts && vaultGithubIssuesOpts?.length > 0 && ( + <> ( - (field.name, dirtyFields, defaultValues)} error={error} + label={t("Submissions.githubIssue")} + placeholder={t("Submissions.selectGithubIssue")} colorable + options={vaultGithubIssuesOpts ?? []} {...field} + value={field.value ?? ""} + onChange={(a) => { + field.onChange(a); + const ghIssue = vaultGithubIssues?.find((gh) => gh.number === Number(a as string)); + if (ghIssue) { + setValue(`descriptions.${index}.complementGhIssue`, ghIssue); + setValue(`descriptions.${index}.needsFix`, ghIssue.bonusPointsLabels.needsFix); + setValue(`descriptions.${index}.needsTest`, ghIssue.bonusPointsLabels.needsTest); + } + }} /> )} /> - {!submissionDescription.isEncrypted && !allFormDisabled && ( - ( - - )} - /> + {/* labels */} +
+
+ {complementGhIssue?.bonusPointsLabels.needsFix && } + {complementGhIssue?.bonusPointsLabels.needsTest && } +
+
+ + {/* Fix PR section */} + {needsFix && ( + <> +

{t("Submissions.addFixFiles")}:

+

{t("Submissions.addFixFilesExplanation")}

+ { + if (!a?.length) return; + for (const file of a) { + const fixFiles = getValues(`descriptions.${index}.complementFixFiles`); + if (fixFiles.some((f) => f.file.ipfsHash === file.ipfsHash)) continue; + const newFile = { file, path: "", pathOpts: [] }; + + const repoName = await getVaultRepoName(vault?.id); + if (!repoName) { + setValue(`descriptions.${index}.complementFixFiles`, [...fixFiles, newFile]); + return; + } + + const pathOptions = await searchFileInHatsRepo(repoName, file.name); + if (pathOptions.length === 0) { + setValue(`descriptions.${index}.complementFixFiles`, [...fixFiles, newFile]); + } else if (pathOptions.length === 1) { + setValue(`descriptions.${index}.complementFixFiles`, [ + ...fixFiles, + { ...newFile, path: pathOptions[0], pathOpts: [] }, + ]); + } else { + setValue(`descriptions.${index}.complementFixFiles`, [ + ...fixFiles, + { ...newFile, path: "", pathOpts: pathOptions }, + ]); + } + } + }} + /> + +
+
+ {(submissionDescription.complementFixFiles ?? []).map((item, idx) => ( +
  • +
    +
    + { + const fixFiles = getValues(`descriptions.${index}.complementFixFiles`); + setValue( + `descriptions.${index}.complementFixFiles`, + fixFiles.filter((_, fileIndex) => fileIndex !== idx) + ); + }} + /> +

    {item.file?.name}

    +
    + +
    +

    {t("Submissions.filePath")}:

    +
    + +
    +
    +
    + +
    + {item.pathOpts?.map((opt) => ( +
    setValue(`descriptions.${index}.complementFixFiles.${idx}.path`, opt)}> + +
    + ))} +
    +
  • + ))} +
    +
    + )} +
    + + {/* Test PR section */} + {!submissionDescription.testNotApplicable && needsTest && ( + <> +

    {t("Submissions.addTestFiles")}:

    +

    {t("Submissions.addTestFilesExplanation")}

    + { + if (!a?.length) return; + for (const file of a) { + const testFiles = getValues(`descriptions.${index}.complementTestFiles`); + if (testFiles.some((f) => f.file.ipfsHash === file.ipfsHash)) continue; + const newFile = { file, path: "", pathOpts: [] }; + + const repoName = await getVaultRepoName(vault?.id); + if (!repoName) { + setValue(`descriptions.${index}.complementTestFiles`, [...testFiles, newFile]); + return; + } + + const pathOptions = await searchFileInHatsRepo(repoName, file.name); + if (pathOptions.length === 0) { + setValue(`descriptions.${index}.complementTestFiles`, [...testFiles, newFile]); + } else if (pathOptions.length === 1) { + setValue(`descriptions.${index}.complementTestFiles`, [ + ...testFiles, + { ...newFile, path: pathOptions[0], pathOpts: [] }, + ]); + } else { + setValue(`descriptions.${index}.complementTestFiles`, [ + ...testFiles, + { ...newFile, path: "", pathOpts: pathOptions }, + ]); + } + } + }} + /> + + {/*

    {t("Submissions.filesAttached")}:

    */} +
    +
    + {(submissionDescription.complementTestFiles ?? []).map((item, idx) => ( +
  • +
    +
    + { + const testFiles = getValues(`descriptions.${index}.complementTestFiles`); + setValue( + `descriptions.${index}.complementTestFiles`, + testFiles.filter((_, fileIndex) => fileIndex !== idx) + ); + }} + /> +

    {item.file?.name}

    +
    + +
    +

    {t("Submissions.filePath")}:

    +
    + +
    +
    +
    + +
    + {item.pathOpts?.map((opt) => ( +
    setValue(`descriptions.${index}.complementTestFiles.${idx}.path`, opt)}> + +
    + ))} +
    +
  • + ))} +
    +
    + + )} + {needsTest && needsFix && ( +
    + +
    + )} + + )} + + ); + }; + + return ( + + {controlledDescriptions.map((submissionDescription, index) => { + return ( + +

    + + {t("submission")} #{index + 1} + + {((submissionDescription.type === "new" && submissionDescription.severity) || + submissionDescription.type === "complement") && ( + + + {submissionDescription.isEncrypted + ? t("Submissions.encryptedSubmission") + : t("Submissions.decryptedSubmission")} + + + )} +

    + + {bonusPointsEnabled && ( +
    +
    setValue(`descriptions.${index}.type`, "new")}> +
    +
    +

    {t("newSubmission")}

    +
    +
    + +
    setValue(`descriptions.${index}.type`, "complement")}> +
    +
    +

    {t("complementSubmission")}

    +
    +
    +
    + )} + + {submissionDescription.type === "new" + ? getNewIssueForm(submissionDescription, index) + : getComplementIssueForm(submissionDescription, index)} + {controlledDescriptions.length > 1 && !allFormDisabled && (
    + + {(isLoadingClaimedIssues || isLoadingGH) && } ); } diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/formSchema.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/formSchema.ts index 8ae22ea9e..a909197ab 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/formSchema.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/formSchema.ts @@ -5,13 +5,77 @@ export const getCreateDescriptionSchema = (intl: TFunction) => Yup.object().shape({ descriptions: Yup.array().of( Yup.object({ - title: Yup.string() - .min(5, intl("min-characters", { min: 5 })) - .required(intl("required")), - severity: Yup.string().required(intl("required")), - description: Yup.string() - .min(20, intl("min-characters", { min: 20 })) - .required(intl("required")), + type: Yup.string().required(intl("required")), + + // new fields + title: Yup.string().when("type", (type: "new" | "complement", schema: any) => { + if (type === "complement") return schema; + return schema.min(5, intl("min-characters", { min: 5 })).required(intl("required")); + }), + severity: Yup.string().when("type", (type: "new" | "complement", schema: any) => { + if (type === "complement") return schema; + return schema.required(intl("required")); + }), + description: Yup.string().when("type", (type: "new" | "complement", schema: any) => { + if (type === "complement") return schema; + return schema.min(20, intl("min-characters", { min: 20 })).required(intl("required")); + }), + isTestApplicable: Yup.boolean().when("type", (type: "new" | "complement", schema: any) => { + if (type === "new") return schema.required(intl("required")); + return schema; + }), + + // complement fields + needsFix: Yup.boolean(), + needsTest: Yup.boolean(), + testNotApplicable: Yup.boolean(), + complementGhIssueNumber: Yup.string().when("type", (type: "new" | "complement", schema: any) => { + if (type === "new") return schema; + return schema.required(intl("required")); + }), + complementGhIssue: Yup.object().when("type", (type: "new" | "complement", schema: any) => { + if (type === "new") return schema; + return schema.required(intl("required")); + }), + complementFixFiles: Yup.array() + .of( + Yup.object({ + file: Yup.object().required(intl("required")), + path: Yup.string() + .required(intl("required")) + .test("pathEndsWithFileNameError", intl("pathEndsWithFileNameError"), (val, ctx: any) => { + const fileName = ctx.from[0].value.file?.name; + return val?.endsWith(fileName) ?? false; + }), + }) + ) + .test("min", intl("required"), (val, ctx: any) => { + const type = ctx.from[0].value.type; + const needsFix = ctx.from[0].value.needsFix; + if (type === "new") return true; + if (!needsFix) return true; + return (val?.length ?? 0) > 0 ?? false; + }), + complementTestFiles: Yup.array() + .of( + Yup.object({ + file: Yup.object().required(intl("required")), + path: Yup.string() + .required(intl("required")) + .test("pathEndsWithFileNameError", intl("pathEndsWithFileNameError"), (val, ctx: any) => { + const fileName = ctx.from[0].value.file?.name; + return val?.endsWith(fileName) ?? false; + }), + }) + ) + .test("min", intl("required"), (val, ctx: any) => { + const type = ctx.from[0].value.type; + const testNotApplicable = ctx.from[0].value.testNotApplicable; + const needsTest = ctx.from[0].value.needsTest; + if (type === "new") return true; + if (testNotApplicable || !needsTest) return true; + return (val?.length ?? 0) > 0 ?? false; + }), }) ), }); diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/styles.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/styles.ts index 76388068e..95c8834ef 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/styles.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/styles.ts @@ -46,9 +46,164 @@ export const StyledSubmissionDescription = styled.div<{ isEncrypted: boolean }>( } } + .gh-labels { + display: flex; + flex-direction: column; + gap: ${getSpacing(1)}; + margin-bottom: ${getSpacing(2.5)}; + margin-top: ${getSpacing(-1.5)}; + + .labels { + display: flex; + gap: ${getSpacing(1)}; + } + } + + .options { + display: flex; + gap: ${getSpacing(3)}; + width: 100%; + + .option { + display: flex; + gap: ${getSpacing(2)}; + cursor: pointer; + transition: all 0.2s; + + &:hover { + opacity: 0.8; + } + + .check-circle { + width: ${getSpacing(2.5)}; + height: ${getSpacing(2.5)}; + aspect-ratio: 1; + border-radius: 50%; + border: 2px solid var(--primary); + + &.selected { + position: relative; + + &::after { + content: ""; + width: ${getSpacing(1.2)}; + height: ${getSpacing(1.2)}; + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-43%, -50%); + position: absolute; + background-color: var(--primary); + display: block; + } + } + + &.error { + border-color: var(--error-red); + + &::after { + background-color: var(--error-red); + } + } + } + .info { + width: 100%; + display: flex; + flex-direction: column; + gap: ${getSpacing(0.5)}; + } + } + } + + .files-attached-container { + display: flex; + align-items: center; + gap: ${getSpacing(1)}; + margin-top: ${getSpacing(3)}; + + .files { + display: flex; + align-items: center; + gap: ${getSpacing(2)}; + flex-wrap: wrap; + flex-direction: column; + width: 100%; + + li { + list-style: none; + display: flex; + width: 100%; + align-items: flex-start; + flex-direction: column; + + .file-container { + display: flex; + gap: ${getSpacing(5)}; + width: 100%; + align-items: flex-start; + + .file { + display: flex; + align-items: center; + gap: ${getSpacing(1)}; + border: 1px solid var(--primary); + border-radius: 50px; + padding: ${getSpacing(0.5)} ${getSpacing(1)}; + font-size: var(--xxsmall); + margin-top: ${getSpacing(2)}; + + .remove-icon { + cursor: pointer; + transition: 0.1s; + + &:hover { + color: var(--error-red); + } + } + } + + .file-path { + display: flex; + align-items: flex-start; + gap: ${getSpacing(2)}; + width: 100%; + + p { + white-space: nowrap; + margin-top: ${getSpacing(2.5)}; + } + + .file-path-input { + width: 100%; + } + } + } + + .file-opts { + display: flex; + gap: ${getSpacing(0.5)}; + flex-wrap: wrap; + + div { + cursor: pointer; + transition: 0.2s; + + &:hover { + opacity: 0.8; + } + } + } + } + } + } + .buttons { display: flex; justify-content: flex-end; } + + .error-text { + color: var(--error-red); + } ` ); diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/utils.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/utils.ts index 3ad0cccd1..fb97f35da 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/utils.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/FormSteps/SubmissionDescriptions/utils.ts @@ -1,4 +1,6 @@ +import { IPFS_PREFIX } from "constants/constants"; import { BASE_SERVICE_URL } from "settings"; +import { IHackerProfile } from "types"; import { ISubmissionData, ISubmissionsDescriptionsData } from "../../types"; export const SUBMISSION_DESCRIPTION_TEMPLATE = `**Description**\\ @@ -20,7 +22,8 @@ Describe how the vulnerability can be exploited. export const getAuditSubmissionTexts = ( submissionData: ISubmissionData, - descriptions: ISubmissionsDescriptionsData["descriptions"] + descriptions: ISubmissionsDescriptionsData["descriptions"], + hackerProfile: IHackerProfile | undefined ) => { const toEncrypt = `**Communication channel:** ${submissionData.contact?.communicationChannel} (${ submissionData.contact?.communicationChannelType @@ -29,10 +32,18 @@ export const getAuditSubmissionTexts = ( ${descriptions .filter((description) => description.isEncrypted) - .map( - (description, idx) => ` + .map((description, idx) => + description.type === "new" + ? ` ## [ISSUE #${idx + 1}]: ${description.title} (${description.severity})\n ${description.description.trim()} +##` + : ` +## [ISSUE #${idx + 1}]: COMPLEMENTARY [Issue #${description.complementGhIssueNumber}] ${description.complementGhIssue?.title}\n\n +### Fix files +${description.complementFixFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")}\n +### Test files +${description.complementTestFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")} ##` ) .join("\n")}`; @@ -40,12 +51,15 @@ ${description.description.trim()} const decrypted = `**Project Name:** ${submissionData.project?.projectName}\n **Project Id:** ${submissionData.project?.projectId}\n **Github username:** ${submissionData.contact?.githubUsername || "---"}\n -**Twitter username:** ${submissionData.contact?.twitterUsername || "---"} +**Twitter username:** ${submissionData.contact?.twitterUsername || "---"}\n +**HATS Profile:** ${hackerProfile ? `${hackerProfile?.username}` : "---"}\n +**Beneficiary:** ${submissionData.contact?.beneficiary} ${descriptions .filter((description) => !description.isEncrypted) - .map( - (description, idx) => ` + .map((description, idx) => + description.type === "new" + ? ` ## [ISSUE #${idx + 1}]: ${description.title} (${description.severity})\n ${description.description.trim()} ${ @@ -53,6 +67,13 @@ ${ ? `**Files:**\n${description.files.map((file) => ` - ${file.name} (${BASE_SERVICE_URL}/files/${file.ipfsHash})`).join("\n")}` : "" } +##` + : ` +## [ISSUE #${idx + 1}]: COMPLEMENTARY [Issue #${description.complementGhIssueNumber}] ${description.complementGhIssue?.title}\n\n +### Fix files +${description.complementFixFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")}\n +### Test files ${description.testNotApplicable ? "(NOT APPLICABLE) " : ""} +${description.complementTestFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")} ##` ) .join("\n")}`; @@ -68,11 +89,13 @@ ${ export const getBountySubmissionTexts = ( submissionData: ISubmissionData, - descriptions: ISubmissionsDescriptionsData["descriptions"] + descriptions: ISubmissionsDescriptionsData["descriptions"], + hackerProfile: IHackerProfile | undefined ) => { const toEncrypt = `**Project Name:** ${submissionData.project?.projectName}\n **Project Id:** ${submissionData.project?.projectId}\n **Beneficiary:** ${submissionData.contact?.beneficiary}\n +**HATS Profile:** ${hackerProfile ? `${hackerProfile?.username}` : "---"}\n **Communication channel:** ${submissionData.contact?.communicationChannel} (${submissionData.contact?.communicationChannelType}) ${descriptions @@ -95,21 +118,42 @@ ${description.description.trim()} export const getGithubIssueDescription = ( submissionData: ISubmissionData, - description: ISubmissionsDescriptionsData["descriptions"][0] + description: ISubmissionsDescriptionsData["descriptions"][0], + hackerProfile: IHackerProfile | undefined ) => { - return `${submissionData.ref === "audit-wizard" ? "***Submitted via auditwizard.io***\n" : ""} -**Github username:** ${submissionData.contact?.githubUsername ? `@${submissionData.contact?.githubUsername}` : "--"} + if (description.type === "new") { + return `${submissionData.ref === "audit-wizard" ? "***Submitted via auditwizard.io***\n" : ""} + **Github username:** ${submissionData.contact?.githubUsername ? `@${submissionData.contact?.githubUsername}` : "--"} + **Twitter username:** ${submissionData.contact?.twitterUsername ? `${submissionData.contact?.twitterUsername}` : "--"} + **HATS Profile:** ${hackerProfile ? `${hackerProfile?.username}` : "---"} + **Beneficiary:** ${submissionData.contact?.beneficiary} + **Submission hash (on-chain):** ${submissionData.submissionResult?.transactionHash} + **Severity:** ${description.severity} + + **Description:** + ${description.description.trim()} + + ${ + description.files && description.files.length > 0 + ? `**Files:**\n${description.files + .map((file) => ` - ${file.name} (${BASE_SERVICE_URL}/files/${file.ipfsHash})`) + .join("\n")}` + : "" + } + `; + } + + return `**Github username:** ${submissionData.contact?.githubUsername ? `@${submissionData.contact?.githubUsername}` : "--"} **Twitter username:** ${submissionData.contact?.twitterUsername ? `${submissionData.contact?.twitterUsername}` : "--"} +**HATS Profile:** ${hackerProfile ? `${hackerProfile?.username}` : "---"} +**Beneficiary:** ${submissionData.contact?.beneficiary} **Submission hash (on-chain):** ${submissionData.submissionResult?.transactionHash} -**Severity:** ${description.severity} **Description:** -${description.description.trim()} - -${ - description.files && description.files.length > 0 - ? `**Files:**\n${description.files.map((file) => ` - ${file.name} (${BASE_SERVICE_URL}/files/${file.ipfsHash})`).join("\n")}` - : "" -} +### Fix files +${description.complementFixFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")}\n +### Test files ${description.testNotApplicable ? "(NOT APPLICABLE) " : ""} +${description.complementTestFiles.map((file) => ` - ${file.path} (${IPFS_PREFIX}/${file.file.ipfsHash})`).join("\n")} +## `; }; diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/SubmissionFormPage.tsx b/packages/web/src/pages/Submissions/SubmissionFormPage/SubmissionFormPage.tsx index 136bb7507..9a354272b 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/SubmissionFormPage.tsx +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/SubmissionFormPage.tsx @@ -1,4 +1,3 @@ -import { IVulnerabilitySeverity } from "@hats.finance/shared"; import ErrorIcon from "@mui/icons-material/ErrorOutlineOutlined"; import ClearIcon from "@mui/icons-material/HighlightOffOutlined"; import { Button, Loading, Seo } from "components"; @@ -6,6 +5,7 @@ import { LocalStorage } from "constants/constants"; import { LogClaimContract } from "contracts"; import { useVaults } from "hooks/subgraph/vaults/useVaults"; import useConfirm from "hooks/useConfirm"; +import { useProfileByAddress } from "pages/HackerProfile/hooks"; import { useUserHasCollectedSignature } from "pages/Honeypots/VaultDetailsPage/hooks"; import { HoneypotsRoutePaths } from "pages/Honeypots/router"; import { calcCid } from "pages/Submissions/SubmissionFormPage/encrypt"; @@ -15,7 +15,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { IS_PROD } from "settings"; import { getAppVersion } from "utils"; import { slugify } from "utils/slug.utils"; -import { useNetwork, useWaitForTransaction } from "wagmi"; +import { useAccount, useNetwork, useWaitForTransaction } from "wagmi"; import { SubmissionContactInfo, SubmissionDescriptions, @@ -42,6 +42,9 @@ export const SubmissionFormPage = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { chain } = useNetwork(); + const { address } = useAccount(); + const { data: hackerProfile } = useProfileByAddress(address); + const [currentStep, setCurrentStep] = useState(); const [submissionData, setSubmissionData] = useState(); const [allFormDisabled, setAllFormDisabled] = useState(false); @@ -51,6 +54,7 @@ export const SubmissionFormPage = () => { const vault = (activeVaults ?? []).find((vault) => vault.id === submissionData?.project?.projectId); const requireMessageSignature = vault?.description?.["project-metadata"].requireMessageSignature; + const { data: userHasCollectedSignature, isLoading: isLoadingCollectedSignature } = useUserHasCollectedSignature(vault); const steps = useMemo( @@ -59,7 +63,7 @@ export const SubmissionFormPage = () => { { title: t("Submissions.termsAndProcess"), component: SubmissionTermsAndProcess, card: SubmissionStep.terms }, { title: t("Submissions.communicationChannel"), component: SubmissionContactInfo, card: SubmissionStep.contact }, { - title: t("Submissions.describeVulnerability"), + title: t("Submissions.submissionDescription"), component: SubmissionDescriptions, card: SubmissionStep.submissionsDescriptions, }, @@ -168,7 +172,7 @@ export const SubmissionFormPage = () => { }); try { - const res = await submitVulnerabilitySubmission(data, vault); + const res = await submitVulnerabilitySubmission(data, vault, hackerProfile); if (res.success) { setSubmissionData({ @@ -187,7 +191,7 @@ export const SubmissionFormPage = () => { }); } }, - [vault] + [vault, hackerProfile] ); const submitSubmission = useCallback(async () => { @@ -300,20 +304,19 @@ export const SubmissionFormPage = () => { verified: false, submission: "", submissionMessage: "", - descriptions: auditWizardSubmission.submissionsDescriptions.descriptions.map((desc: any) => { - const severity = (vault.description?.severities as IVulnerabilitySeverity[]).find( - (sev) => - desc.severity.toLowerCase()?.includes(sev.name.toLowerCase()) || - sev.name.toLowerCase()?.includes(desc.severity.toLowerCase()) - ); - return { - title: desc.title, - description: desc.description, - severity: severity?.name.toLowerCase() ?? desc.severity.toLowerCase(), - isEncrypted: !severity?.decryptSubmissions, - files: [], - }; - }), + descriptions: auditWizardSubmission.submissionsDescriptions.descriptions.map((desc: any) => ({ + type: "new", // or "complement" based on your logic + complementTestFiles: [], + complementFixFiles: [], + title: desc.title, + description: desc.description, + severity: desc.severity, + isEncrypted: desc.isEncrypted, + files: desc.files || [], + testNotApplicable: false, + needsFix: false, + needsTest: false, + })), }, submissionResult: undefined, })); @@ -397,7 +400,7 @@ export const SubmissionFormPage = () => {
    - {isLoadingCollectedSignature && } + {requireMessageSignature && isLoadingCollectedSignature && } ); }; diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/store.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/store.ts index 303723edf..6a250ca92 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/store.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/store.ts @@ -1,7 +1,7 @@ import { IVault } from "@hats.finance/shared"; import { Dispatch, SetStateAction, createContext } from "react"; import { SUBMISSION_DESCRIPTION_TEMPLATE } from "./FormSteps/SubmissionDescriptions/utils"; -import { ISubmissionData } from "./types"; +import { ISubmissionData, ISubmissionsDescriptionsData } from "./types"; const packageJSON = require("../../../../package.json"); @@ -29,13 +29,20 @@ export const SUBMISSION_INIT_DATA = { submission: "", descriptions: [ { + type: "new", isEncrypted: true, title: "", description: SUBMISSION_DESCRIPTION_TEMPLATE, severity: "", files: [], sessionKey: "" as any, + complementFixFiles: [], + complementTestFiles: [], + testNotApplicable: false, + isTestApplicable: undefined, + needsFix: false, + needsTest: false, }, ], - }, + } satisfies ISubmissionsDescriptionsData, }; diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/submissionsService.api.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/submissionsService.api.ts index 8344ad3a0..e5629f423 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/submissionsService.api.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/submissionsService.api.ts @@ -1,4 +1,4 @@ -import { IVault } from "@hats.finance/shared"; +import { GithubIssue, IHackerProfile, IVault } from "@hats.finance/shared"; import { axiosClient } from "config/axiosClient"; import { auditWizardVerifyService } from "constants/constants"; import { BASE_SERVICE_URL } from "settings"; @@ -14,12 +14,15 @@ import { IAuditWizardSubmissionData, ISubmissionData, ISubmitSubmissionRequest } */ export async function submitVulnerabilitySubmission( submissionData: ISubmissionData, - vault: IVault + vault: IVault, + hackerProfile: IHackerProfile | undefined ): Promise<{ success: boolean; auditCompetitionRepo?: string }> { if (!submissionData.project || !submissionData.submissionsDescriptions || !submissionData.submissionResult) { throw new Error(`Invalid params on 'submitVulnerabilitySubmission' function: ${submissionData}`); } + const bonusPointsEnabled = vault?.description?.["project-metadata"]?.bonusPointsEnabled; + const submissionRequest: ISubmitSubmissionRequest = { submitVulnerabilityRequest: { chainId: submissionData.submissionResult.chainId, @@ -31,11 +34,28 @@ export async function submitVulnerabilitySubmission( createIssueRequests: vault.description?.["project-metadata"].type === "audit" ? submissionData.submissionsDescriptions.descriptions - ?.filter((desc) => !desc.isEncrypted) + ?.filter((desc) => !desc.isEncrypted && desc.type === "new") ?.map((description) => ({ issueTitle: description.title, - issueDescription: getGithubIssueDescription(submissionData, description), + issueDescription: getGithubIssueDescription(submissionData, description, hackerProfile), issueFiles: description.files?.map((file) => file.ipfsHash), + isTestApplicable: description.isTestApplicable, + bonusPointsEnabled, + })) + : [], + createPRsRequests: + vault.description?.["project-metadata"].type === "audit" + ? submissionData.submissionsDescriptions.descriptions + ?.filter((desc) => !desc.isEncrypted && desc.type === "complement") + ?.map((description) => ({ + pullRequestTitle: `Complementary submission for #${description.complementGhIssueNumber}`, + pullRequestDescription: getGithubIssueDescription(submissionData, description, hackerProfile), + pullRequestFiles: [...description.complementFixFiles, ...description.complementTestFiles].map((file) => ({ + path: file.path, + fileIpfsHash: file.file.ipfsHash, + })), + githubIssue: description.complementGhIssue as GithubIssue, + githubIssueNumber: Number(description.complementGhIssueNumber), })) : [], }; diff --git a/packages/web/src/pages/Submissions/SubmissionFormPage/types.ts b/packages/web/src/pages/Submissions/SubmissionFormPage/types.ts index 14309f77d..6adc99eba 100644 --- a/packages/web/src/pages/Submissions/SubmissionFormPage/types.ts +++ b/packages/web/src/pages/Submissions/SubmissionFormPage/types.ts @@ -1,3 +1,4 @@ +import { GithubIssue } from "@hats.finance/shared"; import { ISavedFile } from "components"; import { SessionKey } from "openpgp"; @@ -29,12 +30,25 @@ export interface ISubmissionsDescriptionsData { submission: string; // Submission object ({encrypted: string, decrypted: string}) submissionMessage: string; // It's only for showing on the frontend final step descriptions: { + type: "new" | "complement"; // "new" is for new vulnerabilities, "complement" is for fix/test submissions + + // complement fields + testNotApplicable: boolean; + complementTestFiles: { file: ISavedFile; path: string; pathOpts: string[] }[]; + complementFixFiles: { file: ISavedFile; path: string; pathOpts: string[] }[]; + complementGhIssueNumber?: string; + complementGhIssue?: GithubIssue; + needsFix: boolean; + needsTest: boolean; + + // new fields title: string; description: string; severity: string; files: ISavedFile[]; sessionKey?: SessionKey; isEncrypted?: boolean; + isTestApplicable?: boolean; }[]; } @@ -80,6 +94,15 @@ export interface ISubmitSubmissionRequest { issueTitle: string; issueDescription: string; issueFiles: string[]; + isTestApplicable?: boolean; + bonusPointsEnabled?: boolean; + }[]; + createPRsRequests: { + pullRequestTitle: string; + pullRequestDescription: string; + pullRequestFiles: { path: string; fileIpfsHash: string }[]; + githubIssue: GithubIssue; + githubIssueNumber?: number; }[]; } @@ -119,8 +142,8 @@ export const getCurrentAuditwizardSubmission = ( ...awSubmission.submissionsDescriptions, descriptions: form.submissionsDescriptions?.descriptions.map((d, idx) => ({ - title: d.title, - description: d.description, + title: d.title ?? "", + description: d.description ?? "", severity: awSubmission.submissionsDescriptions.descriptions[idx].severity, })) ?? [], }, diff --git a/packages/web/src/pages/VaultEditor/VaultEditorFormPage/SetupSteps/VaultDetailsForm/VaultDetailsForm.tsx b/packages/web/src/pages/VaultEditor/VaultEditorFormPage/SetupSteps/VaultDetailsForm/VaultDetailsForm.tsx index 437a5e228..2ac9c2b0d 100644 --- a/packages/web/src/pages/VaultEditor/VaultEditorFormPage/SetupSteps/VaultDetailsForm/VaultDetailsForm.tsx +++ b/packages/web/src/pages/VaultEditor/VaultEditorFormPage/SetupSteps/VaultDetailsForm/VaultDetailsForm.tsx @@ -99,6 +99,10 @@ export function VaultDetailsForm() { if (isAudit) setValue("usingPointingSystem", true); else setValue("usingPointingSystem", false); trigger("vulnerability-severities-spec"); + + // Change the bonus points enabled value + if (vaultType === "audit") setValue("project-metadata.bonusPointsEnabled", true); + else setValue("project-metadata.bonusPointsEnabled", false); }); return ( @@ -159,6 +163,15 @@ export function VaultDetailsForm() { label={t("requireMessageSignature")} /> ) : null} + {isAdvancedMode && vaultType === "audit" && ( + + )}
    diff --git a/packages/web/src/pages/VaultEditor/VaultEditorFormPage/formSchema.ts b/packages/web/src/pages/VaultEditor/VaultEditorFormPage/formSchema.ts index f440d22e4..f22d3cbeb 100644 --- a/packages/web/src/pages/VaultEditor/VaultEditorFormPage/formSchema.ts +++ b/packages/web/src/pages/VaultEditor/VaultEditorFormPage/formSchema.ts @@ -27,6 +27,7 @@ export const getEditedDescriptionYupSchema = (intl: TFunction) => isPrivateAudit: Yup.boolean(), isContinuousAudit: Yup.boolean(), requireMessageSignature: Yup.boolean(), + bonusPointsEnabled: Yup.boolean(), messageToSign: Yup.string().when("requireMessageSignature", (requireMessageSignature: boolean, schema: any) => { if (requireMessageSignature) return schema.required(intl("required")).typeError(intl("required")); }), diff --git a/packages/web/src/utils/github.utils.ts b/packages/web/src/utils/github.utils.ts index 39c77d4d7..d9c6cb770 100644 --- a/packages/web/src/utils/github.utils.ts +++ b/packages/web/src/utils/github.utils.ts @@ -1,4 +1,20 @@ +import { axiosClient } from "config/axiosClient"; +import { BASE_SERVICE_URL } from "settings"; + export const isGithubUsernameValid = async (username: string) => { const response = await fetch(`https://api.github.com/users/${username}`); return response.status === 200; }; + +export const searchFileInHatsRepo = async (repoName: string, fileName: string): Promise => { + if (!repoName || !fileName) return []; + + try { + const res = await axiosClient.get(`${BASE_SERVICE_URL}/utils/search-file-in-repo?repoName=${repoName}&fileName=${fileName}`); + + return res.data.files ?? []; + } catch (err) { + console.error(err); + return []; + } +};