diff --git a/package-lock.json b/package-lock.json
index e2039377..3c70df44 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "sherlock-v2-frontend",
"version": "0.1.0",
"dependencies": {
+ "@floating-ui/react": "^0.26.9",
"@sentry/react": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@testing-library/jest-dom": "^5.16.1",
@@ -2951,6 +2952,54 @@
"@ethersproject/strings": "^5.7.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
+ "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.1"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz",
+ "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==",
+ "dependencies": {
+ "@floating-ui/core": "^1.0.0",
+ "@floating-ui/utils": "^0.2.0"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.26.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz",
+ "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.8",
+ "@floating-ui/utils": "^0.2.1",
+ "tabbable": "^6.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz",
+ "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==",
+ "dependencies": {
+ "@floating-ui/dom": "^1.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
+ "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
+ },
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -19190,6 +19239,11 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
"node_modules/tailwindcss": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.5.tgz",
@@ -22948,6 +23002,46 @@
"@ethersproject/strings": "^5.7.0"
}
},
+ "@floating-ui/core": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
+ "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
+ "requires": {
+ "@floating-ui/utils": "^0.2.1"
+ }
+ },
+ "@floating-ui/dom": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz",
+ "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==",
+ "requires": {
+ "@floating-ui/core": "^1.0.0",
+ "@floating-ui/utils": "^0.2.0"
+ }
+ },
+ "@floating-ui/react": {
+ "version": "0.26.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz",
+ "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==",
+ "requires": {
+ "@floating-ui/react-dom": "^2.0.8",
+ "@floating-ui/utils": "^0.2.1",
+ "tabbable": "^6.0.1"
+ }
+ },
+ "@floating-ui/react-dom": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz",
+ "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==",
+ "requires": {
+ "@floating-ui/dom": "^1.6.1"
+ }
+ },
+ "@floating-ui/utils": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
+ "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
+ },
"@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -35052,6 +35146,11 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
+ "tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
"tailwindcss": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.5.tgz",
diff --git a/package.json b/package.json
index a692f2a7..aa35eab1 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
+ "@floating-ui/react": "^0.26.9",
"@sentry/react": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@testing-library/jest-dom": "^5.16.1",
diff --git a/src/App.tsx b/src/App.tsx
index 35dc326e..58da7613 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -43,7 +43,7 @@ function App() {
)
useEffect(() => {
- if (!connectedAddress || (profile && !addressIsAllowed(connectedAddress))) {
+ if (profile && connectedAddress && !addressIsAllowed(connectedAddress)) {
signOut()
}
}, [connectedAddress, addressIsAllowed, signOut, profile])
diff --git a/src/AppAdmin.tsx b/src/AppAdmin.tsx
index 05d68e16..7646369f 100644
--- a/src/AppAdmin.tsx
+++ b/src/AppAdmin.tsx
@@ -11,20 +11,12 @@ import { Box } from "./components/Box"
import { useAdminSignIn } from "./hooks/api/admin/useAdminSignIn"
import { ErrorModal } from "./pages/ContestDetails/ErrorModal"
import { useAccount } from "wagmi"
-import { contests as contestsAPI } from "./hooks/api/axios"
-import { adminSignOut as adminSignOutUrl } from "./hooks/api/urls"
const AppInternal = () => {
const { data: adminAddress } = useAdminProfile()
- const { address: connectedAddress, isDisconnected } = useAccount()
+ const { address: connectedAddress } = useAccount()
const { signIn, error, reset } = useAdminSignIn()
- useEffect(() => {
- if (adminAddress && !connectedAddress && isDisconnected) {
- contestsAPI.get(adminSignOutUrl())
- }
- }, [connectedAddress, adminAddress, isDisconnected])
-
const handleSignInAsAdmin = useCallback(() => {
signIn()
}, [signIn])
@@ -33,10 +25,7 @@ const AppInternal = () => {
reset()
}, [reset])
- const validAdmin = useMemo(
- () => adminAddress && connectedAddress && adminAddress === connectedAddress,
- [adminAddress, connectedAddress]
- )
+ const validAdmin = useMemo(() => !!adminAddress, [adminAddress])
const navigationLinks: NavigationLink[] = validAdmin
? [
@@ -48,17 +37,13 @@ const AppInternal = () => {
title: "CONTESTS",
route: adminRoutes.Contests,
},
- {
- title: "SCOPE",
- route: adminRoutes.Scope,
- },
]
: []
return (
-
+
{validAdmin ? (
diff --git a/src/AppProtocolDashboard.tsx b/src/AppProtocolDashboard.tsx
index b47c4b48..6f735a30 100644
--- a/src/AppProtocolDashboard.tsx
+++ b/src/AppProtocolDashboard.tsx
@@ -96,7 +96,11 @@ const AppProtocolDashboard = () => {
Contest Prize Pool
- {`${commify(protocolDashboard.contest.prizePool)} USDC`}
+
+ {protocolDashboard.contest.prizePool
+ ? ` ${commify(protocolDashboard.contest.prizePool)} USDC`
+ : "TBD"}
+
|
@@ -104,9 +108,11 @@ const AppProtocolDashboard = () => {
Lead Senior Watson Fixed Pay
|
- {`${commify(
- protocolDashboard.contest.leadSeniorAuditorFixedPay
- )} USDC`}
+
+ {protocolDashboard.contest.leadSeniorAuditorFixedPay
+ ? `${commify(protocolDashboard.contest.leadSeniorAuditorFixedPay)} USDC`
+ : "TBD"}
+
|
diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss
new file mode 100644
index 00000000..9c186b2d
--- /dev/null
+++ b/src/components/Checkbox/Checkbox.module.scss
@@ -0,0 +1,25 @@
+@import "../../styles/variables";
+
+.container {
+ height: 2rem;
+ width: 2rem;
+ border-radius: 4px;
+ border: 1px solid $primary-purple;
+ color: $primary-purple;
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
+
+ &:hover {
+ border-color: lighten($primary-purple, 20%);
+ color: lighten($primary-purple, 20%);
+ }
+
+ &:active {
+ border-color: darken($primary-purple, 20%);
+ color: darken($primary-purple, 20%);
+ }
+}
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
new file mode 100644
index 00000000..61c78666
--- /dev/null
+++ b/src/components/Checkbox/Checkbox.tsx
@@ -0,0 +1,17 @@
+import { FaCheck } from "react-icons/fa"
+import styles from "./Checkbox.module.scss"
+
+type Props = {
+ checked?: boolean
+ onChange?: (checked: boolean) => void
+}
+
+const Checkbox: React.FC = ({ checked = false, onChange }) => {
+ return (
+ onChange?.(!checked)}>
+ {checked && }
+
+ )
+}
+
+export default Checkbox
diff --git a/src/components/ClaimsList/ClaimsList.tsx b/src/components/ClaimsList/ClaimsList.tsx
index 9d3312ae..7e89f387 100644
--- a/src/components/ClaimsList/ClaimsList.tsx
+++ b/src/components/ClaimsList/ClaimsList.tsx
@@ -7,7 +7,18 @@ import { formatAmount } from "../../utils/format"
import styles from "./ClaimsList.module.scss"
import cx from "classnames"
-const CLAIMS = [
+type Claim = {
+ id: number
+ protocol: string
+ date: string
+ coverageAgreementUrl: string
+ evidenceUrl?: string
+ amount: number
+ txHashUrl: string
+ status: "paid" | "denied"
+}
+
+const CLAIMS: Claim[] = [
{
id: 1,
protocol: "Euler",
@@ -18,6 +29,7 @@ const CLAIMS = [
"https://sherlock-files.ams3.digitaloceanspaces.com/claims/Euler_0x3019e52a670390f24e4b9b58af62a7367658e457bbb07f86b19b213ec74b5be7_16817996.pdf?hash=d6c8e864a5312384143a21f542e998f6e83357f5b8e3e34ccdcb551404d25c09",
amount: 4_529_285,
txHashUrl: "https://etherscan.io/tx/0x234cd8e369fdcd0387ada4214e563a6a72aad4abd0b464a2873f3eb9dac2579b",
+ status: "paid",
},
{
id: 2,
@@ -29,9 +41,29 @@ const CLAIMS = [
"https://sherlock-files.ams3.digitaloceanspaces.com/claims/Sentiment_0x5af5b22283e35ef9d9d4a32753014cdc40fd7a5a5d920d83d2c1e901c10a0a7c_16977102.pdf?hash=f513422f0d98aaac6f669142caf9148172ca7f2a17559f3c1cb26177df11e8ef",
amount: 65_701,
txHashUrl: "https://etherscan.io/tx/0xe67bbb7a9085f1a603fcea1eefe08c7674a2f504e81f6d69df41c8b077c2765e",
+ status: "paid",
+ },
+ {
+ id: 3,
+ protocol: "Ajna",
+ date: "Sept 26, 2023",
+ coverageAgreementUrl:
+ "https://github.com/sherlock-protocol/sherlock-reports/blob/main/coverage-agreements/Ajna%20Coverage%20Agreement%202023.07.15.pdf",
+ amount: 49_500,
+ txHashUrl: "https://etherscan.io/tx/0xa4a8fbea2c24662ea46e25ab3ab69456a8327463803c4851948c2ba2234fed5f",
+ status: "paid",
},
]
+function getStatusLabel(status: "paid" | "denied") {
+ switch (status) {
+ case "paid":
+ return "Paid"
+ case "denied":
+ return "Denied"
+ }
+}
+
/**
* List of past claims
*/
@@ -64,7 +96,7 @@ const ClaimsList: React.FC = () => {
Evidence
- Transaction
+ Status
@@ -89,16 +121,20 @@ const ClaimsList: React.FC = () => {
-
-
- Link
-
-
+ {item.evidenceUrl ? (
+
+
+ Link
+
+
+ ) : (
+ -
+ )}
- Link
+ {getStatusLabel(item.status)}
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
index c685c4e9..55e3442b 100644
--- a/src/components/Header/Header.tsx
+++ b/src/components/Header/Header.tsx
@@ -16,7 +16,7 @@ import { Title } from "../Title"
export type NavigationLink = {
title: string
- route: Route
+ route: Route | string
external?: boolean
protected?: boolean
}
@@ -48,6 +48,8 @@ type HeaderProps = {
* URL of logo. Default to Sherlock's
*/
logoURL?: string
+
+ includePayouts?: boolean
}
/**
@@ -60,6 +62,7 @@ export const Header: React.FC = ({
connectButton = true,
title,
logoURL,
+ includePayouts,
}) => {
const { authenticate } = useAuthentication()
const { data: authenticatedProfile, isFetched } = useProfile()
@@ -104,6 +107,7 @@ export const Header: React.FC = ({
{navLink.external && }
))}
+ {includePayouts ? PAYOUTS : null}
)}
diff --git a/src/components/RadioButton/RadioButton.module.scss b/src/components/RadioButton/RadioButton.module.scss
new file mode 100644
index 00000000..a69d3802
--- /dev/null
+++ b/src/components/RadioButton/RadioButton.module.scss
@@ -0,0 +1,35 @@
+@import "../../styles/variables";
+
+.options {
+ border: 3px solid #000;
+ height: 3.315rem;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ .option {
+ height: 100%;
+ width: 100%;
+ background: darken($primary-purple, 30%);
+ padding: 0 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid black;
+ cursor: pointer;
+
+ &.active {
+ background: $primary-purple;
+ font-weight: bold;
+ }
+
+ &:hover {
+ background: darken($primary-purple, 10%);
+ }
+
+ &:active {
+ background: darken($primary-purple, 35%);
+ }
+ }
+}
diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx
new file mode 100644
index 00000000..6c6f70a0
--- /dev/null
+++ b/src/components/RadioButton/RadioButton.tsx
@@ -0,0 +1,39 @@
+import { Column, Row } from "../Layout"
+import { Text } from "../Text"
+import styles from "./RadioButton.module.scss"
+import cx from "classnames"
+
+type Props = {
+ value: any
+ label?: string
+ options: Array<{
+ label: string
+ value: any
+ }>
+ onChange: (value: any) => void
+}
+
+const RadioButton: React.FC = ({ options, value, label, onChange }) => {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {options.map((option) => (
+
onChange?.(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+
+ )
+}
+
+export default RadioButton
diff --git a/src/components/Select/Select.module.scss b/src/components/Select/Select.module.scss
index f06f8a3b..e27ebdda 100644
--- a/src/components/Select/Select.module.scss
+++ b/src/components/Select/Select.module.scss
@@ -7,7 +7,6 @@
border: 3px solid black;
background-color: $primary-purple;
- width: 180px;
padding: $spacing-m;
align-items: center;
@@ -20,6 +19,18 @@
&:active {
background-color: darken($color: $primary-purple, $amount: 15%);
}
+
+ &.small {
+ width: 180px;
+ }
+
+ &.full-width {
+ flex-grow: 1;
+ }
+ }
+
+ &.full-width {
+ flex-grow: 1;
}
.optionsContainer {
diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx
index 0136b17b..46d8cce2 100644
--- a/src/components/Select/Select.tsx
+++ b/src/components/Select/Select.tsx
@@ -4,23 +4,25 @@ import { Column, Row } from "../Layout"
import { Text } from "../Text"
import { FaCaretDown } from "react-icons/fa"
import Option from "./Option"
+import cx from "classnames"
type OptionType = {
- value: T
+ value?: T
label: string
}
type Props = {
options: Array>
- onChange: (value: T) => void
+ onChange: (value?: T) => void
value?: T
placeholder?: string
+ variant?: "small" | "full-width"
}
/**
* Custom Select/Dropdown component
*/
-const Select = ({ options, onChange, value, placeholder }: Props) => {
+const Select = ({ options, onChange, value, placeholder, variant = "small" }: Props) => {
// const [selectedOption, setSelectedOption] = React.useState()
const selectedOptionLabel = React.useMemo(
() => options?.find((item) => item.value === value)?.label ?? placeholder,
@@ -36,7 +38,7 @@ const Select = ({ options, onChange, value, placeholder }: Props) => {
const [optionsVisible, setOptionsVisible] = React.useState(false)
const handleUpdateSelectedOption = React.useCallback(
- (option: T) => {
+ (option: T | undefined) => {
setOptionsVisible(false)
onChange?.(option)
},
@@ -61,8 +63,13 @@ const Select = ({ options, onChange, value, placeholder }: Props) => {
}, [options, value, handleUpdateSelectedOption, placeholder])
return (
-
-
+
+
{hasOptions ? selectedOptionLabel : "No entries"}
diff --git a/src/components/StakingPositionsList/StakingPositionsList.tsx b/src/components/StakingPositionsList/StakingPositionsList.tsx
index 3b220fef..3a5c2440 100644
--- a/src/components/StakingPositionsList/StakingPositionsList.tsx
+++ b/src/components/StakingPositionsList/StakingPositionsList.tsx
@@ -136,30 +136,19 @@ export const StakingPositionsList: React.FC = () => {
navigate("/")
}, [navigate])
- const mapleAlertVisible = useMemo(() => positions?.some((item) => item.id <= 442), [positions])
-
if (!data) return null
return (
- {mapleAlertVisible && (
-
-
- Stakers affected by the Maple loss
-
- A portion of the Maple funds will be airdropped directly to staker addresses instead of delivered when
- unstaking. More info{" "}
-
- here
-
- .
-
-
-
- )}
+
+
+ Important Information for Unstaking
+
+ If a position is not unstaked within two weeks of unlocking, the position will get automatically restaked
+ for 6 months
+
+
+
{positions.map((position) => (
, "value" | "onChange"> & {
token: InputToken
onChange: (value?: BigNumber) => void
initialValue?: BigNumber
+ displayTokenLabel?: boolean
}
export const decimalsByToken: Record = {
@@ -27,7 +28,14 @@ export const decimalsByToken: Record = {
const decommify = (value: string) => value.replaceAll(",", "")
-export const TokenInput: React.FC = ({ balance, token, onChange, initialValue, ...props }) => {
+export const TokenInput: React.FC = ({
+ balance,
+ token,
+ onChange,
+ initialValue,
+ displayTokenLabel = true,
+ ...props
+}) => {
const [amount, amountBN, setAmount, setAmountBN] = useAmountState(decimalsByToken[token])
const { disabled } = props
@@ -62,11 +70,13 @@ export const TokenInput: React.FC = ({ balance, token, onChange, initialV
-
-
- {token}
-
-
+ {displayTokenLabel && (
+
+
+ {token}
+
+
+ )}
{balance && !disabled && (
diff --git a/src/components/Tooltip/Tooltip.module.scss b/src/components/Tooltip/Tooltip.module.scss
new file mode 100644
index 00000000..0a64e846
--- /dev/null
+++ b/src/components/Tooltip/Tooltip.module.scss
@@ -0,0 +1,17 @@
+@import "../../styles/variables";
+
+.container {
+ display: inline-block;
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.tooltip {
+ position: fixed;
+ padding: 10px;
+
+ background: $background-color;
+ border: 1px solid $primary-purple;
+}
diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx
new file mode 100644
index 00000000..a6d851b7
--- /dev/null
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -0,0 +1,168 @@
+import * as React from "react"
+import {
+ useFloating,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ useHover,
+ useFocus,
+ useDismiss,
+ useRole,
+ useInteractions,
+ useMergeRefs,
+ FloatingPortal,
+ safePolygon,
+} from "@floating-ui/react"
+import type { Placement } from "@floating-ui/react"
+import cx from "classnames"
+
+interface TooltipOptions {
+ initialOpen?: boolean
+ placement?: Placement
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+export function useTooltip({
+ initialOpen = false,
+ placement = "top",
+ open: controlledOpen,
+ onOpenChange: setControlledOpen,
+}: TooltipOptions = {}) {
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen)
+
+ const open = controlledOpen ?? uncontrolledOpen
+ const setOpen = setControlledOpen ?? setUncontrolledOpen
+
+ const data = useFloating({
+ placement,
+ open,
+ onOpenChange: setOpen,
+ whileElementsMounted: autoUpdate,
+ middleware: [
+ offset(5),
+ flip({
+ crossAxis: placement.includes("-"),
+ fallbackAxisSideDirection: "start",
+ padding: 5,
+ }),
+ shift({ padding: 5 }),
+ ],
+ })
+
+ const context = data.context
+
+ const hover = useHover(context, {
+ move: false,
+ enabled: controlledOpen == null,
+ handleClose: safePolygon({ requireIntent: false }),
+ })
+ const focus = useFocus(context, {
+ enabled: controlledOpen == null,
+ })
+ const dismiss = useDismiss(context)
+ const role = useRole(context, { role: "tooltip" })
+
+ const interactions = useInteractions([hover, focus, dismiss, role])
+
+ return React.useMemo(
+ () => ({
+ open,
+ setOpen,
+ ...interactions,
+ ...data,
+ }),
+ [open, setOpen, interactions, data]
+ )
+}
+
+type ContextType = ReturnType | null
+
+const TooltipContext = React.createContext(null)
+
+export const useTooltipContext = () => {
+ const context = React.useContext(TooltipContext)
+
+ if (context == null) {
+ throw new Error("Tooltip components must be wrapped in ")
+ }
+
+ return context
+}
+
+export function Tooltip({ children, ...options }: { children: React.ReactNode } & TooltipOptions) {
+ // This can accept any props as options, e.g. `placement`,
+ // or other positioning options.
+ const tooltip = useTooltip(options)
+ return {children}
+}
+
+export const TooltipTrigger = React.forwardRef & { asChild?: boolean }>(
+ function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
+ const context = useTooltipContext()
+ const childrenRef = (children as any).ref
+ const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
+
+ // `asChild` allows the user to pass any element as the anchor
+ if (asChild && React.isValidElement(children)) {
+ return React.cloneElement(
+ children,
+ context.getReferenceProps({
+ ref,
+ ...props,
+ ...children.props,
+ "data-state": context.open ? "open" : "closed",
+ })
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+ }
+)
+
+type TooltipContentProps = {
+ padding?: boolean
+} & React.HTMLProps
+
+export const TooltipContent = React.forwardRef(function TooltipContent(
+ { style, padding = true, ...props },
+ propRef
+) {
+ const context = useTooltipContext()
+ const ref = useMergeRefs([context.refs.setFloating, propRef])
+
+ if (!context.open) return null
+
+ return (
+
+
+
+ )
+})
diff --git a/src/hooks/api/admin/useAdminConfirmContest.ts b/src/hooks/api/admin/useAdminConfirmContest.ts
new file mode 100644
index 00000000..6a70f226
--- /dev/null
+++ b/src/hooks/api/admin/useAdminConfirmContest.ts
@@ -0,0 +1,74 @@
+import { DateTime } from "luxon"
+import { useMutation, useQueryClient } from "react-query"
+import { contests as contestsAPI } from "../axios"
+import { adminConfirmContest as adminConfirmContestUrl } from "../urls"
+import { AxiosError } from "axios"
+import { adminContestsQuery } from "./useAdminContests"
+
+type AdminConfirmContestParams = {
+ id: number
+ title: string
+ shortDescription: string
+ startDate: DateTime
+ endDate: DateTime
+ auditRewards: number
+ judgingPrizePool: number
+ leadJudgeFixedPay: number
+ fullPayment: number
+ lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED"
+ customLswFixedPay?: number | null
+ private?: boolean
+ requiresKYC?: boolean
+ maxNumberOfParticipants?: number | null
+ token: string
+ exchangeRate: number
+}
+
+export const useAdminConfirmContest = () => {
+ const queryClient = useQueryClient()
+ const { mutate, mutateAsync, ...mutation } = useMutation(
+ async (params) => {
+ try {
+ await contestsAPI.post(
+ adminConfirmContestUrl(params.id),
+ {
+ title: params.title,
+ short_description: params.shortDescription,
+ starts_at: params.startDate.toSeconds(),
+ ends_at: params.endDate.toSeconds(),
+ audit_rewards: params.auditRewards,
+ judging_prize_pool: params.judgingPrizePool,
+ lead_judge_fixed_pay: params.leadJudgeFixedPay,
+ full_payment: params.fullPayment,
+ lsw_payment_structure: params.lswPaymentStructure,
+ custom_lsw_fixed_pay: params.customLswFixedPay,
+ private: params.private,
+ requires_kyc: params.requiresKYC,
+ max_number_of_participants: params.maxNumberOfParticipants,
+ token: params.token,
+ exchange_rate: params.exchangeRate,
+ },
+
+ {
+ timeout: 5 * 60 * 1000,
+ }
+ )
+ } catch (error) {
+ const axiosError = error as AxiosError
+ throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.")
+ }
+ },
+ {
+ onSuccess: async () => {
+ await queryClient.invalidateQueries(adminContestsQuery("draft"))
+ await queryClient.invalidateQueries(adminContestsQuery("active"))
+ await queryClient.invalidateQueries(adminContestsQuery("finished"))
+ },
+ }
+ )
+
+ return {
+ confirmContest: mutate,
+ ...mutation,
+ }
+}
diff --git a/src/hooks/api/admin/useAdminContest.ts b/src/hooks/api/admin/useAdminContest.ts
new file mode 100644
index 00000000..23aef9e4
--- /dev/null
+++ b/src/hooks/api/admin/useAdminContest.ts
@@ -0,0 +1,31 @@
+import { useQuery } from "react-query"
+import { contests as contestsAPI } from "../axios"
+import { getAdminContest as getAdminContestUrl } from "../urls"
+import { ContestsListItem, GetAdminContestsResponse, parseContest } from "./useAdminContests"
+
+type AdminContestResponse = {
+ contest: GetAdminContestsResponse & {
+ nsloc?: number
+ expected_nsloc?: number
+ context_questions_ready?: boolean
+ }
+}
+
+type AdminContest = ContestsListItem & {
+ nSLOC?: number
+ expectedNSLOC?: number
+ contextQuestionsReady?: boolean
+}
+
+export const adminContestQuery = (contestID: number) => ["admin-contest", contestID]
+export const useAdminContest = (contestID: number) =>
+ useQuery(adminContestQuery(contestID), async () => {
+ const { data } = await contestsAPI.get(getAdminContestUrl(contestID))
+
+ return {
+ ...parseContest(data.contest),
+ nSLOC: data.contest.nsloc,
+ expectedNSLOC: data.contest.expected_nsloc,
+ contextQuestionsReady: data.contest.context_questions_ready,
+ }
+ })
diff --git a/src/hooks/api/admin/useAdminContestScope.ts b/src/hooks/api/admin/useAdminContestScope.ts
index 5762b50c..730abb2f 100644
--- a/src/hooks/api/admin/useAdminContestScope.ts
+++ b/src/hooks/api/admin/useAdminContestScope.ts
@@ -8,7 +8,11 @@ type AdminContestScopeResponse = {
repo_name: string
branch_name: string
commit_hash: string
- files: string[]
+ files: {
+ file_path: string
+ nsloc?: number
+ selected: boolean
+ }[]
solidity_metrics_report: string
nsloc: number
comment_to_source_ratio: number
@@ -17,14 +21,18 @@ type AdminContestScopeResponse = {
export const adminContestScopeQuery = (contestID: number) => ["admin-contest-scope", contestID]
export const useAdminContestScope = (contestID: number) =>
- useQuery(adminContestScopeQuery(contestID), async () => {
+ useQuery(adminContestScopeQuery(contestID), async () => {
const { data } = await contestsAPI.get(getAdminContestScopeUrl(contestID))
return data.scope.map((d) => ({
repoName: d.repo_name,
branchName: d.branch_name,
commitHash: d.commit_hash,
- files: d.files,
+ files: d.files.map((f) => ({
+ filePath: f.file_path,
+ nSLOC: f.nsloc,
+ selected: f.selected,
+ })),
solidityMetricsReport: d.solidity_metrics_report,
nSLOC: d.nsloc,
commentToSourceRatio: d.comment_to_source_ratio,
diff --git a/src/hooks/api/admin/useAdminContestTweetPreview.ts b/src/hooks/api/admin/useAdminContestTweetPreview.ts
index 4020155f..f3e3858d 100644
--- a/src/hooks/api/admin/useAdminContestTweetPreview.ts
+++ b/src/hooks/api/admin/useAdminContestTweetPreview.ts
@@ -4,8 +4,8 @@ import { getAdminContestTweetPreview as getAdminContestTweetPreviewUrl } from ".
export const adminContestTweetPreviewKey = (contestID: number) => ["contest-tweet-preview", contestID]
export const useAdminContestTweetPreview = (contestID: number) =>
- useQuery(adminContestTweetPreviewKey(contestID), async () => {
- const { data } = await contestsAPI.get(getAdminContestTweetPreviewUrl(contestID))
+ useQuery(adminContestTweetPreviewKey(contestID), async () => {
+ const { data } = await contestsAPI.get<{ tweets: string[] }>(getAdminContestTweetPreviewUrl(contestID))
- return data
+ return data.tweets
})
diff --git a/src/hooks/api/admin/useAdminContestVariables.ts b/src/hooks/api/admin/useAdminContestVariables.ts
index 4525899d..f04a196b 100644
--- a/src/hooks/api/admin/useAdminContestVariables.ts
+++ b/src/hooks/api/admin/useAdminContestVariables.ts
@@ -2,21 +2,29 @@ import { useQuery } from "react-query"
import { contests as contestsAPI } from "../axios"
type GetAdminContestVariablesResponse = {
- length: number
- full_payment: number
- contest_rewards: number
- judging_prize_pool: number
+ min_total_rewards: number
+ min_contest_rewards: number
+ min_total_price: number
+ rec_total_rewards: number
+ rec_contest_rewards: number
+ rec_total_price: number
+ spreadsheet_row: number
lead_judge_fixed_pay: number
- admin_fee: number
+ judging_prize_pool: number
+ length: number
}
type ContestVariables = {
- length: number
- fullPayment: number
- auditContestRewards: number
- judgingPrizePool: number
+ minTotalRewards: number
+ minContestRewards: number
+ minTotalPrice: number
+ recTotalRewards: number
+ recContestRewards: number
+ recTotalPrice: number
+ spreadsheetRow: number
leadJudgeFixedPay: number
- adminFee: number
+ judgingPrizePool: number
+ length: number
}
export const adminContestVariablesQueryKey = (nSLOC: number) => ["contest-variables", nSLOC]
@@ -25,11 +33,15 @@ export const useAdminContestVariables = (nSLOC: number) =>
const { data } = await contestsAPI.get(`/admin/contest/variables?nsloc=${nSLOC}`)
return {
- length: data.length,
- fullPayment: data.full_payment,
- auditContestRewards: data.contest_rewards,
- judgingPrizePool: data.judging_prize_pool,
+ minTotalRewards: data.min_total_rewards,
+ minContestRewards: data.min_contest_rewards,
+ minTotalPrice: data.min_total_price,
+ recTotalRewards: data.rec_total_rewards,
+ recContestRewards: data.rec_contest_rewards,
+ recTotalPrice: data.rec_total_price,
+ spreadsheetRow: data.spreadsheet_row,
leadJudgeFixedPay: data.lead_judge_fixed_pay,
- adminFee: data.admin_fee,
+ judgingPrizePool: data.judging_prize_pool,
+ length: data.length,
}
})
diff --git a/src/hooks/api/admin/useAdminContests.ts b/src/hooks/api/admin/useAdminContests.ts
index 8ee32661..909de0c6 100644
--- a/src/hooks/api/admin/useAdminContests.ts
+++ b/src/hooks/api/admin/useAdminContests.ts
@@ -3,15 +3,16 @@ import { contests as contestsAPI } from "../axios"
import { getAdminContests as getAdminContestsUrl } from "../urls"
-export type ContestStatus = "CREATED" | "RUNNING" | "JUDGING" | "FINISHED" | "ESCALATING" | "SHERLOCK_JUDGING"
+export type ContestStatus = "DRAFT" | "CREATED" | "RUNNING" | "JUDGING" | "FINISHED" | "ESCALATING" | "SHERLOCK_JUDGING"
export type ContestsListItem = {
id: number
title: string
+ shortDescription: string
logoURL: string
status: ContestStatus
initialPayment: boolean
- fullPayment: boolean
+ fullPaymentComplete: boolean
adminUpcomingApproved: boolean
adminStartApproved: boolean
dashboardID?: string
@@ -20,14 +21,34 @@ export type ContestsListItem = {
submissionReady: boolean
hasSolidityMetricsReport: boolean
leadSeniorAuditorHandle: string
+ leadSeniorAuditorFixedPay: number | null
leadSeniorSelectionMessageSentAt: number
+ leadSeniorSelectionDate: number
leadSeniorConfirmationMessage: string
auditReport?: string
+ nSLOC?: number
+ expectedNSLOC?: number
+ rewards: number
+ judgingPrizePool: number
+ leadJudgeFixedPay: number
+ fullPayment: number
+ initialScopeSubmitted: boolean
+ initialScopeSubmittedAt: number | null
+ finalScopeSubmitted: boolean
+ telegramChat?: string
+ finalReportAvailable?: boolean
+ lswPaymentStructure: "TIERED" | "BEST_EFFORTS" | "FIXED"
+ customLswFixedPay: number | null
+ private: boolean
+ requiresKYC: boolean
+ maxNumberOfParticipants: number | null
+ token: string
}
-type GetAdminContestsResponse = {
+export type GetAdminContestsResponse = {
id: number
title: string
+ short_description: string
logo_url: string
status: ContestStatus
initial_payment_complete: boolean
@@ -41,34 +62,75 @@ type GetAdminContestsResponse = {
has_solidity_metrics_report: boolean
lead_senior_auditor_handle: string
senior_selection_message_sent_at: number
+ senior_selection_date: number
senior_confirmed_message: string
audit_report?: string
-}[]
+ nsloc?: number
+ expected_nsloc?: number
+ audit_rewards: number
+ judging_prize_pool: number
+ lead_judge_fixed_pay: number
+ full_payment: number
+ initial_scope_submitted: boolean
+ initial_scope_submitted_at: number | null
+ final_scope_submitted: boolean
+ telegram_chat?: string
+ final_report_available?: boolean
+ lsw_payment_structure: "TIERED" | "BEST_EFFORTS" | "FIXED"
+ lead_senior_auditor_fixed_pay: number | null
+ private: boolean
+ requires_kyc: boolean
+ max_number_of_participants: number | null
+ token: string
+}
+
+export type ContestListStatus = "active" | "finished" | "draft"
-export type ContestListStatus = "active" | "finished"
+export const parseContest = (d: GetAdminContestsResponse): ContestsListItem => {
+ return {
+ id: d.id,
+ title: d.title,
+ shortDescription: d.short_description,
+ logoURL: d.logo_url,
+ status: d.status,
+ initialPayment: d.initial_payment_complete,
+ fullPaymentComplete: d.full_payment_complete,
+ adminUpcomingApproved: d.admin_upcoming_approved,
+ adminStartApproved: d.admin_start_approved,
+ dashboardID: d.dashboard_id,
+ startDate: d.starts_at,
+ endDate: d.ends_at,
+ submissionReady: d.protocol_submission_ready,
+ hasSolidityMetricsReport: d.has_solidity_metrics_report,
+ leadSeniorAuditorHandle: d.lead_senior_auditor_handle,
+ leadSeniorSelectionMessageSentAt: d.senior_selection_message_sent_at,
+ leadSeniorSelectionDate: d.senior_selection_date,
+ leadSeniorConfirmationMessage: d.senior_confirmed_message,
+ auditReport: d.audit_report,
+ rewards: d.audit_rewards,
+ judgingPrizePool: d.judging_prize_pool,
+ leadJudgeFixedPay: d.lead_judge_fixed_pay,
+ fullPayment: d.full_payment,
+ initialScopeSubmitted: d.initial_scope_submitted,
+ initialScopeSubmittedAt: d.initial_scope_submitted_at,
+ finalScopeSubmitted: d.final_scope_submitted,
+ nSLOC: d.nsloc,
+ telegramChat: d.telegram_chat,
+ finalReportAvailable: d.final_report_available,
+ lswPaymentStructure: d.lsw_payment_structure,
+ customLswFixedPay: d.lead_senior_auditor_fixed_pay,
+ private: d.private,
+ requiresKYC: d.requires_kyc,
+ maxNumberOfParticipants: d.max_number_of_participants,
+ leadSeniorAuditorFixedPay: d.lead_senior_auditor_fixed_pay,
+ token: d.token,
+ }
+}
export const adminContestsQuery = (status: ContestListStatus) => ["admin-contests", status]
export const useAdminContests = (status: ContestListStatus) =>
useQuery(adminContestsQuery(status), async () => {
- const { data } = await contestsAPI.get(getAdminContestsUrl(status))
+ const { data } = await contestsAPI.get(getAdminContestsUrl(status))
- return data.map((d) => ({
- id: d.id,
- title: d.title,
- logoURL: d.logo_url,
- status: d.status,
- initialPayment: d.initial_payment_complete,
- fullPayment: d.full_payment_complete,
- adminUpcomingApproved: d.admin_upcoming_approved,
- adminStartApproved: d.admin_start_approved,
- dashboardID: d.dashboard_id,
- startDate: d.starts_at,
- endDate: d.ends_at,
- submissionReady: d.protocol_submission_ready,
- hasSolidityMetricsReport: d.has_solidity_metrics_report,
- leadSeniorAuditorHandle: d.lead_senior_auditor_handle,
- leadSeniorSelectionMessageSentAt: d.senior_selection_message_sent_at,
- leadSeniorConfirmationMessage: d.senior_confirmed_message,
- auditReport: d.audit_report,
- }))
+ return data.map(parseContest)
})
diff --git a/src/hooks/api/admin/useAdminCreateContest.ts b/src/hooks/api/admin/useAdminCreateContest.ts
index 9dcb31da..9bd44824 100644
--- a/src/hooks/api/admin/useAdminCreateContest.ts
+++ b/src/hooks/api/admin/useAdminCreateContest.ts
@@ -16,14 +16,7 @@ type AdminCreateContestParams = {
}
contest: {
title: string
- shortDescription: string
- nSLOC: string
- startDate: DateTime
- endDate: DateTime
- auditRewards: number
- judgingPrizePool: number
- leadJudgeFixedPay: number
- fullPayment: number
+ previousContestId?: number | null
}
}
@@ -42,14 +35,7 @@ export const useAdminCreateContest = () => {
website: params.protocol.website,
},
title: params.contest.title,
- short_description: params.contest.shortDescription,
- lines_of_code: params.contest.nSLOC,
- starts_at: params.contest.startDate.toSeconds(),
- ends_at: params.contest.endDate.toSeconds(),
- audit_rewards: params.contest.auditRewards,
- judging_prize_pool: params.contest.judgingPrizePool,
- lead_judge_fixed_pay: params.contest.leadJudgeFixedPay,
- full_payment: params.contest.fullPayment,
+ previous_contest_id: params.contest.previousContestId,
})
} catch (error) {
const axiosError = error as AxiosError
@@ -58,6 +44,7 @@ export const useAdminCreateContest = () => {
},
{
onSuccess: async () => {
+ await queryClient.invalidateQueries(adminContestsQuery("draft"))
await queryClient.invalidateQueries(adminContestsQuery("active"))
},
}
diff --git a/src/hooks/api/admin/useAdminDeleteDraftContest.ts b/src/hooks/api/admin/useAdminDeleteDraftContest.ts
new file mode 100644
index 00000000..ec518a1f
--- /dev/null
+++ b/src/hooks/api/admin/useAdminDeleteDraftContest.ts
@@ -0,0 +1,29 @@
+import { useMutation } from "wagmi"
+import { adminDeleteDraftContest as adminDeleteDraftContestUrl } from "../urls"
+import { contests as contestsAPI } from "../axios"
+import { useQueryClient } from "react-query"
+import { adminContestsQuery } from "./useAdminContests"
+
+type AdminDeleteDraftContestParams = {
+ contestID: number
+}
+
+export const useAdminDeleteDraftContest = () => {
+ const queryClient = useQueryClient()
+
+ const { mutate, mutateAsync, ...mutation } = useMutation(
+ async (params) => {
+ await contestsAPI.delete(adminDeleteDraftContestUrl(params.contestID))
+ },
+ {
+ async onSettled(data, error, params) {
+ await queryClient.invalidateQueries(adminContestsQuery("draft"))
+ },
+ }
+ )
+
+ return {
+ deleteContest: mutate,
+ ...mutation,
+ }
+}
diff --git a/src/hooks/api/admin/useAdminPricing.ts b/src/hooks/api/admin/useAdminPricing.ts
new file mode 100644
index 00000000..f3a12bf8
--- /dev/null
+++ b/src/hooks/api/admin/useAdminPricing.ts
@@ -0,0 +1,69 @@
+import { useQuery } from "react-query"
+import { contests as contestsAPI } from "../axios"
+
+type PricingType = "MINIMUM" | "RECOMMENDED"
+
+type GetAdminPricingResponse = {
+ audit_contest_rewards: number
+ bottom_3rd_contest_pot: number
+ bottom_3rd_lsw_pay: number
+ judging_prize_pool: number
+ lead_judge_fixed_pay: number
+ length: number
+ middle_3rd_contest_pot: number
+ middle_3rd_lsw_pay: number
+ referral_fee: number
+ reserved_auditor_fixed_pay: number | null
+ sherlock_fee: number
+ top_3rd_contest_pot: number
+ top_3rd_lsw_pay: number
+ total_price: number
+ total_rewards: number
+ type: PricingType
+ exchangeRate: number
+}[]
+
+type Pricing = {
+ minTotalRewards: number
+ minContestRewards: number
+ minTotalPrice: number
+ minLeadJudgeFixedPay: number
+ minJudgingPrizePool: number
+ recTotalRewards: number
+ recContestRewards: number
+ recTotalPrice: number
+ recLeadJudgeFixedPay: number
+ recJudgingPrizePool: number
+ length: number
+ exchangeRate: number
+}
+
+export const adminPricingQueryKey = (nSLOC: number, token: string) => ["contest-variables", nSLOC, token]
+export const useAdminPricing = (nSLOC: number, token: string) =>
+ useQuery(adminPricingQueryKey(nSLOC, token), async (): Promise => {
+ const { data } = await contestsAPI.get(
+ `/internal/simulate-pricing?nsloc=${nSLOC}&token=${token}`
+ )
+
+ const minimumPricing = data.find((p) => p.type === "MINIMUM")
+ const recommendedPricing = data.find((p) => p.type === "RECOMMENDED")
+
+ if (!minimumPricing || !recommendedPricing) {
+ throw Error("Missing pricing data")
+ }
+
+ return {
+ minTotalRewards: minimumPricing.total_rewards,
+ minContestRewards: minimumPricing.audit_contest_rewards,
+ minTotalPrice: minimumPricing.total_price,
+ minLeadJudgeFixedPay: minimumPricing.lead_judge_fixed_pay,
+ minJudgingPrizePool: minimumPricing.judging_prize_pool,
+ recTotalRewards: recommendedPricing.total_rewards,
+ recContestRewards: recommendedPricing.audit_contest_rewards,
+ recTotalPrice: recommendedPricing.total_price,
+ recLeadJudgeFixedPay: recommendedPricing.lead_judge_fixed_pay,
+ recJudgingPrizePool: recommendedPricing?.judging_prize_pool,
+ length: minimumPricing.length,
+ exchangeRate: minimumPricing.exchangeRate,
+ }
+ })
diff --git a/src/hooks/api/admin/useAdminProtocolContests.ts b/src/hooks/api/admin/useAdminProtocolContests.ts
new file mode 100644
index 00000000..67c2c77d
--- /dev/null
+++ b/src/hooks/api/admin/useAdminProtocolContests.ts
@@ -0,0 +1,22 @@
+import { useQuery } from "react-query"
+import { contests as contestsAPI } from "../axios"
+import { GetAdminContestsResponse, parseContest } from "./useAdminContests"
+import { getAdminProtocolContests } from "../urls"
+
+export const adminProtocolContestsKey = (protocolID: number) => ["protocol-last-contest", protocolID]
+export const useAdminProtocolContests = (protocolID: number | undefined) =>
+ useQuery(
+ adminProtocolContestsKey(protocolID ?? -1),
+ async () => {
+ const { data } = await contestsAPI.get(getAdminProtocolContests(protocolID ?? -1))
+
+ if (!data) {
+ return null
+ }
+
+ return data?.map(parseContest)
+ },
+ {
+ enabled: !!protocolID,
+ }
+ )
diff --git a/src/hooks/api/admin/useAdminResetQA.ts b/src/hooks/api/admin/useAdminResetQA.ts
new file mode 100644
index 00000000..6bb9231b
--- /dev/null
+++ b/src/hooks/api/admin/useAdminResetQA.ts
@@ -0,0 +1,30 @@
+import { useMutation } from "wagmi"
+import { adminResetQA as adminResetQAUrl } from "../urls"
+import { contests as contestsAPI } from "../axios"
+import { useQueryClient } from "react-query"
+import { adminContestsQuery } from "./useAdminContests"
+import { adminContestQuery } from "./useAdminContest"
+
+type AdminResetQAParams = {
+ contestID: number
+}
+
+export const useAdminResetQA = () => {
+ const queryClient = useQueryClient()
+
+ const { mutate, mutateAsync, ...mutation } = useMutation(
+ async (params) => {
+ await contestsAPI.post(adminResetQAUrl(params.contestID))
+ },
+ {
+ async onSettled(data, error, params) {
+ await queryClient.invalidateQueries(adminContestQuery(params.contestID))
+ },
+ }
+ )
+
+ return {
+ resetQA: mutate,
+ ...mutation,
+ }
+}
diff --git a/src/hooks/api/admin/useAdminResetScope.ts b/src/hooks/api/admin/useAdminResetScope.ts
new file mode 100644
index 00000000..2540d5cb
--- /dev/null
+++ b/src/hooks/api/admin/useAdminResetScope.ts
@@ -0,0 +1,30 @@
+import { useMutation } from "wagmi"
+import { adminResetScope as adminResetScopeUrl } from "../urls"
+import { contests as contestsAPI } from "../axios"
+import { useQueryClient } from "react-query"
+import { adminContestsQuery } from "./useAdminContests"
+
+type AdminResetScopeParams = {
+ contestID: number
+ scopeType: "initial" | "final"
+}
+
+export const useAdminResetScope = () => {
+ const queryClient = useQueryClient()
+
+ const { mutate, mutateAsync, ...mutation } = useMutation(
+ async (params) => {
+ await contestsAPI.delete(adminResetScopeUrl(params.contestID, params.scopeType))
+ },
+ {
+ async onSettled(data, error, params) {
+ await queryClient.invalidateQueries(adminContestsQuery(params.scopeType === "initial" ? "draft" : "active"))
+ },
+ }
+ )
+
+ return {
+ resetScope: mutate,
+ ...mutation,
+ }
+}
diff --git a/src/hooks/api/admin/useAdminSubmitScope.ts b/src/hooks/api/admin/useAdminSubmitScope.ts
index 588c17d4..e7d7aa5b 100644
--- a/src/hooks/api/admin/useAdminSubmitScope.ts
+++ b/src/hooks/api/admin/useAdminSubmitScope.ts
@@ -9,19 +9,14 @@ type AdminSubmitScopeParams = {
branchName: string
commitHash: string
files: string[]
+ nSLOCAdjustment?: number
}
type AdminSubmitScopeResponse = {
- report: {
- url: string
- nSLOC: number
- }
+ report: string
}
-type Report = {
- url: string
- nSLOC: number
-}
+type Report = string
export const useAdminSubmitScope = () => {
const {
@@ -38,16 +33,14 @@ export const useAdminSubmitScope = () => {
branch_name: params.branchName,
commit_hash: params.commitHash,
files: params.files,
+ nsloc_adjustment: params.nSLOCAdjustment,
},
{
timeout: 5 * 60 * 1000,
}
)
- return {
- url: data.report.url,
- nSLOC: data.report.nSLOC,
- }
+ return data.report
} catch (error) {
const axiosError = error as AxiosError
throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.")
diff --git a/src/hooks/api/admin/useAdminUpdateContest.ts b/src/hooks/api/admin/useAdminUpdateContest.ts
new file mode 100644
index 00000000..699a66e0
--- /dev/null
+++ b/src/hooks/api/admin/useAdminUpdateContest.ts
@@ -0,0 +1,66 @@
+import { DateTime } from "luxon"
+import { useMutation, useQueryClient } from "react-query"
+import { contests as contestsAPI } from "../axios"
+import { adminUpdateContest as adminUpdateContestUrl } from "../urls"
+import { AxiosError } from "axios"
+import { adminContestsQuery } from "./useAdminContests"
+
+type AdminUpdateContestParams = {
+ id: number
+ title: string
+ shortDescription: string
+ startDate: DateTime
+ endDate: DateTime
+ auditRewards: number
+ judgingPrizePool: number
+ leadJudgeFixedPay: number
+ fullPayment: number
+ lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED"
+ customLswFixedPay?: number | null
+ private?: boolean
+ requiresKYC?: boolean
+ maxNumberOfParticipants?: number | null
+ token: string
+ exchangeRate: number
+}
+
+export const useAdminUpdateContest = () => {
+ const queryClient = useQueryClient()
+ const { mutate, mutateAsync, ...mutation } = useMutation(
+ async (params) => {
+ try {
+ await contestsAPI.put(adminUpdateContestUrl(params.id), {
+ title: params.title,
+ short_description: params.shortDescription,
+ starts_at: params.startDate.toSeconds(),
+ ends_at: params.endDate.toSeconds(),
+ audit_rewards: params.auditRewards,
+ judging_prize_pool: params.judgingPrizePool,
+ lead_judge_fixed_pay: params.leadJudgeFixedPay,
+ full_payment: params.fullPayment,
+ lsw_payment_structure: params.lswPaymentStructure,
+ custom_lsw_fixed_pay: params.customLswFixedPay,
+ private: params.private,
+ requires_kyc: params.requiresKYC,
+ max_number_of_participants: params.maxNumberOfParticipants,
+ token: params.token,
+ exchange_rate: params.exchangeRate,
+ })
+ } catch (error) {
+ const axiosError = error as AxiosError
+ throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.")
+ }
+ },
+ {
+ onSuccess: async () => {
+ await queryClient.invalidateQueries(adminContestsQuery("active"))
+ await queryClient.invalidateQueries(adminContestsQuery("finished"))
+ },
+ }
+ )
+
+ return {
+ updateContest: mutate,
+ ...mutation,
+ }
+}
diff --git a/src/hooks/api/contests.ts b/src/hooks/api/contests.ts
index b2d4c820..9196e080 100644
--- a/src/hooks/api/contests.ts
+++ b/src/hooks/api/contests.ts
@@ -27,7 +27,6 @@ export type Contest = {
judgingPrizePool?: number
jugdingEndDate?: number // Timestamp in seconds.
repo: string
- linesOfCode?: string
rewards: number
judgingRepo: string
escalationStartDate?: number // Timestamp in seconds.
@@ -118,7 +117,6 @@ type GetContestResponseData = {
judging_prize_pool: number | null
judging_ends_at?: number
template_repo_name: string
- lines_of_code: string
lead_judge_handle: string
lead_judge_fixed_pay: number
rewards: number
@@ -153,7 +151,6 @@ export const useContest = (id: number) =>
judgingPrizePool: response.judging_prize_pool ?? undefined,
jugdingEndDate: response.judging_ends_at,
repo: response.template_repo_name,
- linesOfCode: response.lines_of_code,
rewards: response.rewards,
leadJudgeHandle: response.lead_judge_handle,
leadJudgeFixedPay: response.lead_judge_fixed_pay,
diff --git a/src/hooks/api/contests/useProtocolDashboard.ts b/src/hooks/api/contests/useProtocolDashboard.ts
index 3877e8f4..bc88ab50 100644
--- a/src/hooks/api/contests/useProtocolDashboard.ts
+++ b/src/hooks/api/contests/useProtocolDashboard.ts
@@ -24,7 +24,6 @@ export type ContestDetails = {
submissionReady: boolean
scopeReady: boolean
startApproved: boolean
- linesOfCode: string
initialPaymentComplete: boolean
fullPaymentComplete: boolean
teamHandlesAdded: boolean
@@ -60,7 +59,6 @@ type PaymentsResponse = {
protocol_submission_ready: boolean
scope_ready: boolean
admin_start_approved: boolean
- lines_of_code: string
judging_prize_pool: number
rewards: number
initial_payment_complete: boolean
@@ -100,7 +98,6 @@ export const useProtocolDashboard = (dashboardID: string) =>
submissionReady: data.contest.protocol_submission_ready,
scopeReady: data.contest.scope_ready,
startApproved: data.contest.admin_start_approved,
- linesOfCode: data.contest.lines_of_code,
initialPaymentComplete: data.contest.initial_payment_complete,
fullPaymentComplete: data.contest.full_payment_complete,
teamHandlesAdded: data.contest.team_handles_added,
diff --git a/src/hooks/api/scope/useDeleteScope.ts b/src/hooks/api/scope/useDeleteScope.ts
index 6ab4e3ad..7049ef2c 100644
--- a/src/hooks/api/scope/useDeleteScope.ts
+++ b/src/hooks/api/scope/useDeleteScope.ts
@@ -36,7 +36,7 @@ export const useDeleteScope = () => {
const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID))
- queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) =>
+ queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) =>
previous?.filter((s) => s.repoName !== params.repoName)
)
diff --git a/src/hooks/api/scope/useRepositoryContracts.ts b/src/hooks/api/scope/useRepositoryContracts.ts
index c54007c8..78edd0c1 100644
--- a/src/hooks/api/scope/useRepositoryContracts.ts
+++ b/src/hooks/api/scope/useRepositoryContracts.ts
@@ -2,58 +2,121 @@ import { useQuery } from "react-query"
import { contests as contestsAPI } from "../axios"
import { getRepositoryContracts as getRepositoryContractsUrl } from "../urls"
-type GetRepositoryContractsResponse = string[]
+type GetRepositoryContractsResponse = {
+ file_path: string
+ nsloc?: number
+}[]
-export type TreeValue = Tree | string
-export interface Tree extends Map {}
-
-export const getAllTreePaths = (parentPath: string, tree: TreeValue) => {
- if (typeof tree === "string") {
- return [`${parentPath}`]
- }
+type File = {
+ filepath: string
+ nsloc?: number
+}
- let paths: string[] = []
+interface BaseEntry {
+ name: string
+}
- tree.forEach((value, key) => {
- paths = [...paths, ...getAllTreePaths(`${parentPath}/${key}`, value)]
- })
+export interface FileEntry extends BaseEntry {
+ type: "file"
+ filepath: string
+ nsloc?: number
+}
- return paths
+interface DirectoryEntry extends BaseEntry {
+ type: "directory"
+ entries: Array
}
-type Files = {
- tree: Tree
- rawPaths: string[]
+export type Entry = FileEntry | DirectoryEntry
+
+type AnyEntry = RootDirectory | DirectoryEntry
+
+export interface RootDirectory {
+ type: "root"
+ entries: Array
}
-export const convertToTree = (paths: string[]) => {
- const tree: Tree = new Map()
+export const convertToTree2 = (files: File[]) => {
+ const root: RootDirectory = {
+ type: "root",
+ entries: [],
+ }
+
+ files.forEach(({ filepath, nsloc }) => {
+ const parts = filepath.split("/")
+
+ let current: AnyEntry = root
- paths.forEach((d) => {
- const parts = d.split("/")
- let current: Tree = tree
parts.forEach((p) => {
if (p.endsWith(".sol") || p.endsWith(".vy")) {
- current.set(p, p)
+ current.entries.push({
+ type: "file",
+ name: p,
+ filepath,
+ nsloc,
+ })
} else {
- if (!current.get(p)) current.set(p, new Map())
- current = current.get(p) as Tree
+ let directory: DirectoryEntry | undefined = current.entries.find(
+ (e) => e.type === "directory" && e.name === p
+ ) as DirectoryEntry
+
+ if (!directory) {
+ directory = {
+ type: "directory",
+ name: p,
+ entries: [],
+ }
+ current.entries.push(directory)
+ }
+
+ current = directory
}
})
})
- return tree
+ return root
+}
+
+export type TreeValue = Tree | string
+export interface Tree extends Map {}
+
+export const getAllTreePaths = (parentPath: string, tree: Entry) => {
+ if (tree.type === "file") {
+ return [`${parentPath}`]
+ }
+
+ let paths: string[] = []
+
+ tree.entries.forEach((value, key) => {
+ paths = [...paths, ...getAllTreePaths(`${parentPath}/${value.name}`, value)]
+ })
+
+ return paths
+}
+
+type RepositoryContracts = {
+ tree: RootDirectory
+ rawPaths: string[]
}
export const repositoryContractsQuery = (repo: string, commit: string) => ["repository-contracts", repo, commit]
export const useRepositoryContracts = (repo: string, commit: string) =>
- useQuery(repositoryContractsQuery(repo, commit), async () => {
- const { data } = await contestsAPI.get(getRepositoryContractsUrl(repo, commit))
+ useQuery(
+ repositoryContractsQuery(repo, commit),
+ async () => {
+ const { data } = await contestsAPI.get(getRepositoryContractsUrl(repo, commit), {
+ timeout: 5 * 60 * 1000,
+ })
- const tree = convertToTree(data)
+ const tree = convertToTree2(data.map((f) => ({ filepath: f.file_path, nsloc: f.nsloc })))
- return {
- tree,
- rawPaths: data,
+ return {
+ tree,
+ rawPaths: data.map((f) => f.file_path),
+ }
+ },
+ {
+ enabled: !!repo && !!commit,
+ refetchOnWindowFocus: false,
}
- })
+ )
diff --git a/src/hooks/api/scope/useScope.ts b/src/hooks/api/scope/useScope.ts
index 68ffd22a..0388ac05 100644
--- a/src/hooks/api/scope/useScope.ts
+++ b/src/hooks/api/scope/useScope.ts
@@ -6,38 +6,59 @@ export type Scope = {
repoName: string
branchName: string
commitHash: string
- files: string[]
+ files: {
+ filePath: string
+ nSLOC?: number
+ selected: boolean
+ }[]
solidityMetricsReport?: string
- nSLOC?: number
commentToSourceRatio?: number
-}[]
+ initialScope?: Scope
+ nSLOC?: number
+}
-export type GetScopeResponse = {
- scope: {
- repo_name: string
- branch_name: string
- commit_hash: string
- files: string[]
+type ScopeResponse = {
+ repo_name: string
+ branch_name: string
+ commit_hash: string
+ files: {
+ file_path: string
nsloc?: number
- comment_to_source_ratio?: number
+ selected: boolean
}[]
+ comment_to_source_ratio?: number
+ initial_scope?: Omit
+ nsloc?: number
+}
+
+export type GetScopeResponse = {
+ scope: ScopeResponse[]
+}
+
+function parseScope(d: ScopeResponse): Scope {
+ return {
+ repoName: d.repo_name,
+ branchName: d.branch_name,
+ commitHash: d.commit_hash,
+ files: d.files.map((f) => ({
+ filePath: f.file_path,
+ nSLOC: f.nsloc,
+ selected: f.selected,
+ })),
+ commentToSourceRatio: d.comment_to_source_ratio,
+ initialScope: d.initial_scope ? parseScope(d.initial_scope) : undefined,
+ nSLOC: d.nsloc,
+ }
}
export const scopeQueryKey = (dashboardID?: string) => ["scope", dashboardID]
export const useScope = (dashboardID?: string) =>
- useQuery(
+ useQuery(
scopeQueryKey(dashboardID),
async () => {
const { data } = await contestsAPI.get(getScopeUrl(dashboardID ?? ""))
- return data.scope.map((d) => ({
- repoName: d.repo_name,
- branchName: d.branch_name,
- commitHash: d.commit_hash,
- files: d.files,
- nSLOC: d.nsloc,
- commentToSourceRatio: d.comment_to_source_ratio,
- }))
+ return data.scope.map(parseScope)
},
{
enabled: !!dashboardID,
diff --git a/src/hooks/api/scope/useUpdateScope.ts b/src/hooks/api/scope/useUpdateScope.ts
index 07b862f3..97a205ac 100644
--- a/src/hooks/api/scope/useUpdateScope.ts
+++ b/src/hooks/api/scope/useUpdateScope.ts
@@ -14,7 +14,7 @@ type UpdateScopeParams = {
}
type UpdateScopeContext = {
- previousScope?: Scope
+ previousScope?: Scope[]
}
export const useUpdateScope = () => {
@@ -38,11 +38,9 @@ export const useUpdateScope = () => {
},
{
onMutate: async (params) => {
- await queryClient.invalidateQueries(scopeQueryKey(params.protocolDashboardID))
+ const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID))
- const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID))
-
- queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => {
+ queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => {
if (!previous) return
const scopeIndex = previous.findIndex((s) => s.repoName === params.repoName)
@@ -54,7 +52,13 @@ export const useUpdateScope = () => {
repoName: previous[scopeIndex].repoName,
branchName: params.branchName ?? previous[scopeIndex].branchName,
commitHash: params.commitHash ?? previous[scopeIndex].commitHash,
- files: params.files ?? previous[scopeIndex].files,
+ files: params.files
+ ? previous[scopeIndex].files.map((f) => ({
+ ...f,
+ selected: params.files!.includes(f.filePath),
+ }))
+ : previous[scopeIndex].files,
+ initialScope: previous[scopeIndex].initialScope,
},
...previous.slice(scopeIndex + 1),
]
diff --git a/src/hooks/api/stats.ts b/src/hooks/api/stats.ts
index 142471be..ea63cb7a 100644
--- a/src/hooks/api/stats.ts
+++ b/src/hooks/api/stats.ts
@@ -5,6 +5,7 @@ import {
getAPYOverTime as getAPYOverTimeUrl,
getTVCOverTime as getTVCOverTimeUrl,
getTVLOverTime as getTVLOverTimeUrl,
+ getExternalCoverageOverTime as getExternalCoverageOverTimeUrl,
} from "./urls"
type APYDataPoint = {
@@ -81,6 +82,22 @@ export const useTVCOverTime = () =>
.sort((a, b) => a.timestamp - b.timestamp)
})
+export const externalCoverageOverTimeQueryKey = "externalCoverageOverTime"
+export const useExternalCoverageOverTime = () =>
+ useQuery[] | null, Error>(externalCoverageOverTimeQueryKey, async () => {
+ const { data: response } = await axios.get(getExternalCoverageOverTimeUrl())
+
+ if (response.ok === false) throw Error(response.error)
+ if (response.data === null) return null
+
+ return response.data
+ .map((r) => ({
+ timestamp: r.timestamp,
+ value: BigNumber.from(r.value),
+ }))
+ .sort((a, b) => a.timestamp - b.timestamp)
+ })
+
export const tvlOverTimeQueryKey = "tvlOverTime"
export const useTVLOverTime = () =>
useQuery[] | null, Error>(tvlOverTimeQueryKey, async () => {
diff --git a/src/hooks/api/urls.ts b/src/hooks/api/urls.ts
index 03afa6b9..60c0935a 100644
--- a/src/hooks/api/urls.ts
+++ b/src/hooks/api/urls.ts
@@ -5,6 +5,7 @@ export const getUnlockOverTime = () => "stats/unlock"
export const getAPYOverTime = () => "stats/apy"
export const getTVLOverTime = () => "stats_tvl"
export const getTVCOverTime = () => "stats_tvc"
+export const getExternalCoverageOverTime = () => "stats_external_coverage"
export const getStakePositions = (account?: string) => (account ? `staking/${account}` : "staking")
export const getCoveredProtocols = () => "protocols"
export const getLastIndexedBlock = () => `last-block-indexed`
@@ -67,13 +68,21 @@ export const adminApproveStart = () => `/admin/approve_start`
export const getAdminProtocol = (name: string) => `/admin/protocol/${name}`
export const getAdminContestScope = (contestID: number) => `/admin/contest/${contestID}/scope`
export const adminCreateContest = () => `/admin/contests`
+export const adminUpdateContest = (contestID: number) => `/admin/contests/${contestID}`
+export const adminConfirmContest = (contestID: number) => `/admin/contests/${contestID}/confirm`
+export const adminResetScope = (contestID: number, scopeType: "initial" | "final") =>
+ `/admin/contests/${contestID}/scope/${scopeType}`
+export const adminResetQA = (contestID: number) => `/admin/contests/${contestID}/reset_qa`
export const getAdminTwitterAccount = (handle: string) => `/admin/twitter_account/${handle}`
+export const getAdminProtocolContests = (protocolID: number) => `/admin/protocol_contests/${protocolID}`
export const adminSubmitScope = () => `/admin/scope`
export const getSeniorWatson = (handle: string) => `/admin/senior_watson?handle=${handle}`
export const adminStartLeadSeniorWatsonSelection = () => `/admin/start_lead_senior_watson_selection`
export const adminSelectLeadSeniorWatson = () => `/admin/select_lead_senior_watson`
export const adminGenerateReport = (contestID: number) => `/admin/contest/${contestID}/report/generate`
export const adminPublishReport = (contestID: number) => `/admin/contest/${contestID}/report/publish`
+export const adminDeleteDraftContest = (contestID: number) => `/admin/contests/${contestID}`
+export const getAdminContest = (contestID: number) => `/admin/contests/${contestID}`
// Stats
export const getLeaderboard = () => "/stats/leaderboard"
diff --git a/src/pages/AuditScope/AuditScope.module.scss b/src/pages/AuditScope/AuditScope.module.scss
index 1359cf25..a84bf468 100644
--- a/src/pages/AuditScope/AuditScope.module.scss
+++ b/src/pages/AuditScope/AuditScope.module.scss
@@ -77,6 +77,11 @@
opacity: 1;
}
}
+
+ .addedNSLOC {
+ color: #58c322;
+ font-weight: bold;
+ }
}
}
}
@@ -104,3 +109,20 @@
}
}
}
+
+.nslocDiff {
+ color: #ffa837 !important;
+}
+
+.fileAdded {
+ color: #91e467 !important;
+}
+
+.fileRemoved {
+ color: #ef3131 !important;
+ opacity: 1 !important;
+}
+
+.fileSame {
+ color: $primary-purple !important;
+}
diff --git a/src/pages/AuditScope/AuditScope.tsx b/src/pages/AuditScope/AuditScope.tsx
index 02df44a4..56b97f0c 100644
--- a/src/pages/AuditScope/AuditScope.tsx
+++ b/src/pages/AuditScope/AuditScope.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"
-import { FaGithub, FaTrashAlt } from "react-icons/fa"
+import { FaCheckCircle, FaGithub, FaMinusCircle, FaPlusCircle, FaRegDotCircle, FaTrashAlt } from "react-icons/fa"
import { Box } from "../../components/Box"
import { Button } from "../../components/Button"
import { Input } from "../../components/Input"
@@ -21,6 +21,9 @@ import { AuditScopeReadOnly } from "./AuditScopeReadOnly"
import Modal, { Props as ModalProps } from "../../components/Modal/Modal"
import { useSubmitScope } from "../../hooks/api/contests/useSubmitScope"
import { ErrorModal } from "../ContestDetails/ErrorModal"
+import { convertToTree2 } from "../../hooks/api/scope/useRepositoryContracts"
+
+import styles from "./AuditScope.module.scss"
type Props = ModalProps & {
dashboardID: string
@@ -106,8 +109,10 @@ export const AuditScope = () => {
const handlePathSelected = useCallback(
(repo: string, paths: string[]) => {
- console.log(paths)
- let selectedPaths = scope?.find((s) => s.repoName === repo)?.files
+ let selectedPaths = scope
+ ?.find((s) => s.repoName === repo)
+ ?.files.filter((f) => f.selected)
+ .map((f) => f.filePath)
if (!selectedPaths) return
@@ -179,14 +184,14 @@ export const AuditScope = () => {
)
const handleSelectAll = useCallback(
- (repoName: string, files: string[]) => {
- const repo = scope?.find((s) => s.repoName === repoName)
- if (!repo) return
+ (repoName: string) => {
+ const s = scope?.find((s) => s.repoName === repoName)
+ if (!s) return
updateScope({
protocolDashboardID: dashboardID ?? "",
repoName,
- files,
+ files: s.files.map((f) => f.filePath),
})
},
[updateScope, dashboardID, scope]
@@ -226,80 +231,155 @@ export const AuditScope = () => {
{scope && scope.length > 0 ? (
-
-
- Branches & commits
-
- Select branch and commit hash
-
-
-
- Repo
+ <>
+
+
+ Branches & commits
+
+ Select branch and commit hash
+
+
+
+ Repo
+
+ {scope?.map((s) => (
+
+ ))}
+
+
+
+ Branch
+
+ {scope?.map((s) => (
+
+ ))}
+
+
+
+ Commit hash
+
+ {scope?.map((s) => (
+
+ ))}
+
+
+ Remove
+ {scope?.map((s) => (
+
+ ))}
+
+
+
+
+
+
+
+ Legend
+
+
+
+
+ File selected in both original and current scope. No change in nSLOC.
+
+
+
+
+ File selected in both original and current scope. nSLOC changed.
+
+
+
+ File added to scope
+
+
+
+ File removed from scope
+
+
+
+
+ >
+ ) : null}
+ {scope?.map((s) => {
+ const selectedNSLOC = s.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0)
+ const initialNSLOC = s.initialScope?.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0)
+ const differenceNSLOC = initialNSLOC && selectedNSLOC - initialNSLOC
+
+ return (
+
+
+
+
+
+ {s.repoName}
+
+
+
+ Original Scope from Quote
+
+
+
+ Files: {s.initialScope?.files.filter((f) => f.selected).length}
- {scope?.map((s) => (
-
- ))}
-
-
-
- Branch
+
+ nSLOC: {initialNSLOC}
- {scope?.map((s) => (
-
- ))}
-
-
-
- Commit hash
+
+
+
+
+ Currently Selected Scope
+
+
+
+ Files: {s.files.filter((f) => f.selected).length}
- {scope?.map((s) => (
-
- ))}
-
-
- Remove
- {scope?.map((s) => (
-
- ))}
-
-
+
+ nSLOC: {s.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0)}
+
+
+
+
+ {differenceNSLOC ? (
+
+
+ Changes from Original Scope:
+
+ 0 ? "success" : "info"}>
+ {`${differenceNSLOC > 0 ? "+" : ""}${differenceNSLOC}`} nSLOC
+
+
+ ) : null}
+ ({
+ filepath: f.filePath,
+ nsloc: f.nSLOC ?? 0,
+ })) ?? []
+ )}
+ onPathSelected={(paths) => handlePathSelected(s.repoName, paths)}
+ selectedPaths={s.files.filter((f) => f.selected).map((f) => f.filePath)}
+ onSelectAll={() => handleSelectAll(s.repoName)}
+ onClearSelection={() => handleClearSelection(s.repoName)}
+ initialScope={s.initialScope}
+ />
-
-
- ) : null}
- {scope?.map((s) => (
-
-
-
-
-
- {s.repoName}
-
- handlePathSelected(s.repoName, paths)}
- selectedPaths={s.files}
- onSelectAll={(files) => handleSelectAll(s.repoName, files)}
- onClearSelection={() => handleClearSelection(s.repoName)}
- />
-
-
- ))}
+
+ )
+ })}
diff --git a/src/pages/AuditScope/RepositoryContractsSelector.tsx b/src/pages/AuditScope/RepositoryContractsSelector.tsx
index 50c5433f..d8d4d638 100644
--- a/src/pages/AuditScope/RepositoryContractsSelector.tsx
+++ b/src/pages/AuditScope/RepositoryContractsSelector.tsx
@@ -1,28 +1,41 @@
import { useCallback, useMemo, useState } from "react"
-import { FaCaretDown, FaCaretRight, FaCheckCircle, FaFile, FaFolder, FaRegFile } from "react-icons/fa"
+import {
+ FaCaretDown,
+ FaCaretRight,
+ FaCheckCircle,
+ FaDotCircle,
+ FaFile,
+ FaFolder,
+ FaMinusCircle,
+ FaPlusCircle,
+ FaRegDotCircle,
+ FaRegFile,
+} from "react-icons/fa"
import cx from "classnames"
import { Column, Row } from "../../components/Layout"
import { Text } from "../../components/Text"
-import { getAllTreePaths, TreeValue, useRepositoryContracts } from "../../hooks/api/scope/useRepositoryContracts"
+import { Entry, FileEntry, getAllTreePaths, RootDirectory } from "../../hooks/api/scope/useRepositoryContracts"
import styles from "./AuditScope.module.scss"
import { Button } from "../../components/Button"
+import { Scope } from "../../hooks/api/scope/useScope"
type Props = {
- repo: string
- commit: string
+ tree: RootDirectory
selectedPaths: string[]
onPathSelected: (paths: string[]) => void
onClearSelection: () => void
- onSelectAll: (files: string[]) => void
+ onSelectAll: () => void
+ initialScope?: Scope
}
type TreeEntryProps = {
name: string
parentPath?: string
- tree: TreeValue
+ tree: Entry
onToggleCollapse?: (paths: string) => void
collapsedEntries?: string[]
+ initialScope?: Scope
} & (
| { selectedPaths: string[]; onPathSelected: (path: string[]) => void; readOnly?: false }
| {
@@ -32,6 +45,27 @@ type TreeEntryProps = {
}
)
+const FileIcon: React.FC<{
+ entry: FileEntry
+ selected: boolean
+ initialScope?: { nSLOC?: number; selected: boolean }
+}> = ({ entry, selected, initialScope }) => {
+ if (!selected && !initialScope?.selected) return
+
+ if (selected) {
+ if (initialScope?.selected) {
+ if (entry.nsloc !== initialScope.nSLOC) {
+ return
+ }
+ return
+ } else {
+ return
+ }
+ }
+
+ return
+}
+
export const TreeEntry: React.FC = ({
name,
tree,
@@ -41,10 +75,11 @@ export const TreeEntry: React.FC = ({
collapsedEntries = [],
selectedPaths = [],
readOnly = false,
+ initialScope,
}) => {
const handleFileClick = useCallback(
(paths: string[]) => {
- typeof tree === "string" ? onPathSelected?.(paths.map((p) => `${parentPath}/${p}`)) : onPathSelected?.(paths)
+ tree.type === "file" ? onPathSelected?.(paths.map((p) => `${parentPath}/${p}`)) : onPathSelected?.(paths)
},
[parentPath, onPathSelected, tree]
)
@@ -54,23 +89,46 @@ export const TreeEntry: React.FC = ({
[parentPath, tree, name]
)
const allSelected = useMemo(() => allSubPaths.every((p) => selectedPaths.includes(p)), [allSubPaths, selectedPaths])
+ const allInInitialScope = useMemo(
+ () => initialScope?.files.filter((f) => f.selected).map((f) => f.filePath) ?? [],
+ [initialScope]
+ )
const isCollapsed = collapsedEntries.includes(`${parentPath}/${name}`)
- if (typeof tree === "string") {
- const selected = selectedPaths.includes(parentPath !== "" ? `${parentPath}/${tree}` : tree)
+ if (tree.type === "file") {
+ const selected = selectedPaths.includes(parentPath !== "" ? `${parentPath}/${tree.name}` : tree.name)
+ const initialScopeFile = initialScope?.files.find((f) => f.filePath === tree.filepath)
+ const diffWithInitialScope =
+ initialScopeFile && initialScopeFile.nSLOC && tree.nsloc && tree.nsloc - initialScopeFile.nSLOC
+
return (
- handleFileClick([tree])}>
+ handleFileClick([tree.name])}>
{selected ? : }
- {tree}
+ {tree.name}
- {selected && (
-
-
+
+ {initialScope ? (
+
+ {initialScopeFile?.nSLOC ?? "NA"}
+
+ ) : null}
+
+ {tree.nsloc}
- )}
+ {initialScope ? (
+ {`${diffWithInitialScope && diffWithInitialScope > 0 ? "+" : ""}${
+ diffWithInitialScope === undefined || diffWithInitialScope === 0 ? "NA" : diffWithInitialScope
+ }`}
+ ) : null}
+
+
)
@@ -78,17 +136,18 @@ export const TreeEntry: React.FC = ({
const entryElements: React.ReactNode[] = []
- tree.forEach((value, key) => {
+ tree.entries.forEach((value, key) => {
entryElements.push(
)
})
@@ -112,14 +171,13 @@ export const TreeEntry: React.FC = ({
}
export const RepositoryContractsSelector: React.FC = ({
- repo,
- commit,
+ tree,
selectedPaths = [],
onPathSelected,
onClearSelection,
onSelectAll,
+ initialScope,
}) => {
- const { data, isLoading } = useRepositoryContracts(repo, commit)
const [collapsedEntries, setCollapsedEntries] = useState([])
const handleToggleCollapse = useCallback((path: string) => {
@@ -134,47 +192,28 @@ export const RepositoryContractsSelector: React.FC = ({
const treeElements: React.ReactNode[] = []
- data?.tree.forEach((value, key) => {
+ tree.entries.forEach((value, key) => {
treeElements.push(
)
})
- if (isLoading) {
- return (
-
- Loading contracts ...
-
- )
- }
-
- if (treeElements.length === 0 && !isLoading) {
- return (
-
- No Solidity contracts found
-
- )
- }
-
return (
-
-
- {selectedPaths.length > 0 ? selectedPaths.length : "No"}
- {`contract${selectedPaths.length === 1 ? "" : "s"} selected`}
-
+
@@ -182,6 +221,23 @@ export const RepositoryContractsSelector: React.FC = ({
Clear selection
+
+
+ Files
+
+
+ {initialScope ? (
+
+ Original nSLOC | Current nSLOC | Diff
+
+ ) : (
+
+ nSLOC
+
+ )}
+
+
+
)
diff --git a/src/pages/AuditScope/ScopeDiffModal.tsx b/src/pages/AuditScope/ScopeDiffModal.tsx
new file mode 100644
index 00000000..5fcb8f72
--- /dev/null
+++ b/src/pages/AuditScope/ScopeDiffModal.tsx
@@ -0,0 +1,7 @@
+import Modal, { Props as ModalProps } from "../../components/Modal/Modal"
+
+type Props = ModalProps & {}
+
+export const ScopeDiffModal: React.FC = ({ onClose }) => {
+ return
+}
diff --git a/src/pages/AuditScope/ScopeList.tsx b/src/pages/AuditScope/ScopeList.tsx
index 74ea4774..be27e5f5 100644
--- a/src/pages/AuditScope/ScopeList.tsx
+++ b/src/pages/AuditScope/ScopeList.tsx
@@ -3,25 +3,32 @@ import { Box } from "../../components/Box"
import { Column } from "../../components/Layout"
import { Text } from "../../components/Text"
import { Title } from "../../components/Title"
-import { convertToTree } from "../../hooks/api/scope/useRepositoryContracts"
+import { convertToTree2 } from "../../hooks/api/scope/useRepositoryContracts"
import { Scope } from "../../hooks/api/scope/useScope"
import { TreeEntry } from "./RepositoryContractsSelector"
import styles from "./AuditScope.module.scss"
type Props = {
- scope: Scope
+ scope: Scope[]
}
export const ScopeList: React.FC = ({ scope }) => {
return (
{scope?.map((s) => {
- const tree = convertToTree(s.files)
+ const tree = convertToTree2(
+ s.files
+ .filter((f) => f.selected)
+ .map((f) => ({
+ filepath: f.filePath,
+ nsloc: f.nSLOC ?? 0,
+ })) ?? []
+ )
const treeElements: React.ReactNode[] = []
- tree.forEach((value, key) => {
- treeElements.push()
+ tree.entries.forEach((value, key) => {
+ treeElements.push()
})
return (
diff --git a/src/pages/Claim/ClaimStatusAction.tsx b/src/pages/Claim/ClaimStatusAction.tsx
index f5757285..5a8a4d80 100644
--- a/src/pages/Claim/ClaimStatusAction.tsx
+++ b/src/pages/Claim/ClaimStatusAction.tsx
@@ -187,10 +187,10 @@ const Escalate: React.FC = ({ claim, protocol }) => {
{!connectedAccountIsClaimInitiator && (
<>
- Only the claim inintiator can escalate it to UMA.
+ Only the claim initiator can escalate it to UMA.
- Please connnect using account with address {shortenAddress(claim.initiator)}
+ Please connect using account with address {shortenAddress(claim.initiator)}
>
)}
diff --git a/src/pages/Claim/Claims.tsx b/src/pages/Claim/Claims.tsx
index 901ea73a..205fa34e 100644
--- a/src/pages/Claim/Claims.tsx
+++ b/src/pages/Claim/Claims.tsx
@@ -44,7 +44,7 @@ export const ClaimsPage: React.FC = () => {
/**
* Handler for protocol's select option change
*/
- const handleProtocolChanged = useCallback((option: string) => {
+ const handleProtocolChanged = useCallback((option?: string) => {
setSelectedProtocolBytesIdentifier(option)
}, [])
diff --git a/src/pages/Claim/Field.tsx b/src/pages/Claim/Field.tsx
index f555bf96..c5971495 100644
--- a/src/pages/Claim/Field.tsx
+++ b/src/pages/Claim/Field.tsx
@@ -19,7 +19,7 @@ type Props = {
export const Field: React.FC> = ({ label, detail, children, sublabel, ...props }) => {
return (
-
+
{(label || props.error) && (
@@ -43,7 +43,9 @@ export const Field: React.FC> = ({ label, detail, child
)}
- {children}
+
+ {children}
+
{detail && (
{detail}
diff --git a/src/pages/FundraisingClaim/FundraisingClaim.tsx b/src/pages/FundraisingClaim/FundraisingClaim.tsx
index dd3a76b3..c1c43464 100644
--- a/src/pages/FundraisingClaim/FundraisingClaim.tsx
+++ b/src/pages/FundraisingClaim/FundraisingClaim.tsx
@@ -71,7 +71,7 @@ export const FundraisingClaimPage = () => {
}
fetchClaimIsActive()
- })
+ }, [sherClaim])
const handleOnSuccess = useCallback(
async (blockNumber: number) => {
@@ -130,7 +130,7 @@ export const FundraisingClaimPage = () => {
- Claiming {fundraisePositionData?.claimableAt < DateTime.now() ? "started" : "starts in"}
+ Claiming {fundraisePositionData?.claimableAt < DateTime.now() ? " started" : " starts in"}
diff --git a/src/pages/Overview/ExcessCoverageChart.tsx b/src/pages/Overview/ExcessCoverageChart.tsx
index 1b670570..9d16a39d 100644
--- a/src/pages/Overview/ExcessCoverageChart.tsx
+++ b/src/pages/Overview/ExcessCoverageChart.tsx
@@ -1,6 +1,6 @@
import { Chart } from "../../components/Chart/Chart"
import { DateTime } from "luxon"
-import { useTVCOverTime } from "../../hooks/api/stats"
+import { useExternalCoverageOverTime } from "../../hooks/api/stats"
import { useMemo } from "react"
import { utils } from "ethers"
@@ -17,11 +17,11 @@ const tooltipTitles: Record = {
const nexusStartDate = DateTime.fromSeconds(config.nexusMutualStartTimestamp)
export const ExcessCoverageChart = () => {
- const { data: tvcData } = useTVCOverTime()
+ const { data: externalCoverageData } = useExternalCoverageOverTime()
const chartData = useMemo(
() =>
- tvcData?.reduce<{ name: number; value: number }[]>((dataPoints, item) => {
+ externalCoverageData?.reduce<{ name: number; value: number }[]>((dataPoints, item) => {
const date = DateTime.fromSeconds(item.timestamp)
if (nexusStartDate.diff(date, "days").days < 5) {
@@ -31,13 +31,13 @@ export const ExcessCoverageChart = () => {
dataPoints.push({
name: item.timestamp,
- value: date > nexusStartDate ? Number(utils.formatUnits(item.value.mul(25).div(100), 6)) : 0,
+ value: Number(utils.formatUnits(item.value, 6)),
})
}
return dataPoints
}, []),
- [tvcData]
+ [externalCoverageData]
)
const totalAmount = useMemo(() => {
diff --git a/src/pages/Overview/Overview.tsx b/src/pages/Overview/Overview.tsx
index 661606f6..76dab00c 100644
--- a/src/pages/Overview/Overview.tsx
+++ b/src/pages/Overview/Overview.tsx
@@ -6,16 +6,14 @@ import { Column, Row } from "../../components/Layout"
import { Title } from "../../components/Title"
import { Chart } from "../../components/Chart/Chart"
-import { useTVCOverTime, useTVLOverTime } from "../../hooks/api/stats"
+import { useTVCOverTime, useTVLOverTime, useExternalCoverageOverTime } from "../../hooks/api/stats"
import styles from "./Overview.module.scss"
import APYChart from "../../components/APYChart/APYChart"
import CoveredProtocolsList from "../../components/CoveredProtocolsList/CoveredProtocolsList"
import { formatAmount } from "../../utils/format"
import StrategiesList from "../../components/StrategiesList/StrategiesList"
-import config from "../../config"
import { ExcessCoverageChart } from "./ExcessCoverageChart"
-import { Text } from "../../components/Text"
import ClaimsList from "../../components/ClaimsList/ClaimsList"
type ChartDataPoint = {
@@ -26,33 +24,19 @@ type ChartDataPoint = {
export const OverviewPage: React.FC = () => {
const { data: tvlData } = useTVLOverTime()
const { data: tvcData } = useTVCOverTime()
+ const { data: externalCoverageData } = useExternalCoverageOverTime()
const chartsData = useMemo(() => {
- if (!tvlData || !tvcData) return
+ if (!tvlData || !tvcData || !externalCoverageData) return
const tvcChartData: ChartDataPoint[] = []
const tvlChartData: ChartDataPoint[] = []
const capitalEfficiencyChartData: ChartDataPoint[] = []
- for (let i = 0, j = 0; i < tvlData.length && j < tvcData.length; ) {
- const tvcDataPointDate = DateTime.fromSeconds(tvcData[i].timestamp)
- const tvlDataPointDate = DateTime.fromSeconds(tvlData[j].timestamp)
-
- let tvc = tvcData[i]
- let tvl = tvlData[j]
-
- if (tvcDataPointDate.day !== tvlDataPointDate.day) {
- if (tvcDataPointDate < tvlDataPointDate) {
- tvl = j > 0 ? tvlData[j - 1] : tvlData[j]
- i++
- } else {
- tvc = i > 0 ? tvcData[i - 1] : tvcData[i]
- j++
- }
- } else {
- i++
- j++
- }
+ for (let i = 0; i < tvcData.length; i++) {
+ const tvc = tvcData[i]
+ const tvl = tvlData[i]
+ const externalCoverage = externalCoverageData[i]
const timestamp = Math.min(tvc.timestamp, tvl.timestamp)
@@ -66,9 +50,10 @@ export const OverviewPage: React.FC = () => {
value: Number(utils.formatUnits(tvl.value, 6)),
}
- // TVC is increased by 25% due to our agreement with Nexus.
- // To calculate capital efficiency, we only used what is being covered by Sherlock's staking pool.
- const sherlockTVC = timestamp > config.nexusMutualStartTimestamp ? tvc.value.mul(75).div(100) : tvc.value
+ // External coverage is subtracted from the total TVC due to our agreement with Nexus
+ // and only a part of the TVC is covereged by Sherlock's staking pool.
+ // const sherlockTVC = timestamp > config.nexusMutualStartTimestamp ? tvc.value.mul(75).div(100) : tvc.value
+ const sherlockTVC = tvc.value.sub(externalCoverage.value)
const capitalEfficiencyDataPoint = {
name: timestamp,
@@ -83,7 +68,10 @@ export const OverviewPage: React.FC = () => {
tvcChartData.push(tvcDataPoint)
tvlChartData.push(tvlDataPoint)
- capitalEfficiencyChartData.push(capitalEfficiencyDataPoint)
+
+ if (capitalEfficiencyDataPoint.value > 0) {
+ capitalEfficiencyChartData.push(capitalEfficiencyDataPoint)
+ }
}
return {
@@ -91,7 +79,7 @@ export const OverviewPage: React.FC = () => {
tvlChartData,
capitalEfficiencyChartData,
}
- }, [tvlData, tvcData])
+ }, [tvlData, tvcData, externalCoverageData])
return (
diff --git a/src/pages/Protocol/Protocol.tsx b/src/pages/Protocol/Protocol.tsx
index 94eadbb6..b6b63661 100644
--- a/src/pages/Protocol/Protocol.tsx
+++ b/src/pages/Protocol/Protocol.tsx
@@ -28,16 +28,25 @@ export const ProtocolPage: React.FC = () => {
const { data: protocols } = useProtocols()
const { address: connectedAddress } = useAccount()
- const protocolSelectOptions = React.useMemo(
- () =>
- Object.entries(protocols ?? {})
+ const protocolSelectOptions = React.useMemo(() => {
+ const options = [
+ ...(!selectedProtocolId
+ ? [
+ {
+ value: undefined,
+ label: "Select Protocol",
+ },
+ ]
+ : []),
+ ...(Object.entries(protocols ?? {})
.filter(([_, p]) => p.agent !== ethers.constants.AddressZero)
.map(([key, item]) => ({
label: item.name ?? "Unknown",
value: key as `0x${string}`,
- })) ?? [],
- [protocols]
- )
+ })) ?? []),
+ ]
+ return options
+ }, [protocols, selectedProtocolId])
const selectedProtocol = React.useMemo(
() => (selectedProtocolId ? protocols?.[selectedProtocolId] ?? null : null),
[selectedProtocolId, protocols]
@@ -59,7 +68,9 @@ export const ProtocolPage: React.FC = () => {
if (!protocols) return
if (protocolTag) {
- const found = Object.entries(protocols).find(([_, p]) => p.tag === protocolTag)
+ const found = Object.entries(protocols).find(
+ ([_, p]) => (p.name && p.name.toLowerCase().replaceAll(" ", "_") === protocolTag) || p.tag === protocolTag
+ )
if (found) {
const [_, protocol] = found
@@ -71,7 +82,7 @@ export const ProtocolPage: React.FC = () => {
/**
* Handler for changing the protocol
*/
- const handleOnProtocolChanged = React.useCallback((option: `0x${string}`) => {
+ const handleOnProtocolChanged = React.useCallback((option?: `0x${string}`) => {
setSelectedProtocolId(option)
}, [])
diff --git a/src/pages/Staking/Staking.tsx b/src/pages/Staking/Staking.tsx
index cb4d44b1..9c22b537 100644
--- a/src/pages/Staking/Staking.tsx
+++ b/src/pages/Staking/Staking.tsx
@@ -147,9 +147,6 @@ export const StakingPage: React.FC = () => {
-
- The Summer 2023 Staking Round is currently open!
-
Stake
diff --git a/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx b/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx
new file mode 100644
index 00000000..465b6a56
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx
@@ -0,0 +1,215 @@
+import { useEffect, useMemo, useState } from "react"
+import {
+ FaCaretDown,
+ FaCaretRight,
+ FaCaretUp,
+ FaClipboardList,
+ FaFilter,
+ FaTrash,
+ FaUndo,
+ FaUsers,
+} from "react-icons/fa"
+import { Box } from "../../../components/Box"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
+import { Table, TBody, Td, Th, THead, Tr } from "../../../components/Table/Table"
+import { Text } from "../../../components/Text"
+import { Title } from "../../../components/Title"
+import { useAdminContests } from "../../../hooks/api/admin/useAdminContests"
+
+import styles from "./AdminContestsList.module.scss"
+import { ConfirmContestModal } from "./ConfirmContestModal"
+import { ContestResetInitialScopeModal } from "./ContestResetInitialScopeModal"
+import { DeleteDraftContestModal } from "./DeleteDraftContestModal"
+import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/Tooltip/Tooltip"
+import { DateTime } from "luxon"
+import { useNavigate } from "react-router-dom"
+
+type FilterType = "ALL" | "ONLY_SCOPE_SUBMITTED" | "ONLY_SCOPE_NOT_SUBMITTED"
+
+const FilterLabels: Record = {
+ ALL: "All contests",
+ ONLY_SCOPE_SUBMITTED: "Only with submitted scope",
+ ONLY_SCOPE_NOT_SUBMITTED: "Only without submitted scope",
+}
+
+export const AdminContestListDraft = () => {
+ const { data: contests, isLoading } = useAdminContests("draft")
+
+ const [confirmContestIndex, setConfirmContestIndex] = useState()
+ const [resetContestIndex, setResetContestIndex] = useState()
+ const [deleteContestIndex, setDeleteContestIndex] = useState()
+
+ const [activeFilter, setActiveFilter] = useState("ALL")
+ const [collapsed, setCollapsed] = useState(true)
+
+ const visibleContests = useMemo(() => {
+ switch (activeFilter) {
+ case "ALL":
+ return contests
+ case "ONLY_SCOPE_SUBMITTED":
+ return contests
+ ?.filter((item) => item.initialScopeSubmitted)
+ .sort((a, b) => (b.initialScopeSubmittedAt ?? 0) - (a.initialScopeSubmittedAt ?? 0))
+ case "ONLY_SCOPE_NOT_SUBMITTED":
+ return contests?.filter((item) => !item.initialScopeSubmitted)
+ }
+ }, [contests, activeFilter])
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "f") {
+ setCollapsed(false)
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown)
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ DRAFTS
+
+
+ {!collapsed && (
+
+
+
+
+
Filter
+
+ {FilterLabels[activeFilter]}
+
+
+
+
+
+ {Object.entries(FilterLabels).map(([value, label]) => (
+
setActiveFilter(value as FilterType)}>
+ {label}
+
+ ))}
+
+
+
+ )}
+
+ {!collapsed && (
+
+
+
+ |
+ ID
+ |
+
+ Contest
+ |
+ |
+ Status |
+ Action |
+
+
+
+ {visibleContests?.map((c, index) => {
+ return (
+
+ | {c.id} |
+
+
+
+ {c.title}
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+ {c.initialScopeSubmittedAt
+ ? `Initial scope submitted at ${DateTime.fromSeconds(
+ c.initialScopeSubmittedAt
+ ).toLocaleString(DateTime.DATETIME_SHORT)}`
+ : "Waiting on initial scope"}
+
+ |
+
+
+
+
+ |
+
+ )
+ })}
+
+
+ )}
+ {confirmContestIndex !== undefined && contests && (
+ setConfirmContestIndex(undefined)}
+ contest={contests[confirmContestIndex]}
+ />
+ )}
+ {resetContestIndex !== undefined && contests && (
+ setResetContestIndex(undefined)}
+ contest={contests[resetContestIndex]}
+ />
+ )}
+ {deleteContestIndex !== undefined && contests && (
+ setDeleteContestIndex(undefined)}
+ contest={contests[deleteContestIndex]}
+ />
+ )}
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/AdminContestsList.module.scss b/src/pages/admin/AdminContestsList/AdminContestsList.module.scss
index 43b8f8f8..796e049d 100644
--- a/src/pages/admin/AdminContestsList/AdminContestsList.module.scss
+++ b/src/pages/admin/AdminContestsList/AdminContestsList.module.scss
@@ -24,10 +24,10 @@
width: 30%;
}
&:nth-child(3) {
- width: 20%;
+ width: 25%;
}
&:nth-child(4) {
- width: 45%;
+ width: 40%;
}
&:nth-child(5) {
width: 12rem;
@@ -76,3 +76,56 @@
background-color: transparentize($color: #ffcc00, $amount: 0.9);
color: #ffcc00;
}
+
+.telegram {
+ opacity: 0.6;
+ display: flex;
+ font-weight: bold;
+
+ & *:first-child {
+ margin-right: 5px;
+ }
+}
+
+.filterContainer {
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ transition: all 0.1s linear;
+ color: rgb(156, 163, 175);
+
+ &:hover {
+ color: rgb(209, 213, 219);
+ }
+
+ .activeFilter {
+ background-color: rgb(209, 213, 219);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100px;
+ padding: 4px 8px;
+ text-align: center;
+ font-size: 14px;
+ font-weight: 600;
+ color: black;
+ }
+}
+
+.filtersList {
+ display: flex;
+ flex-direction: column;
+
+ div {
+ cursor: pointer;
+ padding: 16px 32px;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+ }
+}
diff --git a/src/pages/admin/AdminContestsList/AdminContestsList.tsx b/src/pages/admin/AdminContestsList/AdminContestsList.tsx
index 483fea5b..d45dab89 100644
--- a/src/pages/admin/AdminContestsList/AdminContestsList.tsx
+++ b/src/pages/admin/AdminContestsList/AdminContestsList.tsx
@@ -1,12 +1,30 @@
-import { Column } from "../../../components/Layout"
+import { useState } from "react"
+import { FaPlus } from "react-icons/fa"
+import { Box } from "../../../components/Box"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import { AdminContestListDraft } from "./AdminContestListDraft"
import { AdminContestsListActive } from "./AdminContestsListActive"
import { AdminContestsListFinished } from "./AdminContestsListFinished"
+import { CreateContestModal } from "./CreateContestModal"
export const AdminContestsList = () => {
+ const [createContestModalOpen, setCreateContestModalOpen] = useState(false)
+
return (
+
+
+
+
+
+
+ {createContestModalOpen && setCreateContestModalOpen(false)} />}
)
}
diff --git a/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx b/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx
index f852801e..0f987477 100644
--- a/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx
+++ b/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx
@@ -1,6 +1,6 @@
import { DateTime } from "luxon"
-import { useCallback, useState } from "react"
-import { FaClipboardList, FaEye, FaFastForward, FaPlus, FaBullseye } from "react-icons/fa"
+import { useCallback, useEffect, useState } from "react"
+import { FaClipboardList, FaFastForward, FaPlus, FaEdit, FaRegListAlt, FaUsers } from "react-icons/fa"
import { Box } from "../../../components/Box"
import { Button } from "../../../components/Button"
import { Column, Row } from "../../../components/Layout"
@@ -12,8 +12,11 @@ import { ContestsListItem, useAdminContests } from "../../../hooks/api/admin/use
import styles from "./AdminContestsList.module.scss"
import { ConfirmContestActionModal } from "./ConfirmContestActionModal"
-import { CreateContestModal } from "./CreateContestModal"
import { ContestScopeModal } from "./ContestScopeModal"
+import { UpdateContestModal } from "./UpdateContestModal"
+import { TelegramBotIndicator } from "./TelegramBotIndicator"
+import { GenerateReportSuccessModal } from "./GenerateReportSuccessModal"
+import { useAdminGenerateReport } from "../../../hooks/api/admin/useGenerateReport"
type ContestLifeCycleStatus =
| "WAITING_INITIAL_PAYMENT"
@@ -21,7 +24,6 @@ type ContestLifeCycleStatus =
| "WAITING_FOR_SENIOR_SELECTION"
| "READY_TO_PUBLISH"
| "WAITING_ON_FINAL_PAYMENT"
- | "WAITING_ON_FINALIZE_SUBMISSION"
| "READY_TO_APPROVE_START"
| "START_APPROVED"
| "RUNNING"
@@ -29,22 +31,22 @@ type ContestLifeCycleStatus =
| "ESCALATING"
| "SHERLOCK_JUDGING"
| "FINISHED"
+ | "DRAFT"
+ | "FINAL_REPORT_AVAILABLE"
+ | "FINAL_REPORT_AVAILABLE_TO_GENERATE"
const getCurrentStatus = (contest: ContestsListItem): ContestLifeCycleStatus => {
if (!contest.initialPayment) return "WAITING_INITIAL_PAYMENT"
if (!contest.leadSeniorAuditorHandle && !contest.leadSeniorSelectionMessageSentAt) return "READY_TO_SELECT_SENIOR"
if (!contest.leadSeniorAuditorHandle) return "WAITING_FOR_SENIOR_SELECTION"
if (!contest.adminUpcomingApproved) return "READY_TO_PUBLISH"
- if (!contest.fullPayment) return "WAITING_ON_FINAL_PAYMENT"
- if (!contest.submissionReady) return "WAITING_ON_FINALIZE_SUBMISSION"
+ if (!contest.fullPaymentComplete) return "WAITING_ON_FINAL_PAYMENT"
if (!contest.adminStartApproved) return "READY_TO_APPROVE_START"
+ if (contest.status === "CREATED") return "START_APPROVED"
+ if (contest.auditReport) return "FINAL_REPORT_AVAILABLE"
+ if (contest.finalReportAvailable) return "FINAL_REPORT_AVAILABLE_TO_GENERATE"
- switch (contest.status) {
- case "CREATED":
- return "START_APPROVED"
- default:
- return contest.status
- }
+ return contest.status
}
const getForcedStatus = (contest: ContestsListItem): ContestLifeCycleStatus | undefined => {
@@ -74,9 +76,29 @@ type ConfirmationModal = {
export const AdminContestsListActive = () => {
const { data: contests, isLoading } = useAdminContests("active")
const [confirmationModal, setConfirmationModal] = useState()
- const [createContestModalOpen, setCreateContestModalOpen] = useState(false)
+ const [updateContestIndex, setUpdateContextIndex] = useState()
const [scopeModal, setScopeModal] = useState()
const [forceActionRowIndex, setForceActionRowIndex] = useState()
+ const [reportGeneratedModalVisible, setReportGenerateModalVisible] = useState()
+ const {
+ generateReport,
+ isLoading: generateReportIsLoading,
+ isSuccess,
+ reset,
+ variables,
+ data: reportURL,
+ } = useAdminGenerateReport()
+
+ useEffect(() => {
+ if (isSuccess) {
+ const index = contests?.findIndex((c) => c.id === variables?.contestID)
+ setReportGenerateModalVisible(index)
+ console.log("undef")
+ } else {
+ setReportGenerateModalVisible(undefined)
+ console.log("undef")
+ }
+ }, [isSuccess, setReportGenerateModalVisible, variables, contests])
const handleActionClick = useCallback(
(contestIndex: number, action: ContestAction) => {
@@ -111,6 +133,33 @@ export const AdminContestsListActive = () => {
})
}, [])
+ const handleModalClose = useCallback(() => {
+ console.log("close")
+ reset()
+ setReportGenerateModalVisible(undefined)
+ }, [reset])
+
+ const handleGenerateReportClick = useCallback(
+ (index: number) => {
+ if (!contests) return
+
+ const contest = contests[index]
+ generateReport({ contestID: contest.id })
+ },
+ [generateReport, contests]
+ )
+
+ const handleViewReportClick = useCallback(
+ (index: number) => {
+ if (!contests) return
+
+ setReportGenerateModalVisible(index)
+
+ console.log(contests[index])
+ },
+ [contests, setReportGenerateModalVisible]
+ )
+
const renderContestAction = useCallback(
(contestIndex: number) => {
if (!contests) return null
@@ -151,13 +200,28 @@ export const AdminContestsListActive = () => {
)
+ if (status === "FINAL_REPORT_AVAILABLE_TO_GENERATE")
+ return (
+
+ )
+
+ if (status === "FINAL_REPORT_AVAILABLE")
+ return
+
return (
)
},
- [contests, handleActionClick, forceActionRowIndex]
+ [contests, handleActionClick, forceActionRowIndex, handleGenerateReportClick, handleViewReportClick]
)
const renderContestState = useCallback(
@@ -176,9 +240,7 @@ export const AdminContestsListActive = () => {
}
if (status === "WAITING_FOR_SENIOR_SELECTION") {
- const timeLeft = DateTime.fromSeconds(contest.leadSeniorSelectionMessageSentAt)
- .plus({ hours: 72 })
- .diffNow(["days", "hours"])
+ const timeLeft = DateTime.fromSeconds(contest.leadSeniorSelectionDate).diffNow(["days", "hours"])
return (
Lead Senior Watson selection in progress
@@ -197,9 +259,6 @@ export const AdminContestsListActive = () => {
return Waiting on full payment
}
- if (status === "WAITING_ON_FINALIZE_SUBMISSION")
- return Waiting of protocol to finalize submission
-
if (status === "READY_TO_APPROVE_START") {
return Ready to approve start
}
@@ -259,16 +318,8 @@ export const AdminContestsListActive = () => {
)
return (
-
+
-
-
-
-
-
CONTESTS
@@ -300,6 +351,7 @@ export const AdminContestsListActive = () => {
Starts {DateTime.fromSeconds(c.startDate).toFormat("LLLL d - t")}
+
@@ -316,18 +368,20 @@ export const AdminContestsListActive = () => {
+
@@ -359,10 +413,22 @@ export const AdminContestsListActive = () => {
force={forceActionRowIndex === confirmationModal.contestIndex}
/>
)}
- {createContestModalOpen && setCreateContestModalOpen(false)} />}
+ {updateContestIndex !== undefined && contests && (
+ setUpdateContextIndex(undefined)}
+ contest={contests[updateContestIndex]}
+ />
+ )}
{scopeModal && }
+ {(reportGeneratedModalVisible === 0 || !!reportGeneratedModalVisible) && contests && (
+
+ )}
)
}
diff --git a/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx b/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx
index 18c22cb3..893ce39f 100644
--- a/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx
+++ b/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx
@@ -128,10 +128,12 @@ export const AdminContestsListFinished = () => {
{c.auditReport ? (
- ) : (
+ ) : c.finalReportAvailable ? (
+ ) : (
+ Conditions not met for the final report to be generated
)}
diff --git a/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx b/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx
new file mode 100644
index 00000000..f771a12a
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useState } from "react"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
+import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import { Text } from "../../../components/Text"
+import { Title } from "../../../components/Title"
+import { useAdminConfirmContest } from "../../../hooks/api/admin/useAdminConfirmContest"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+import { ErrorModal } from "../../ContestDetails/ErrorModal"
+import { ContestValues, CreateContestForm } from "./CreateContestForm"
+
+type Props = ModalProps & {
+ contest: ContestsListItem
+}
+
+export const ConfirmContestModal: React.FC = ({ onClose, contest }) => {
+ const [formIsDirty, setFormIsDirty] = useState(false)
+ const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false)
+
+ const { confirmContest, isSuccess, isLoading, error, reset } = useAdminConfirmContest()
+
+ useEffect(() => {
+ if (isSuccess) onClose?.()
+ }, [isSuccess, onClose])
+
+ const handleModalClose = useCallback(() => {
+ if (formIsDirty) {
+ setDisplayModalFormConfirm(true)
+ } else {
+ onClose && onClose()
+ }
+ }, [setDisplayModalFormConfirm, onClose, formIsDirty])
+
+ const handleModalCloseConfirm = useCallback(() => {
+ onClose && onClose()
+ }, [onClose])
+
+ const handleModalCloseCancel = useCallback(() => {
+ setDisplayModalFormConfirm(false)
+ }, [])
+
+ const handleFormSubmit = useCallback(
+ (values: ContestValues) => {
+ confirmContest({
+ id: contest.id,
+ ...values.contest,
+ })
+ },
+ [contest.id, confirmContest]
+ )
+ return (
+
+ {displayModalCloseConfirm && (
+
+
+ Unsaved contest
+
+ Are you sure you want to close this form? All unsaved changes will be lost and you will need to start
+ over.
+
+
+
+
+
+
+
+ )}
+
+
+ New contest
+
+
+
+ {error && reset()} />}
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx b/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx
index a9150c00..a3602ae7 100644
--- a/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx
+++ b/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx
@@ -1,4 +1,5 @@
import TweetCard from "react-tweet-card"
+import { Column, Row } from "../../../components/Layout"
import { useAdminContestTweetPreview } from "../../../hooks/api/admin/useAdminContestTweetPreview"
type Props = {
@@ -6,17 +7,22 @@ type Props = {
}
export const ContestAnnouncementTweetPreview: React.FC = ({ contestID }) => {
- const { data: tweet } = useAdminContestTweetPreview(contestID)
+ const { data: tweets } = useAdminContestTweetPreview(contestID)
return (
-
+
+ {tweets?.map((tweet) => (
+
+ ))}
+
)
}
diff --git a/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx b/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx
new file mode 100644
index 00000000..72a67eaf
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx
@@ -0,0 +1,49 @@
+import { useCallback, useEffect } from "react"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
+import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import { Text } from "../../../components/Text"
+import { Title } from "../../../components/Title"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+import { useAdminResetScope } from "../../../hooks/api/admin/useAdminResetScope"
+
+type Props = {
+ contest: ContestsListItem
+} & ModalProps
+
+export const ContestResetInitialScopeModal: React.FC = ({ onClose, contest }) => {
+ const { resetScope, isLoading, isSuccess } = useAdminResetScope()
+
+ useEffect(() => {
+ if (isSuccess) {
+ onClose?.()
+ }
+ }, [isSuccess, onClose])
+
+ const handleResetScope = useCallback(() => {
+ resetScope({
+ contestID: contest.id,
+ scopeType: "initial",
+ })
+ }, [resetScope, contest.id])
+
+ return (
+
+
+
+ {contest.title}: Reset initial scope
+ Are you sure you want to reset the scope for this contest?
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/ContestScopeModal.tsx b/src/pages/admin/AdminContestsList/ContestScopeModal.tsx
index 38f03b6c..ebcb7f84 100644
--- a/src/pages/admin/AdminContestsList/ContestScopeModal.tsx
+++ b/src/pages/admin/AdminContestsList/ContestScopeModal.tsx
@@ -11,6 +11,9 @@ import { useAdminContestScope } from "../../../hooks/api/admin/useAdminContestSc
import { useContest } from "../../../hooks/api/contests"
import styles from "./AdminContestsList.module.scss"
+import { useAdminResetScope } from "../../../hooks/api/admin/useAdminResetScope"
+import { useAdminContest } from "../../../hooks/api/admin/useAdminContest"
+import { useAdminResetQA } from "../../../hooks/api/admin/useAdminResetQA"
type Props = ModalProps & {
contestID: number
@@ -19,15 +22,16 @@ type Props = ModalProps & {
const COMMENT_TO_SOURCE_MIN = 0.8
export const ContestScopeModal: React.FC = ({ onClose, contestID }) => {
- const { data: contest, isLoading: contestIsLoading } = useContest(contestID)
+ const { data: contest, isLoading: contestIsLoading } = useAdminContest(contestID)
const { data: scope, isLoading: scopeIsLoading } = useAdminContestScope(contestID)
+ const { resetScope, isLoading: resetScopeIsLoading } = useAdminResetScope()
+ const { resetQA, isLoading: resetQAIsLoading } = useAdminResetQA()
- const submittedNSLOC = scope?.reduce((t, s) => t + (s.nSLOC ?? 0), 0)
- const expectedNSLOCExceeded = contest && scope && (contest.linesOfCode ?? 0) < (submittedNSLOC ?? 0)
+ const expectedNSLOCExceeded = contest?.nSLOC && contest.expectedNSLOC && contest.nSLOC > contest.expectedNSLOC
return (
-
+
{contest?.title}
{expectedNSLOCExceeded ? (
@@ -37,10 +41,10 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => {
Submitted nSLOC is higher than expected
- Expected nSLOC: {contest?.linesOfCode}
+ Expected nSLOC: {contest?.expectedNSLOC}
- Submitted nSLOC: {submittedNSLOC}
+ Submitted nSLOC: {contest.nSLOC}
) : null}
@@ -60,7 +64,7 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => {
Contracts:
- {s.files.length}
+ {s.files.filter((f) => f.selected).length}
|
@@ -72,24 +76,26 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => {
-
-
-
- Comments ratio:
- {Math.round(s.commentToSourceRatio! * 100)}%
- {s.commentToSourceRatio! < COMMENT_TO_SOURCE_MIN ? (
-
-
- Comments ratio is below 80%
-
- ) : null}
-
- |
-
+ {s.commentToSourceRatio ? (
+
+
+
+ Comments ratio:
+ {Math.round(s.commentToSourceRatio * 100)}%
+ {s.commentToSourceRatio < COMMENT_TO_SOURCE_MIN ? (
+
+
+ Comments ratio is below 80%
+
+ ) : null}
+
+ |
+
+ ) : null}
))}
+
+
diff --git a/src/pages/admin/AdminContestsList/CreateContestForm.module.scss b/src/pages/admin/AdminContestsList/CreateContestForm.module.scss
new file mode 100644
index 00000000..d2f55a35
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/CreateContestForm.module.scss
@@ -0,0 +1,13 @@
+@import "../../../styles/variables";
+
+.settingsSection {
+ border: 1px solid darken($primary-purple, 30%);
+ padding: 2rem;
+ width: 100%;
+ // display: flex;
+ // flex-direction: column;
+
+ input {
+ width: 100%;
+ }
+}
diff --git a/src/pages/admin/AdminContestsList/CreateContestForm.tsx b/src/pages/admin/AdminContestsList/CreateContestForm.tsx
new file mode 100644
index 00000000..d937f290
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/CreateContestForm.tsx
@@ -0,0 +1,794 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { FaChrome, FaTwitter } from "react-icons/fa"
+import { useDebounce } from "use-debounce"
+import { BigNumber, ethers } from "ethers"
+import { DateTime } from "luxon"
+
+import { Input } from "../../../components/Input"
+import { Column, Row } from "../../../components/Layout"
+import { Title } from "../../../components/Title"
+import { useAdminProtocol } from "../../../hooks/api/admin/useAdminProtocol"
+import { Field } from "../../Claim/Field"
+import { useAdminTwitterAccount } from "../../../hooks/api/admin/useTwitterAccount"
+import { useAdminContestVariables } from "../../../hooks/api/admin/useAdminContestVariables"
+import { commify } from "../../../utils/units"
+import { Text } from "../../../components/Text"
+import TokenInput from "../../../components/TokenInput/TokenInput"
+import { Button } from "../../../components/Button"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+import RadioButton from "../../../components/RadioButton/RadioButton"
+import styles from "./CreateContestForm.module.scss"
+import { useAdminProtocolContests } from "../../../hooks/api/admin/useAdminProtocolContests"
+import Select from "../../../components/Select/Select"
+import { useAdminPricing } from "../../../hooks/api/admin/useAdminPricing"
+
+export type ContestValues = {
+ protocol: {
+ id?: number
+ name?: string
+ twitter?: string
+ website?: string
+ logoUrl?: string
+ }
+ contest: {
+ title: string
+ shortDescription: string
+ nSLOC: string
+ startDate: DateTime
+ endDate: DateTime
+ auditRewards: number
+ judgingPrizePool: number
+ leadJudgeFixedPay: number
+ fullPayment: number
+ lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED"
+ customLswFixedPay?: number | null
+ private?: boolean
+ requiresKYC?: boolean
+ maxNumberOfParticipants?: number | null
+ previousContestId?: number | null
+ token: string
+ exchangeRate: number
+ }
+}
+
+type Props = {
+ onSubmit: (values: ContestValues) => void
+ onDirtyChange: (dirty: boolean) => void
+ submitLabel: string
+ contest?: ContestsListItem
+ draft?: boolean
+}
+
+const DATE_FORMAT = "yyyy-MM-dd"
+
+export const CreateContestForm: React.FC = ({
+ onSubmit,
+ onDirtyChange,
+ submitLabel,
+ contest,
+ draft = false,
+}) => {
+ const [protocolName, setProtocolName] = useState("")
+ const [debouncedProtocolName] = useDebounce(protocolName, 300)
+
+ const {
+ data: protocol,
+ isError: protocolNotFound,
+ isLoading: protocolLoading,
+ } = useAdminProtocol(debouncedProtocolName)
+ const { data: protocolContests } = useAdminProtocolContests(protocol?.id)
+ const [isUpdateContest, setIsUpdateContest] = useState(false)
+ const [previousContest, setPreviousContest] = useState()
+
+ useEffect(() => {
+ if (!!previousContest) {
+ return
+ }
+
+ if (protocolContests && protocolContests?.length > 0) {
+ setPreviousContest(protocolContests[0].id)
+ }
+ }, [protocolContests, previousContest])
+
+ const previousContestOptions = useMemo(
+ () =>
+ protocolContests?.map((item) => ({
+ label: item.title,
+ value: item.id,
+ })) ?? [],
+ [protocolContests]
+ )
+
+ console.log("Protocol contests", protocolContests)
+
+ const [protocolTwitter, setProtocolTwitter] = useState(protocol?.twitter ?? "")
+ const [protocolWebsite, setProtocolWebsite] = useState(protocol?.website ?? "")
+ const [protocolLogoURL, setProtocolLogoURL] = useState(protocol?.logoURL ?? "")
+
+ const [debouncedProtocolTwitter] = useDebounce(protocolTwitter, 300)
+ const { data: twitterAccount } = useAdminTwitterAccount(debouncedProtocolTwitter)
+
+ const [contestTitle, setContestTitle] = useState("")
+ const [contestShortDescription, setShortDescription] = useState("")
+ const [contestNSLOC, setContestNSLOC] = useState(contest?.nSLOC?.toString() ?? "")
+ const [debouncedContestNSLOC] = useDebounce(contestNSLOC, 300)
+ const [contestStartDate, setContestStartDate] = useState("")
+ const [contestAuditLength, setContestAuditLength] = useState("")
+ const [contestTotalRewards, setContestTotalRewards] = useState(BigNumber.from(0))
+ const [contestAuditRewards, setContestAuditRewards] = useState(BigNumber.from(0))
+ const [contestJudgingPrizePool, setContestJudgingPrizePool] = useState(BigNumber.from(0))
+ const [contestLeadJudgeFixedPay, setContestLeadJudgeFixedPay] = useState(BigNumber.from(0))
+ const [contestTotalCost, setContestTotalCost] = useState(BigNumber.from(0))
+ const [isPrivate, setIsPrivate] = useState(false)
+ const [requiresKYC, setRequiresKYC] = useState(false)
+ const [lswPaymentStructure, setLswPaymentStructure] = useState<"TIERED" | "BEST_EFFORTS" | "FIXED">("TIERED")
+ const [customLswFixedPay, setCustomLswFixedPay] = useState(undefined)
+ const [hasLimitedContestants, setHasLimitedContestants] = useState(false)
+ const [maxNumberOfParticipants, setmaxNumberOfParticipants] = useState("")
+
+ const [initialTotalRewards, setInitialTotalRewards] = useState(BigNumber.from(0))
+ const [initialAuditContestRewards, setInitialAuditContestRewards] = useState(BigNumber.from(0))
+ const [initialJudgingPrizePool, setInitialJudgingPrizePool] = useState(BigNumber.from(0))
+ const [initialLeadJudgeFixedPay, setInitialLeadJudgeFixedPay] = useState(BigNumber.from(0))
+ const [initialTotalCost, setInitialTotalCost] = useState(BigNumber.from(0))
+ const [initialCustomLswFixedPay, setInitialCustomLswFixedPay] = useState(BigNumber.from(0))
+
+ const [startDateError, setStartDateError] = useState()
+ const [shortDescriptionError, setShortDescriptionError] = useState()
+
+ const displayProtocolInfo = !!protocol || protocolNotFound || protocolLoading
+
+ const [token, setToken] = useState(contest?.token ?? "USDC")
+
+ const { data: contestVariables, isSuccess: adminPricingSuccess } = useAdminPricing(
+ parseInt(debouncedContestNSLOC),
+ token
+ )
+
+ useEffect(() => {
+ if (contest) {
+ const startDate = DateTime.fromSeconds(contest.startDate)
+ const endDate = DateTime.fromSeconds(contest.endDate)
+ const auditLength = endDate.diff(startDate, "days").days
+
+ setContestTitle(contest.title)
+ setShortDescription(contest.shortDescription)
+ setContestStartDate(startDate.toFormat(DATE_FORMAT))
+ setContestAuditLength(auditLength.toString())
+ setInitialTotalRewards(
+ ethers.utils.parseUnits(`${contest.rewards + contest.judgingPrizePool + contest.leadJudgeFixedPay}`, 6)
+ )
+ setInitialAuditContestRewards(ethers.utils.parseUnits(`${contest.rewards}`, 6))
+ setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contest.judgingPrizePool}`, 6))
+ setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contest.leadJudgeFixedPay}`, 6))
+ setInitialTotalCost(ethers.utils.parseUnits(`${contest.fullPayment}`, 6))
+ setIsPrivate(contest.private)
+ setRequiresKYC(contest.requiresKYC)
+ setLswPaymentStructure(contest.lswPaymentStructure)
+ setHasLimitedContestants(!!contest.maxNumberOfParticipants)
+ setmaxNumberOfParticipants(contest.maxNumberOfParticipants?.toString() ?? "")
+ setCustomLswFixedPay(ethers.utils.parseUnits(`${contest.leadSeniorAuditorFixedPay ?? 0}`, 6))
+ setInitialCustomLswFixedPay(ethers.utils.parseUnits(`${contest.leadSeniorAuditorFixedPay ?? 0}`, 6))
+ }
+ }, [contest])
+
+ useEffect(() => {
+ if (adminPricingSuccess && (!contest || contest.status === "DRAFT")) {
+ setContestAuditLength(`${contestVariables.length}`)
+ setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables.minTotalRewards}`, 6))
+ setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables.minContestRewards}`, 6))
+ setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables.minJudgingPrizePool}`, 6))
+ setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables.minLeadJudgeFixedPay}`, 6))
+ setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables.minTotalPrice}`, 6))
+ }
+ }, [adminPricingSuccess, setContestAuditLength, contestVariables, contest])
+
+ useEffect(() => {
+ const diff = contestTotalRewards
+ ?.sub(contestAuditRewards ?? BigNumber.from(0))
+ .sub(contestJudgingPrizePool ?? BigNumber.from(0))
+ .sub(contestLeadJudgeFixedPay ?? BigNumber.from(0))
+
+ setInitialAuditContestRewards(contestAuditRewards?.add(diff ?? BigNumber.from(0)))
+ }, [contestTotalRewards, setInitialAuditContestRewards])
+
+ useEffect(() => {
+ if (protocol?.name) {
+ setProtocolName(protocol?.name)
+ }
+ }, [protocol])
+
+ useEffect(() => {
+ if (twitterAccount?.profilePictureUrl) {
+ setProtocolLogoURL(twitterAccount.profilePictureUrl)
+ } else {
+ setProtocolLogoURL("")
+ }
+ }, [twitterAccount])
+
+ useEffect(() => {
+ if (contestStartDate === "") {
+ setStartDateError(undefined)
+ return
+ }
+
+ const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT)
+
+ if (!startDate.isValid) {
+ setStartDateError(`Invalid date. Must be format ${DATE_FORMAT}`)
+ return
+ }
+
+ if (startDate < DateTime.now()) {
+ setStartDateError("Start date cannot be in the past.")
+ return
+ }
+
+ setStartDateError(undefined)
+ }, [contestStartDate, contestAuditLength])
+
+ const sherlockFee = useMemo(() => {
+ const fee = contestTotalCost
+ ?.sub(contestAuditRewards ?? 0)
+ .sub(contestJudgingPrizePool ?? 0)
+ .sub(contestLeadJudgeFixedPay ?? 0)
+
+ return commify(parseInt(ethers.utils.formatUnits(fee ?? 0, 6)))
+ }, [contestTotalCost, contestAuditRewards, contestJudgingPrizePool, contestLeadJudgeFixedPay])
+
+ const canSubmit = useMemo(() => {
+ if (!contest) {
+ if (protocolName === "") return false
+ if (protocolLogoURL === "" && !protocol?.logoURL) return false
+ if (protocolWebsite === "" && !protocol?.website) return false
+ }
+
+ if (contestTitle === "") return false
+
+ if (draft) return true
+
+ if (contestShortDescription === "") return false
+ if (contestAuditLength === "") return false
+
+ const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT)
+
+ if (!startDate.isValid) return false
+ if (startDate < DateTime.now()) return false
+ if (contestAuditRewards?.eq(BigNumber.from(0))) return false
+ if (contestTotalCost?.eq(BigNumber.from(0))) return false
+ if (lswPaymentStructure === "FIXED" && !customLswFixedPay?.gt(BigNumber.from(0))) return false
+ if (hasLimitedContestants && (maxNumberOfParticipants === "" || maxNumberOfParticipants === "0")) return false
+
+ return true
+ }, [
+ draft,
+ contestShortDescription,
+ contestAuditLength,
+ contestAuditRewards,
+ contestStartDate,
+ contestTitle,
+ contestTotalCost,
+ protocol?.logoURL,
+ protocol?.website,
+ protocolLogoURL,
+ protocolName,
+ protocolWebsite,
+ contest,
+ lswPaymentStructure,
+ customLswFixedPay,
+ hasLimitedContestants,
+ maxNumberOfParticipants,
+ ])
+
+ useEffect(() => {
+ if (contest) {
+ const startDate = DateTime.fromSeconds(contest.startDate)
+ const endDate = DateTime.fromSeconds(contest.endDate)
+ const auditLength = endDate.diff(startDate, "days").days
+
+ onDirtyChange(
+ (contestTitle !== contest.title ||
+ contestShortDescription !== contest.shortDescription ||
+ contestNSLOC !== contest.nSLOC?.toString() ||
+ contestStartDate !== startDate.toFormat(DATE_FORMAT) ||
+ contestAuditLength !== auditLength.toString() ||
+ !contestAuditRewards?.eq(ethers.utils.parseUnits(`${contest.rewards}`, 6)) ||
+ !contestJudgingPrizePool?.eq(ethers.utils.parseUnits(`${contest.judgingPrizePool}`, 6)) ||
+ !contestLeadJudgeFixedPay?.eq(ethers.utils.parseUnits(`${contest.leadJudgeFixedPay}`, 6)) ||
+ !contestTotalCost?.eq(ethers.utils.parseUnits(`${contest.fullPayment}`, 6))) ??
+ false
+ )
+ } else {
+ onDirtyChange(
+ (protocolName !== "" ||
+ contestTitle !== "" ||
+ contestShortDescription !== "" ||
+ contestNSLOC !== "" ||
+ contestStartDate !== "" ||
+ contestAuditLength !== "" ||
+ contestAuditRewards?.gt(BigNumber.from(0)) ||
+ contestJudgingPrizePool?.gt(BigNumber.from(0)) ||
+ contestLeadJudgeFixedPay?.gt(BigNumber.from(0)) ||
+ contestTotalCost?.gt(BigNumber.from(0))) ??
+ false
+ )
+ }
+ }, [
+ contest,
+ contestAuditLength,
+ contestAuditRewards,
+ contestJudgingPrizePool,
+ contestLeadJudgeFixedPay,
+ contestNSLOC,
+ contestShortDescription,
+ contestStartDate,
+ contestTitle,
+ contestTotalCost,
+ onDirtyChange,
+ protocolName,
+ ])
+
+ const handleSubmit = useCallback(() => {
+ const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT, { zone: "utc" }).set({
+ hour: 15,
+ minute: 0,
+ second: 0,
+ millisecond: 0,
+ })
+ const endDate = startDate
+ .plus({ hours: 24 * parseInt(contestAuditLength) })
+ .set({ hour: 15, minute: 0, second: 0, millisecond: 0 })
+ onSubmit({
+ protocol: {
+ id: protocol?.id,
+ name: protocolName === "" ? undefined : protocolName,
+ twitter: protocolTwitter === "" ? undefined : protocolTwitter,
+ website: protocolWebsite === "" ? undefined : protocolWebsite,
+ logoUrl: protocolLogoURL === "" ? undefined : protocolLogoURL,
+ },
+ contest: {
+ title: contestTitle,
+ shortDescription: contestShortDescription,
+ nSLOC: contestNSLOC,
+ startDate,
+ endDate,
+ auditRewards: parseInt(ethers.utils.formatUnits(contestAuditRewards ?? 0, 6)),
+ judgingPrizePool: parseInt(ethers.utils.formatUnits(contestJudgingPrizePool ?? 0, 6)),
+ leadJudgeFixedPay: parseInt(ethers.utils.formatUnits(contestLeadJudgeFixedPay ?? 0, 6)),
+ fullPayment: parseInt(ethers.utils.formatUnits(contestTotalCost ?? 0, 6)),
+ lswPaymentStructure,
+ private: isPrivate,
+ requiresKYC,
+ customLswFixedPay: customLswFixedPay ? parseInt(ethers.utils.formatUnits(customLswFixedPay ?? 0, 6)) : null,
+ maxNumberOfParticipants:
+ hasLimitedContestants && maxNumberOfParticipants && maxNumberOfParticipants !== ""
+ ? parseInt(maxNumberOfParticipants)
+ : null,
+ previousContestId: isUpdateContest ? previousContest : null,
+ token,
+ exchangeRate: contestVariables?.exchangeRate ?? 1,
+ },
+ })
+ }, [
+ contestAuditLength,
+ contestAuditRewards,
+ contestJudgingPrizePool,
+ contestLeadJudgeFixedPay,
+ contestNSLOC,
+ contestShortDescription,
+ contestStartDate,
+ contestTitle,
+ contestTotalCost,
+ onSubmit,
+ protocol?.id,
+ protocolLogoURL,
+ protocolName,
+ protocolTwitter,
+ protocolWebsite,
+ lswPaymentStructure,
+ isPrivate,
+ requiresKYC,
+ customLswFixedPay,
+ maxNumberOfParticipants,
+ hasLimitedContestants,
+ previousContest,
+ isUpdateContest,
+ token,
+ contestVariables?.exchangeRate,
+ ])
+
+ const isMinimum = useMemo(
+ () =>
+ contestVariables &&
+ contestTotalRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.minTotalRewards}`, 6)) &&
+ contestAuditRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.minContestRewards}`, 6)) &&
+ contestTotalCost?.eq(ethers.utils.parseUnits(`${contestVariables?.minTotalPrice}`, 6)),
+ [contestTotalRewards, contestAuditRewards, contestTotalCost, contestVariables]
+ )
+
+ const isRecommended = useMemo(
+ () =>
+ contestVariables &&
+ contestTotalRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.recTotalRewards}`, 6)) &&
+ contestAuditRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.recContestRewards}`, 6)) &&
+ contestTotalCost?.eq(ethers.utils.parseUnits(`${contestVariables?.recTotalPrice}`, 6)),
+ [contestTotalRewards, contestAuditRewards, contestTotalCost, contestVariables]
+ )
+
+ const handleAutoFillValues = useCallback(
+ (type: "minimum" | "recommended") => {
+ if (type === "minimum") {
+ setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables?.minTotalRewards}`, 6))
+ setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables?.minContestRewards}`, 6))
+ setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables?.minTotalPrice}`, 6))
+ setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables?.minLeadJudgeFixedPay}`, 6))
+ setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables?.minJudgingPrizePool}`, 6))
+ } else {
+ setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables?.recTotalRewards}`, 6))
+ setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables?.recContestRewards}`, 6))
+ setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables?.recTotalPrice}`, 6))
+ setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables?.recLeadJudgeFixedPay}`, 6))
+ setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables?.recJudgingPrizePool}`, 6))
+ }
+ },
+ [setInitialTotalCost, setInitialAuditContestRewards, setInitialTotalRewards, contestVariables]
+ )
+
+ const lswPaymentStructureDetails = useMemo(() => {
+ switch (lswPaymentStructure) {
+ case "TIERED":
+ return "The Lead Senior Watson fixed pay is determied by his leaderboard ranking."
+ case "BEST_EFFORTS":
+ return "The Lead Senior Watson is 33% of the Audit Contest Rewards."
+ case "FIXED":
+ return "The Lead Senior Watson is a custom fixed amount."
+ }
+ }, [lswPaymentStructure])
+
+ const handleSetContestSetup = useCallback((contestSetup: "public" | "private" | "1v1" | "custom") => {
+ if (contestSetup === "public") {
+ setIsPrivate(false)
+ setRequiresKYC(false)
+ setLswPaymentStructure("TIERED")
+ setHasLimitedContestants(false)
+ } else if (contestSetup === "private") {
+ setIsPrivate(true)
+ setRequiresKYC(true)
+ setLswPaymentStructure("TIERED")
+ setHasLimitedContestants(true)
+ setmaxNumberOfParticipants("10")
+ } else if (contestSetup === "1v1") {
+ setIsPrivate(true)
+ setRequiresKYC(true)
+ setLswPaymentStructure("FIXED")
+ setHasLimitedContestants(true)
+ setmaxNumberOfParticipants("2")
+ }
+ }, [])
+
+ const contestSetup = useMemo(() => {
+ if (!isPrivate && !requiresKYC && lswPaymentStructure === "TIERED" && !hasLimitedContestants) {
+ return "public"
+ } else if (
+ isPrivate &&
+ requiresKYC &&
+ lswPaymentStructure === "TIERED" &&
+ hasLimitedContestants &&
+ maxNumberOfParticipants === "10"
+ ) {
+ return "private"
+ } else if (
+ isPrivate &&
+ requiresKYC &&
+ lswPaymentStructure === "FIXED" &&
+ hasLimitedContestants &&
+ maxNumberOfParticipants === "2"
+ ) {
+ return "1v1"
+ } else {
+ return "custom"
+ }
+ }, [isPrivate, requiresKYC, lswPaymentStructure, hasLimitedContestants, maxNumberOfParticipants])
+
+ return (
+
+ {!contest ? (
+ <>
+
+ PROTOCOL
+
+
+
+ {displayProtocolInfo && (
+ <>
+
+
+ Twitter
+
+ }
+ >
+
+
+
+
+ Website
+
+ }
+ >
+
+
+
+
+
+ {(protocol?.logoURL || protocolLogoURL) && (
+
+ )}
+
+
+ >
+ )}
+
+
+ >
+ ) : null}
+
+ CONTEST
+
+
+
+ {draft && !!previousContest && (
+
+
+ {isUpdateContest && (
+
+
+
+ )}
+
+ )}
+ {draft ? null : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pricing presets
+
+ {isMinimum ? (
+
+ Using minimum pricing
+
+ ) : null}
+ {isRecommended ? (
+
+ Using recommended pricing
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {`Admin Fee: ${sherlockFee} ${token}`}
+
+
+
+
+
+
+
+
+
+ {lswPaymentStructureDetails}
+
+ {lswPaymentStructure === "FIXED" && (
+
+ )}
+
+
+
+
+ {hasLimitedContestants && (
+ <>
+
+
+ Includes the Lead Senior Watson
+
+ >
+ )}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/CreateContestModal.tsx b/src/pages/admin/AdminContestsList/CreateContestModal.tsx
index 9ab7ac16..047a6b50 100644
--- a/src/pages/admin/AdminContestsList/CreateContestModal.tsx
+++ b/src/pages/admin/AdminContestsList/CreateContestModal.tsx
@@ -1,267 +1,26 @@
-import { BigNumber, ethers } from "ethers"
-import { DateTime } from "luxon"
-import { useCallback, useEffect, useMemo, useState } from "react"
-import { FaChrome, FaTwitter, FaGithub } from "react-icons/fa"
-import { useDebounce } from "use-debounce"
+import { useCallback, useEffect, useState } from "react"
import { Button } from "../../../components/Button"
-import { Input } from "../../../components/Input"
import { Column, Row } from "../../../components/Layout"
import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
import { Text } from "../../../components/Text"
import { Title } from "../../../components/Title"
-import TokenInput from "../../../components/TokenInput/TokenInput"
import { useAdminCreateContest } from "../../../hooks/api/admin/useAdminCreateContest"
-import { useAdminProtocol } from "../../../hooks/api/admin/useAdminProtocol"
-import { commify } from "../../../utils/units"
-import { Field } from "../../Claim/Field"
import { ErrorModal } from "../../../pages/ContestDetails/ErrorModal"
-import { useAdminTwitterAccount } from "../../../hooks/api/admin/useTwitterAccount"
-import { useAdminContestVariables } from "../../../hooks/api/admin/useAdminContestVariables"
+import { ContestValues, CreateContestForm } from "./CreateContestForm"
type Props = ModalProps & {}
-const DATE_FORMAT = "yyyy-MM-dd"
-
export const CreateContestModal: React.FC = ({ onClose }) => {
- const [protocolName, setProtocolName] = useState("")
- const [debouncedProtocolName] = useDebounce(protocolName, 300)
- const {
- data: protocol,
- isError: protocolNotFound,
- isLoading: protocolLoading,
- } = useAdminProtocol(debouncedProtocolName)
+ const [formIsDirty, setFormIsDirty] = useState(false)
const { createContest, isLoading, isSuccess, error, reset } = useAdminCreateContest()
- const [protocolTwitter, setProtocolTwitter] = useState(protocol?.twitter ?? "")
- const [protocolGithubTeam, setProtocolGithubTeam] = useState(protocol?.githubTeam ?? "")
- const [protocolWebsite, setProtocolWebsite] = useState(protocol?.website ?? "")
- const [protocolLogoURL, setProtocolLogoURL] = useState(protocol?.logoURL ?? "")
-
- const [debouncedProtocolTwitter] = useDebounce(protocolTwitter, 300)
- const { data: twitterAccount } = useAdminTwitterAccount(debouncedProtocolTwitter)
-
- const [contestTitle, setContestTitle] = useState("")
- const [contestShortDescription, setShortDescription] = useState("")
- const [contestNSLOC, setContestNSLOC] = useState("")
- const [debouncedContestNSLOC] = useDebounce(contestNSLOC, 300)
- const [contestStartDate, setContestStartDate] = useState("")
- const [contestAuditLength, setContestAuditLength] = useState("")
- const [contestAuditRewards, setContestAuditRewards] = useState(BigNumber.from(0))
- const [contestJudgingPrizePool, setContestJudgingPrizePool] = useState(BigNumber.from(0))
- const [contestLeadJudgeFixedPay, setContestLeadJudgeFixedPay] = useState(BigNumber.from(0))
- const [contestTotalCost, setContestTotalCost] = useState(BigNumber.from(0))
- const [initialAuditContestRewards, setInitialAuditContestRewards] = useState(BigNumber.from(0))
- const [initialJudgingPrizePool, setInitialJudgingPrizePool] = useState(BigNumber.from(0))
- const [initialLeadJudgeFixedPay, setInitialLeadJudgeFixedPay] = useState(BigNumber.from(0))
- const [initialTotalCost, setInitialTotalCost] = useState(BigNumber.from(0))
-
- const [startDateError, setStartDateError] = useState()
- const [shortDescriptionError, setShortDescriptionError] = useState()
-
const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false)
- const displayProtocolInfo = !!protocol || protocolNotFound || protocolLoading
-
- const { data: contestVariables, isSuccess: contestVariablesSuccess } = useAdminContestVariables(
- parseInt(debouncedContestNSLOC)
- )
-
- useEffect(() => {
- if (contestVariablesSuccess) {
- setContestAuditLength(`${contestVariables.length}`)
-
- setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables.auditContestRewards}`, 6))
- setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables.judgingPrizePool}`, 6))
- setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables.leadJudgeFixedPay}`, 6))
- setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables.fullPayment}`, 6))
- } else {
- setContestAuditLength("")
- setInitialAuditContestRewards(BigNumber.from(0))
- setInitialJudgingPrizePool(BigNumber.from(0))
- setInitialLeadJudgeFixedPay(BigNumber.from(0))
- setInitialTotalCost(BigNumber.from(0))
- }
- }, [contestVariablesSuccess, setContestAuditLength, contestVariables])
-
useEffect(() => {
if (isSuccess) onClose?.()
}, [isSuccess, onClose])
- useEffect(() => {
- if (protocol?.name) {
- setProtocolName(protocol?.name)
- }
- }, [protocol])
-
- useEffect(() => {
- if (twitterAccount?.profilePictureUrl) {
- setProtocolLogoURL(twitterAccount.profilePictureUrl)
- } else {
- setProtocolLogoURL("")
- }
- }, [twitterAccount])
-
- useEffect(() => {
- if (contestStartDate === "") {
- setStartDateError(undefined)
- return
- }
-
- const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT)
-
- if (!startDate.isValid) {
- setStartDateError(`Invalid date. Must be format ${DATE_FORMAT}`)
- return
- }
-
- if (startDate < DateTime.now()) {
- setStartDateError("Start date cannot be in the past.")
- return
- }
-
- setStartDateError(undefined)
- }, [contestStartDate, contestAuditLength])
-
- const sherlockFee = useMemo(() => {
- const fee = contestTotalCost
- ?.sub(contestAuditRewards ?? 0)
- .sub(contestJudgingPrizePool ?? 0)
- .sub(contestLeadJudgeFixedPay ?? 0)
-
- return commify(parseInt(ethers.utils.formatUnits(fee ?? 0, 6)))
- }, [contestTotalCost, contestAuditRewards, contestJudgingPrizePool, contestLeadJudgeFixedPay])
-
- const canCreateContest = useMemo(() => {
- if (protocolName === "") return false
- if (protocolLogoURL === "" && !protocol?.logoURL) return false
- if (protocolWebsite === "" && !protocol?.website) return false
- if (protocolTwitter === "" && !protocol?.twitter) return false
- if (protocolGithubTeam === "" && !protocol?.githubTeam) return false
-
- if (contestTitle === "") return false
- if (contestShortDescription.length < 100 || contestShortDescription.length > 200) return false
-
- if (contestAuditLength === "") return false
-
- const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT)
-
- if (!startDate.isValid) return false
- if (startDate < DateTime.now()) return false
-
- if (contestAuditRewards?.eq(BigNumber.from(0))) return false
- if (contestTotalCost?.eq(BigNumber.from(0))) return false
-
- return true
- }, [
- contestAuditLength,
- contestAuditRewards,
- contestShortDescription.length,
- contestStartDate,
- contestTitle,
- contestTotalCost,
- protocol?.logoURL,
- protocol?.twitter,
- protocol?.website,
- protocol?.githubTeam,
- protocolLogoURL,
- protocolName,
- protocolTwitter,
- protocolWebsite,
- protocolGithubTeam,
- ])
-
- const handleUpdateShortDescription = useCallback((value: string) => {
- setShortDescription(value)
-
- if (value === "") {
- setShortDescriptionError(undefined)
- } else if (value.length < 100) {
- setShortDescriptionError("Too short. Must be between 100 and 200 characters.")
- } else if (value.length > 200) {
- setShortDescriptionError("Too long. Must be between 100 and 200 characters.")
- } else {
- setShortDescriptionError(undefined)
- }
- }, [])
-
- const handleCreateContest = useCallback(() => {
- const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT, { zone: "utc" }).set({
- hour: 15,
- minute: 0,
- second: 0,
- millisecond: 0,
- })
- const endDate = startDate
- .plus({ hours: 24 * parseInt(contestAuditLength) })
- .set({ hour: 15, minute: 0, second: 0, millisecond: 0 })
-
- createContest({
- protocol: {
- id: protocol?.id,
- name: protocolName,
- githubTeam: protocolGithubTeam || undefined,
- website: protocolWebsite || undefined,
- twitter: protocolTwitter || undefined,
- logoUrl: protocolLogoURL || undefined,
- },
- contest: {
- title: contestTitle,
- shortDescription: contestShortDescription,
- nSLOC: contestNSLOC,
- startDate,
- endDate,
- auditRewards: parseInt(ethers.utils.formatUnits(contestAuditRewards ?? 0, 6)),
- judgingPrizePool: parseInt(ethers.utils.formatUnits(contestJudgingPrizePool ?? 0, 6)),
- leadJudgeFixedPay: parseInt(ethers.utils.formatUnits(contestLeadJudgeFixedPay ?? 0, 6)),
- fullPayment: parseInt(ethers.utils.formatUnits(contestTotalCost ?? 0, 6)),
- },
- })
- }, [
- contestAuditLength,
- contestAuditRewards,
- contestJudgingPrizePool,
- contestLeadJudgeFixedPay,
- contestNSLOC,
- contestShortDescription,
- contestStartDate,
- contestTitle,
- contestTotalCost,
- createContest,
- protocol?.id,
- protocolLogoURL,
- protocolName,
- protocolTwitter,
- protocolWebsite,
- protocolGithubTeam,
- ])
-
- const formIsDirty = useMemo(
- () =>
- protocolName !== "" ||
- contestTitle !== "" ||
- contestShortDescription !== "" ||
- contestNSLOC !== "" ||
- contestStartDate !== "" ||
- contestAuditLength !== "" ||
- contestAuditRewards?.gt(BigNumber.from(0)) ||
- contestJudgingPrizePool?.gt(BigNumber.from(0)) ||
- contestLeadJudgeFixedPay?.gt(BigNumber.from(0)) ||
- contestTotalCost?.gt(BigNumber.from(0)),
- [
- contestAuditLength,
- contestAuditRewards,
- contestJudgingPrizePool,
- contestLeadJudgeFixedPay,
- contestNSLOC,
- contestShortDescription,
- contestStartDate,
- contestTitle,
- contestTotalCost,
- protocolName,
- ]
- )
-
const handleModalClose = useCallback(() => {
if (formIsDirty) {
setDisplayModalFormConfirm(true)
@@ -297,123 +56,15 @@ export const CreateContestModal: React.FC = ({ onClose }) => {
)}
-
+
New contest
-
- PROTOCOL
-
-
-
- {displayProtocolInfo && (
- <>
-
-
- GitHub
-
- }
- >
-
-
-
-
- Twitter
-
- }
- >
-
-
-
-
- Website
-
- }
- >
-
-
-
-
-
- {(protocol?.logoURL || protocolLogoURL) && (
-
- )}
-
-
- >
- )}
-
-
-
- CONTEST
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {`Admin Fee: ${sherlockFee} USDC`}
-
-
-
+
{error && reset()} />}
diff --git a/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx b/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx
new file mode 100644
index 00000000..2a5ba09a
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx
@@ -0,0 +1,48 @@
+import { useCallback, useEffect } from "react"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
+import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import { Text } from "../../../components/Text"
+import { Title } from "../../../components/Title"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+import { useAdminDeleteDraftContest } from "../../../hooks/api/admin/useAdminDeleteDraftContest"
+
+type Props = {
+ contest: ContestsListItem
+} & ModalProps
+
+export const DeleteDraftContestModal: React.FC = ({ onClose, contest }) => {
+ const { deleteContest, isLoading, isSuccess } = useAdminDeleteDraftContest()
+
+ useEffect(() => {
+ if (isSuccess) {
+ onClose?.()
+ }
+ }, [isSuccess, onClose])
+
+ const handleResetScope = useCallback(() => {
+ deleteContest({
+ contestID: contest?.id,
+ })
+ }, [deleteContest, contest])
+
+ return (
+
+
+
+ {contest?.title}: Delete contest
+ Are you sure you want to delete this draft contest?
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx b/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx
new file mode 100644
index 00000000..04178ef6
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx
@@ -0,0 +1,39 @@
+import { FaTelegram } from "react-icons/fa"
+import { Column } from "../../../components/Layout"
+import { Text } from "../../../components/Text"
+import { Tooltip, TooltipTrigger, TooltipContent } from "../../../components/Tooltip/Tooltip"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+
+import styles from "./AdminContestsList.module.scss"
+
+type Props = {
+ contest: Pick
+}
+
+export const TelegramBotIndicator: React.FC = ({ contest }) => {
+ return contest.telegramChat ? (
+
+ Linked to telegram group
+
+ ) : (
+
+
+
+ Telegram bot not added
+
+
+
+ {contest.telegramChat ? null : (
+
+
+ To link this contest with a Telegram group chat:
+
+ 1. add https://t.me/sherlockdefibot to the protocol's telegram chat
+ 2. send a link to the dashboard in the chat
+ After that, it will be automatically linked.
+
+ )}
+
+
+ )
+}
diff --git a/src/pages/admin/AdminContestsList/UpdateContestModal.tsx b/src/pages/admin/AdminContestsList/UpdateContestModal.tsx
new file mode 100644
index 00000000..8f1dd151
--- /dev/null
+++ b/src/pages/admin/AdminContestsList/UpdateContestModal.tsx
@@ -0,0 +1,86 @@
+import { useCallback, useEffect, useState } from "react"
+import { Button } from "../../../components/Button"
+import { Column, Row } from "../../../components/Layout"
+import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer"
+import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import { Text } from "../../../components/Text"
+import { Title } from "../../../components/Title"
+import { ErrorModal } from "../../../pages/ContestDetails/ErrorModal"
+import { ContestValues, CreateContestForm } from "./CreateContestForm"
+import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests"
+import { useAdminUpdateContest } from "../../../hooks/api/admin/useAdminUpdateContest"
+
+type Props = ModalProps & {
+ contest: ContestsListItem
+}
+
+export const UpdateContestModal: React.FC = ({ onClose, contest }) => {
+ const [formIsDirty, setFormIsDirty] = useState(false)
+ const { updateContest, isLoading, isSuccess, error, reset } = useAdminUpdateContest()
+
+ const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false)
+
+ useEffect(() => {
+ if (isSuccess) onClose?.()
+ }, [isSuccess, onClose])
+
+ const handleModalClose = useCallback(() => {
+ if (formIsDirty) {
+ setDisplayModalFormConfirm(true)
+ } else {
+ onClose && onClose()
+ }
+ }, [setDisplayModalFormConfirm, onClose, formIsDirty])
+
+ const handleModalCloseConfirm = useCallback(() => {
+ onClose && onClose()
+ }, [onClose])
+
+ const handleModalCloseCancel = useCallback(() => {
+ setDisplayModalFormConfirm(false)
+ }, [])
+
+ const handleFormSubmit = useCallback(
+ (values: ContestValues) => {
+ updateContest({
+ id: contest.id,
+ ...values.contest,
+ })
+ },
+ [contest.id, updateContest]
+ )
+
+ return (
+
+ {displayModalCloseConfirm && (
+
+
+ Unsaved contest
+
+ Are you sure you want to close this form? All unsaved changes will be lost and you will need to start
+ over.
+
+
+
+
+
+
+
+ )}
+
+
+ Edit {contest.title}
+
+
+
+ {error && reset()} />}
+
+ )
+}
diff --git a/src/pages/admin/AdminScope/AdminScope.tsx b/src/pages/admin/AdminScope/AdminScope.tsx
index 64762eec..1659493a 100644
--- a/src/pages/admin/AdminScope/AdminScope.tsx
+++ b/src/pages/admin/AdminScope/AdminScope.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"
-import { FaCopy, FaDownload, FaGithub } from "react-icons/fa"
+import { FaGithub } from "react-icons/fa"
import { useDebounce } from "use-debounce"
import { Box } from "../../../components/Box"
import { Button } from "../../../components/Button"
@@ -14,6 +14,10 @@ import { shortenCommitHash } from "../../../utils/repository"
import { BranchSelectionModal } from "../../AuditScope/BranchSelectionModal"
import { CommitSelectionModal } from "../../AuditScope/CommitSelectionModal"
import { RepositoryContractsSelector } from "../../AuditScope/RepositoryContractsSelector"
+import { repositoryContractsQuery, useRepositoryContracts } from "../../../hooks/api/scope/useRepositoryContracts"
+import { SaveScopeSuccessModal } from "./SaveScopeSuccessModal"
+import { ScopeErrorModal } from "./ScopeErrorModal"
+import { useQueryClient } from "react-query"
export const AdminScope = () => {
const [repoLink, setRepoLink] = useState("")
@@ -21,13 +25,22 @@ export const AdminScope = () => {
const [debouncedRepoName] = useDebounce(repoName, 300)
const [branchName, setBranchName] = useState()
const [commitHash, setCommitHash] = useState()
+ const [nSLOCAdjustment, setNSLOCAdjustment] = useState()
const [files, setFiles] = useState([])
const [branchSelectionModalOpen, setBranchSelectionModalOpen] = useState(false)
const [commitSelectionModalOpen, setCommitSelectionModalOpen] = useState(false)
const { data: repo, isLoading: repoIsLoading } = useRepository(debouncedRepoName)
- const { submitScope, isLoading, data: report } = useAdminSubmitScope()
+ const { submitScope, isLoading, data: report, isSuccess } = useAdminSubmitScope()
+
+ const {
+ data: repoContracts,
+ isLoading: isLoadingContracts,
+ error: contractsError,
+ } = useRepositoryContracts(debouncedRepoName, commitHash ?? "")
+
+ const queryClient = useQueryClient()
useEffect(() => {
const pattern = /^https?:\/\/github\.com\/([A-Za-z0-9-]+\/[A-Za-z0-9-]+)(?:\.git)?(?:\/tree\/([A-Za-z0-9-]+))?$/
@@ -46,6 +59,10 @@ export const AdminScope = () => {
}
}, [repo, branchName])
+ const handleErrorModalClose = useCallback(() => {
+ window.location.reload()
+ }, [])
+
const handlePathSelected = useCallback(
(selectedPaths: string[]) => {
setFiles((f) => {
@@ -75,12 +92,9 @@ export const AdminScope = () => {
[setCommitHash]
)
- const handleSelectAll = useCallback(
- (selectedPaths: string[]) => {
- setFiles(selectedPaths)
- },
- [setFiles]
- )
+ const handleSelectAll = useCallback(() => {
+ setFiles(repoContracts?.rawPaths ?? [])
+ }, [repoContracts])
const handleClearSelection = useCallback(() => {
setFiles([])
@@ -99,21 +113,15 @@ export const AdminScope = () => {
branchName: branchName!,
commitHash: commitHash!,
files,
+ nSLOCAdjustment,
})
- }, [canGenerateReport, submitScope, repo, branchName, commitHash, files])
-
- const handleDownloadReport = useCallback(() => {
- if (report?.url) {
- window.open(report.url, "blank")
- }
- }, [report])
-
- const handleCopyLink = useCallback(async () => {
- await navigator.clipboard.writeText(report?.url ?? "")
- }, [report?.url])
+ }, [canGenerateReport, submitScope, repo, branchName, commitHash, files, nSLOCAdjustment])
return (
-
+
@@ -142,29 +150,17 @@ export const AdminScope = () => {
)}
{repo && (
- {report ? (
-
-
- nSLOC:
- {report.nSLOC}
-
-
-
-
-
-
-
-
- ) : (
-
- )}
+
+ nSLOC Adjustment
+
+
+
+ )}
+ {repo && (
+
+
)}
@@ -177,17 +173,20 @@ export const AdminScope = () => {
{repo.name}
-
+ {repoContracts ? (
+
+ ) : null}
)}
+ {isSuccess && }
+ {contractsError && }
{branchSelectionModalOpen && (
= ({ reportURL, ...props }) => {
+ const handleDownloadClick = useCallback(() => {
+ window.open(reportURL, "blank")
+ }, [reportURL])
+
+ return (
+
+
+ Scope saved
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminScope/ScopeErrorModal.tsx b/src/pages/admin/AdminScope/ScopeErrorModal.tsx
new file mode 100644
index 00000000..9b47a6ca
--- /dev/null
+++ b/src/pages/admin/AdminScope/ScopeErrorModal.tsx
@@ -0,0 +1,21 @@
+import { useCallback } from "react"
+import { Button } from "../../../components/Button"
+import { Column } from "../../../components/Layout"
+import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import { Title } from "../../../components/Title"
+import { FaDownload } from "react-icons/fa"
+import { Text } from "../../../components/Text"
+
+type Props = ModalProps & {}
+
+export const ScopeErrorModal: React.FC = (props) => {
+ return (
+
+
+ Solidity metrics failed
+ There was an error analyzing the scope. Try again.
+
+
+
+ )
+}
diff --git a/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx b/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx
index cd14dadc..4232f568 100644
--- a/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx
+++ b/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx
@@ -13,6 +13,7 @@ import { useContextQuestions } from "../../../hooks/api/protocols/useContextQues
import { useSubmitContextQuestionsAnswers } from "../../../hooks/api/protocols/useSubmitContextQuestionsAnswers"
import { useUpdateContextQuestionAnswers } from "../../../hooks/api/protocols/useUpdateContextQuestionAnswers"
import Modal, { Props as ModalProps } from "../../../components/Modal/Modal"
+import * as DOMPurify from "dompurify"
import styles from "./ContextQuestions.module.scss"
import { ErrorModal } from "../../ContestDetails/ErrorModal"
@@ -139,11 +140,16 @@ export const ContextQuestions = () => {
{contextQuestions && contextQuestions.length > 0 ? (
contextQuestions?.map((q) => {
const answer = answers.find((a) => a.questionID === q.id)
+ const cleanDescriptionHTML = DOMPurify.sanitize(q.description, {
+ USE_PROFILES: { html: true },
+ ADD_ATTR: ["target"],
+ })
+
return (
{q.question}
- {q.description}
+