diff --git a/packages/apps/human-app/frontend/.env.example b/packages/apps/human-app/frontend/.env.example index b0ea764d9e..8a13a7385e 100644 --- a/packages/apps/human-app/frontend/.env.example +++ b/packages/apps/human-app/frontend/.env.example @@ -25,6 +25,7 @@ VITE_H_CAPTCHA_ORACLE_TASK_TYPES=image_points,image_boxes # Other VITE_HUMAN_PROTOCOL_HELP_URL=https://docs.humanprotocol.org/ VITE_HUMAN_PROTOCOL_URL=https://humanprotocol.org/ +VITE_STAKING_DASHBOARD_URL=https://staking.humanprotocol.org/ VITE_HUMAN_SUPPORT_EMAIL=support@local.app VITE_NAVBAR__LINK__HOW_IT_WORK_URL=https://humanprotocol.org/ VITE_NAVBAR__LINK__PROTOCOL_URL=https://humanprotocol.org/ diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg new file mode 100644 index 0000000000..c4c4f89b16 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg new file mode 100644 index 0000000000..7be6824ded --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg new file mode 100644 index 0000000000..37d6a7af11 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg new file mode 100644 index 0000000000..4423c76961 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg new file mode 100644 index 0000000000..77a98a0a93 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg new file mode 100644 index 0000000000..3bbfdd7749 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index ccf792a2b6..bf9aaa190f 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -23,6 +23,7 @@ import { HomePageStateProvider } from '@/shared/contexts/homepage-state'; import { NotificationProvider } from '@/shared/providers/notifications-provider'; import { ModalProvider } from './shared/contexts/modal-context'; import { GlobalModal } from './shared/components/ui/modal/global-modal'; +import { UiConfigProvider } from './shared/providers/ui-config-provider'; const root = document.getElementById('root'); if (!root) throw Error('root element is undefined'); @@ -39,25 +40,30 @@ createRoot(root).render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx index 2b2478b5f4..13b9001fed 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx @@ -11,6 +11,7 @@ const userDataSchema = z.object({ user_id: z.number(), reputation_network: z.string(), exp: z.number(), + is_stake_eligible: z.boolean(), }); export type UserData = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts new file mode 100644 index 0000000000..c958791ca7 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + deleteExchangeApiKeys, + enrollExchangeApiKeys, + getExchangeApiKeys, + getSupportedExchanges, +} from '../services/exchangeApiKeys.service'; + +function useGetSupportedExchanges() { + return useQuery({ + queryKey: ['supported-exchanges'], + queryFn: () => getSupportedExchanges(), + }); +} + +function useGetExchangeApiKeys() { + return useQuery({ + queryKey: ['exchange-api-keys'], + queryFn: () => getExchangeApiKeys(), + }); +} + +function useEnrollExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['enroll-exchange-api-keys'], + mutationFn: (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => enrollExchangeApiKeys(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); + }, + }); +} + +function useDeleteExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['delete-exchange-api-keys'], + mutationFn: () => deleteExchangeApiKeys(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); + }, + }); +} + +export { + useGetSupportedExchanges, + useDeleteExchangeApiKeys, + useGetExchangeApiKeys, + useEnrollExchangeApiKeys, +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx deleted file mode 100644 index 7f6663e162..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { - TopNotificationType, - useNotification, -} from '@/shared/hooks/use-notification'; -import { getErrorMessageForError } from '@/shared/errors'; -import type { ResponseError } from '@/shared/types/global.type'; - -export function useIdvErrorNotifications() { - const { showNotification } = useNotification(); - - return (error: ResponseError) => { - showNotification({ - type: TopNotificationType.WARNING, - message: getErrorMessageForError(error), - durationMs: 5000, - }); - }; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts new file mode 100644 index 0000000000..1556c072d6 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getStakingSummary } from '../services/staking.service'; + +function useGetStakingSummary() { + return useQuery({ + queryKey: ['staking-summary'], + queryFn: () => getStakingSummary(), + }); +} + +export { useGetStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts index 8bd98d3628..e92ec7dc33 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts @@ -8,7 +8,5 @@ export const shouldNavigateToRegistration = ( oracle: Oracle, registrationData?: RegistrationResult ): boolean => - Boolean( - oracle.registrationNeeded && - !registrationData?.oracle_addresses.includes(oracle.address) - ); + !!oracle.registrationNeeded && + !registrationData?.oracle_addresses.includes(oracle.address); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx index f90e919961..6ce55c5c48 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { AvailableJobsFilterModal } from '../available-jobs-filter-modal'; export function useAvailableJobsFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx index 5e65b627ae..21364ccedf 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx @@ -7,7 +7,7 @@ import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { useColorMode } from '@/shared/contexts/color-mode'; import { NoRecords } from '@/shared/components/ui/no-records'; import { PageCardLoader } from '@/shared/components/ui/page-card'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { useGetOracles } from '../hooks'; import { useGetOraclesNotifications } from '../hooks/use-get-oracles-notifications'; import { TabPanel } from './components'; @@ -30,19 +30,15 @@ export function JobsPage() { error, } = useGetOracles(); - const { - data: uiConfigData, - isPending: isPendingUiConfig, - isError: isErrorUiConfig, - } = useGetUiConfig(); + const { uiConfig, isUiConfigLoading, isUiConfigError } = useUiConfig(); const { address: oracle_address } = useParams<{ address: string }>(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(0); const isMobile = useIsMobile(); - const isError = isErrorGetOracles || isErrorUiConfig; - const isPending = isPendingGetOracles || isPendingUiConfig; + const isError = isErrorGetOracles || isUiConfigError; + const isPending = isPendingGetOracles || isUiConfigLoading; const { onError } = useGetOraclesNotifications(); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { @@ -119,7 +115,7 @@ export function JobsPage() { ) : ( )} @@ -128,7 +124,7 @@ export function JobsPage() { ) : ( )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx index 40a0c9c1be..a41ca0c491 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; import { MyJobsFilterModal } from '../components/mobile/my-jobs-filter-modal'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function useMyJobFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx new file mode 100644 index 0000000000..f3b1ee6954 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -0,0 +1,252 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + Box, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; +import { useEffect } from 'react'; + +interface AddApiKeyModalProps { + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function AddApiKeyModal({ onClose, disableClose }: AddApiKeyModalProps) { + const { t } = useTranslation(); + const { + mutate: enrollExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isPending, + isError, + isIdle, + } = useEnrollExchangeApiKeys(); + const isMobile = useIsMobile(); + const { data: supportedExchanges } = useGetSupportedExchanges(); + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + secretKey: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().trim().min(1, t('validation.required')), + secretKey: z.string().trim().min(1, t('validation.required')), + }) + ), + }); + + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => { + enrollExchangeApiKey(data); + }; + + return ( +
+ + + {isMobile + ? t('worker.profile.apiKeyData.connectApiKey') + : t('worker.profile.apiKeyData.connectYourApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} + /> + )} + /> + {errors.exchange && ( + + {errors.exchange.message} + + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + {errors.secretKey && ( + + {errors.secretKey.message} + + )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.connectSuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + {t('worker.profile.apiKeyData.modalFooterAgreement')} + + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx new file mode 100644 index 0000000000..55cb3efb93 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx @@ -0,0 +1,101 @@ +import { IconButton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { DeleteIcon, EditIcon } from '@/shared/components/ui/icons'; +import { + useDeleteApiKeyModal, + useEditApiKeyModal, +} from '../hooks/use-api-key-modals'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; + +export function ApiKeyData() { + const { isDarkMode } = useColorMode(); + const { t } = useTranslation(); + const { openModal: openEditApiKeyModal } = useEditApiKeyModal(); + const { openModal: openDeleteApiKeyModal } = useDeleteApiKeyModal(); + const { data: exchangeApiKeyData, isError: isExchangeApiKeyError } = + useGetExchangeApiKeys(); + + const textField = isDarkMode ? ( + + ) : ( + + ); + + return ( + + + + {t('worker.profile.apiKeyData.apiKey')} + + + + + {textField} + {exchangeApiKeyData?.exchange_name && ( + + + openEditApiKeyModal(exchangeApiKeyData.exchange_name) + } + > + + + + + + + )} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx new file mode 100644 index 0000000000..de9183c226 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx @@ -0,0 +1,42 @@ +import { colorPalette } from '@/shared/styles/color-palette'; +import { + darkColorPalette, + onlyDarkModeColor, +} from '@/shared/styles/dark-color-palette'; +import { styled, TextField } from '@mui/material'; + +const CustomTextField = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: `${colorPalette.text.disabledSecondary} !important`, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${colorPalette.text.primary} !important`, + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: colorPalette.text.disabledSecondary, + }, + }, +})); + +const CustomTextFieldDark = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: `${darkColorPalette.text.disabledSecondary} !important`, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: darkColorPalette.text.disabledSecondary, + }, + }, +})); + +export { CustomTextField, CustomTextFieldDark }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx new file mode 100644 index 0000000000..6a75d44423 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx @@ -0,0 +1,118 @@ +import { Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { useDeleteExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useEffect } from 'react'; +import { ModalError, ModalSuccess, ModalLoading } from './modal-states'; + +interface DeleteApiKeyModalProps { + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function DeleteApiKeyModal({ + onClose, + disableClose, +}: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const { + mutate: deleteExchangeApiKey, + reset: resetMutation, + isSuccess, + isError, + isPending, + isIdle, + } = useDeleteExchangeApiKeys(); + const isMobile = useIsMobile(); + + const handleDeleteExchangeApiKey = () => { + deleteExchangeApiKey(); + }; + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + useEffect(() => { + return () => { + resetMutation(); + }; + }, [resetMutation]); + + return ( + + + {t('worker.profile.apiKeyData.deleteApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.deleteApiKeyConfirmation')} + + + {t('worker.profile.apiKeyData.deleteApiKeyDescription')} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.deleteKeySuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx new file mode 100644 index 0000000000..9141eb9b02 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + Box, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { useEffect } from 'react'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; + +interface EditApiKeyModalProps { + exchangeName: string; + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function EditApiKeyModal({ + onClose, + exchangeName, + disableClose, +}: EditApiKeyModalProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const { + mutate: postExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isError, + isPending, + isIdle, + } = useEnrollExchangeApiKeys(); + const { data: supportedExchanges } = useGetSupportedExchanges(); + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + secretKey: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().trim().min(1, t('validation.required')), + secretKey: z.string().trim().min(1, t('validation.required')), + }) + ), + }); + + useEffect(() => { + if (exchangeName && supportedExchanges) { + reset({ + exchange: exchangeName, + apiKey: '', + secretKey: '', + }); + } + }, [exchangeName, reset, supportedExchanges]); + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => { + postExchangeApiKey(data); + }; + + return ( +
+ + + {t('worker.profile.apiKeyData.editApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} + /> + )} + /> + {errors.exchange && ( + + {errors.exchange.message} + + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + {errors.secretKey && ( + + {errors.secretKey.message} + + )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.editSuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx index b808043119..6acd2ab252 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx @@ -29,7 +29,7 @@ export function IdentityVerificationControl() { return ( - {t('worker.profile.identityVerificationStatus')}:{' '} + {t('worker.profile.identityVerificationStatus')} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts index 43ff1e202d..7df62f04b8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts @@ -1,3 +1,10 @@ export * from './wallet-connect-done'; -export * from './profile-actions'; export * from './profile-data'; +export * from './identity-verification-control'; +export * from './wallet-connection-control'; +export * from './staking-info'; +export * from './api-key-data'; +export * from './custom-text-field'; +export * from './add-api-key-modal'; +export * from './edit-api-key-modal'; +export * from './delete-api-key-modal'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx new file mode 100644 index 0000000000..5225e3bd2b --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx @@ -0,0 +1,55 @@ +import CheckIcon from '@mui/icons-material/CheckCircle'; +import { Loader } from '@/shared/components/ui/loader'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CloseIcon from '@mui/icons-material/Cancel'; + +export function ModalLoading() { + return ( + + + + ); +} + +export function ModalSuccess({ children }: { children: React.ReactNode }) { + return ( + <> + + + + {children} + + ); +} + +export function ModalError({ message }: { message: string }) { + return ( + <> + + + + + {message} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx deleted file mode 100644 index 56551421f6..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Grid from '@mui/material/Grid'; -import { IdentityVerificationControl } from './identity-verification-control'; -import { WalletConnectionControl } from './wallet-connection-control'; - -export function ProfileActions() { - return ( - - - - - - - - - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx index b3222fe956..42278a97c8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx @@ -1,5 +1,4 @@ -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; +import { Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; @@ -13,8 +12,8 @@ export function ProfileData() { const { user } = useAuthenticatedUser(); const { t } = useTranslation(); return ( - - + + {t('worker.profile.email')} {user.email} - - + + + + {t('worker.profile.password')} + - - + + ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx new file mode 100644 index 0000000000..7c24f973db --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -0,0 +1,185 @@ +import { Button, IconButton, Skeleton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { env } from '@/shared/env'; +import { useAddApiKeyModal } from '../hooks/use-api-key-modals'; +import { ApiKeyData } from './'; +import { useGetStakingSummary } from '../../hooks/use-staking'; +import { RefreshIcon } from '@/shared/components/ui/icons'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { useEffect, useRef, useState } from 'react'; +import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; +import { colorPalette } from '@/shared/styles/color-palette'; +import { useGetUiConfig } from '@/shared/hooks/use-get-ui-config'; + +export function StakingInfo() { + const [isPromptExpanded, setIsPromptExpanded] = useState(false); + const tokenRefreshLock = useRef(false); + + const { user, updateUserData } = useAuthenticatedUser(); + const { refreshAccessTokenAsync } = useAccessTokenRefresh(); + const { t } = useTranslation(); + + const { openModal: openAddApiKeyModal } = useAddApiKeyModal(); + const { data: exchangeApiKeyData, isLoading: isExchangeApiKeyLoading } = + useGetExchangeApiKeys(); + const { + data: stakingSummary, + isLoading, + isError, + refetch, + isRefetching, + } = useGetStakingSummary(); + const { data: uiConfig, isLoading: isUiConfigLoading } = useGetUiConfig(); + + const isConnectButtonDisabled = + !!exchangeApiKeyData?.exchange_name || isExchangeApiKeyLoading; + + const stakedAmount = + Number(stakingSummary?.on_chain_stake || 0) + + Number(stakingSummary?.exchange_stake || 0); + + const isStakingError = + !!stakingSummary?.on_chain_error || + !!stakingSummary?.exchange_error || + isError; + + const isStaked = + isLoading || isStakingError || isUiConfigLoading + ? false + : stakedAmount >= Number(uiConfig?.minThreshold || '0'); + + useEffect(() => { + if (isRefetching || isLoading) return; + + if (isStaked !== user.is_stake_eligible) { + if (!tokenRefreshLock.current) { + tokenRefreshLock.current = true; + updateUserData({ is_stake_eligible: isStaked }); + void refreshAccessTokenAsync({ authType: 'web2' }); + } + } else { + tokenRefreshLock.current = false; + } + }, [ + isStaked, + user.is_stake_eligible, + refreshAccessTokenAsync, + isRefetching, + isLoading, + updateUserData, + ]); + + const handleRefreshStakingInfo = () => { + if (isRefetching || isLoading) return; + tokenRefreshLock.current = false; + refetch(); + }; + + return ( + + + + {t('worker.profile.stakingInfo.stakeHmt')} + + {isLoading || isRefetching ? ( + + ) : ( + + )} + + + {isPromptExpanded + ? t('worker.profile.stakingInfo.prompt', { + amount: uiConfig?.minThreshold, + }) + : t('worker.profile.stakingInfo.promptShort')}{' '} + + + + + {t('worker.profile.stakingInfo.stakedAmount')} + + svg > path': { + fill: + isRefetching || isLoading + ? colorPalette.button.disabled + : 'primary.main', + }, + }} + onClick={handleRefreshStakingInfo} + > + + + + {isLoading || isRefetching ? ( + + ) : ( + + {stakedAmount} HMT + + )} + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx index 263b262173..808b282eb1 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx @@ -1,57 +1,97 @@ -import { Stack, TextField, Typography } from '@mui/material'; +import { IconButton, Stack, Tooltip, Typography } from '@mui/material'; import { t } from 'i18next'; -import styled from '@mui/material/styles/styled'; -import { colorPalette } from '@/shared/styles/color-palette'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { - darkColorPalette, - onlyDarkModeColor, -} from '@/shared/styles/dark-color-palette'; import { useWalletConnect } from '@/shared/contexts/wallet-connect'; import { Chip } from '@/shared/components/ui/chip'; - -const CustomTextField = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: colorPalette.text.disabledSecondary, - WebkitTextFillColor: colorPalette.text.disabledSecondary, - }, -})); -const CustomTextFieldDark = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: darkColorPalette.text.disabledSecondary, - WebkitTextFillColor: darkColorPalette.text.disabledSecondary, - }, - '& .MuiOutlinedInput-root': { - '& fieldset': { - borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, - }, - }, -})); +import { CopyIcon } from '@/shared/components/ui/icons'; +import { MouseEvent, useRef, useState } from 'react'; +import { shortenEscrowAddress } from '@/shared/helpers/evm'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; export function WalletConnectDone() { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(); const { isDarkMode } = useColorMode(); const { address } = useWalletConnect(); - const { user } = useAuthenticatedUser(); + const { + user: { wallet_address }, + } = useAuthenticatedUser(); + + if (!wallet_address) { + return null; + } + + const shortAddress = shortenEscrowAddress(wallet_address, 6, 6); + + const handleCopyClick = (e: MouseEvent) => { + if (isCopied) return; + + e.stopPropagation(); + navigator.clipboard.writeText(wallet_address); + setIsCopied(true); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + }, 1500); + }; const textFiled = isDarkMode ? ( - + + + + + + ), + }} + /> ) : ( - + + + + + + ), + }} + /> ); return ( - + - {t('worker.profile.wallet')}:{' '} + {t('worker.profile.wallet')} - {address && !user.wallet_address ? null : textFiled} + {address && !wallet_address ? null : textFiled} ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx new file mode 100644 index 0000000000..5d7db03586 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx @@ -0,0 +1,52 @@ +import { useModal } from '@/shared/contexts/modal-context'; +import { + AddApiKeyModal, + EditApiKeyModal, + DeleteApiKeyModal, +} from '../components'; + +export function useAddApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: () => + openModal({ + content: ( + + ), + }), + }; +} + +export function useEditApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: (exchangeName: string) => + openModal({ + content: ( + + ), + }), + }; +} + +export function useDeleteApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: () => + openModal({ + content: ( + + ), + }), + }; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts index ef40c57822..ca9bc21805 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts @@ -1,11 +1,13 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIdvErrorNotifications } from '@/modules/worker/hooks/use-idv-notification'; import { ApiClientError } from '@/api'; import { useIdvStartMutation } from './use-start-idv-mutation'; +import { TopNotificationType, useNotification } from '@/shared/hooks'; +import { getErrorMessageForError } from '@/shared/errors'; export function useStartIdv() { const [isIdvAlreadyInProgress, setIsIdvAlreadyInProgress] = useState(false); - const onError = useIdvErrorNotifications(); + + const { showNotification } = useNotification(); const { data: idvStartData, isPending: idvStartIsPending, @@ -28,7 +30,11 @@ export function useStartIdv() { setIsIdvAlreadyInProgress(true); return; } - onError(idvStartMutationError); + showNotification({ + type: TopNotificationType.WARNING, + message: getErrorMessageForError(idvStartMutationError), + durationMs: 5000, + }); } if (idvStarted && idvStartData.url) { @@ -39,7 +45,7 @@ export function useStartIdv() { idvStartFailed, idvStarted, idvStartMutationError, - onError, + showNotification, ]); return { diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx index 4f03b09c81..3fa42e64b9 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx @@ -1,4 +1,4 @@ -import { Grid, Paper } from '@mui/material'; +import { Paper, Stack } from '@mui/material'; import { useEffect } from 'react'; import { t } from 'i18next'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; @@ -8,7 +8,14 @@ import { TopNotificationType, useNotification, } from '@/shared/hooks/use-notification'; -import { ProfileData, ProfileActions } from '../components'; +import { + ProfileData, + IdentityVerificationControl, + WalletConnectionControl, + StakingInfo, +} from '../components'; +import { PageCardLoader } from '@/shared/components/ui/page-card/page-card-loader'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function WorkerProfilePage() { const { user } = useAuthenticatedUser(); @@ -16,6 +23,7 @@ export function WorkerProfilePage() { const { isConnected, initializing, web3ProviderMutation } = useWalletConnect(); const { showNotification } = useNotification(); + const { uiConfig, isUiConfigLoading } = useUiConfig(); useEffect(() => { if (initializing) return; @@ -42,6 +50,10 @@ export function WorkerProfilePage() { showNotification, ]); + if (isUiConfigLoading) { + return ; + } + return ( - + - - + + + {!!user.wallet_address && uiConfig?.stakingEligibilityEnabled && ( + + )} + ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx new file mode 100644 index 0000000000..c9e9843718 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx @@ -0,0 +1,31 @@ +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { routerPaths } from '@/router/router-paths'; +import { protectedRoutes } from '@/router/routes'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; +import { Navigate, useLocation, matchPath } from 'react-router-dom'; + +const stakeProtectedPaths = protectedRoutes + .map((route) => route.routerProps.path) + .filter((path) => path !== routerPaths.worker.profile); + +export function RequireStake({ + children, +}: Readonly<{ children: JSX.Element }>) { + const { user } = useAuthenticatedUser(); + const location = useLocation(); + const { uiConfig } = useUiConfig(); + + const isStakeProtectedRoute = stakeProtectedPaths.some( + (path) => path && matchPath(path, location.pathname) + ); + + if ( + uiConfig?.stakingEligibilityEnabled && + !user?.is_stake_eligible && + isStakeProtectedRoute + ) { + return ; + } + + return children; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts new file mode 100644 index 0000000000..41db9404e2 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts @@ -0,0 +1,46 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +interface ExchangeApiKey { + api_key: string; + exchange_name: string; +} + +interface Exchange { + name: string; + display_name: string; +} + +async function getSupportedExchanges(): Promise { + const response = await authorizedHumanAppApiClient.get( + '/exchange-api-keys/supported-exchanges' + ); + return response || []; +} + +async function getExchangeApiKeys(): Promise { + const response = + await authorizedHumanAppApiClient.get('/exchange-api-keys'); + return response || null; +} + +async function enrollExchangeApiKeys(data: { + exchange: string; + apiKey: string; + secretKey: string; +}): Promise { + const { exchange, ...body } = data; + await authorizedHumanAppApiClient.post(`/exchange-api-keys/${exchange}`, { + body, + }); +} + +async function deleteExchangeApiKeys(): Promise { + await authorizedHumanAppApiClient.delete('/exchange-api-keys'); +} + +export { + enrollExchangeApiKeys, + getExchangeApiKeys, + deleteExchangeApiKeys, + getSupportedExchanges, +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts new file mode 100644 index 0000000000..d4a38a65b3 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts @@ -0,0 +1,16 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +interface StakeSummary { + exchange_stake: string; + exchange_error?: string; + on_chain_stake: string; + on_chain_error?: string; +} + +async function getStakingSummary(): Promise { + const response = + await authorizedHumanAppApiClient.get('/staking/summary'); + return response || null; +} + +export { getStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx index 427bba124b..5c39359e16 100644 --- a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx +++ b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx @@ -9,14 +9,21 @@ import type { UserData } from '@/modules/auth/context/auth-context'; import { routerPaths } from '@/router/router-paths'; import { KycStatus } from '@/modules/worker/profile/types'; import { type MenuItem } from '../layout/protected'; +import { UiConfig } from '@/shared/services/ui-config.service'; -export const workerDrawerTopMenuItems = (user: UserData | null): MenuItem[] => { +export const workerDrawerTopMenuItems = ( + user: UserData | null, + uiConfig: UiConfig | undefined +): MenuItem[] => { return [ { label: t('components.DrawerNavigation.jobs'), icon: , link: routerPaths.worker.jobsDiscovery, - disabled: !user?.wallet_address || user.kyc_status !== KycStatus.APPROVED, + disabled: + !user?.wallet_address || + (uiConfig?.stakingEligibilityEnabled && !user?.is_stake_eligible) || + user.kyc_status !== KycStatus.APPROVED, }, ]; }; diff --git a/packages/apps/human-app/frontend/src/router/router.tsx b/packages/apps/human-app/frontend/src/router/router.tsx index b22c8052d8..0d27ce4c46 100644 --- a/packages/apps/human-app/frontend/src/router/router.tsx +++ b/packages/apps/human-app/frontend/src/router/router.tsx @@ -20,9 +20,12 @@ import { workerDrawerBottomMenuItems, workerDrawerTopMenuItems, } from './components'; +import { RequireStake } from '@/modules/worker/providers/require-stake'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function Router() { const { user } = useAuth(); + const { uiConfig } = useUiConfig(); const handleSignOut = () => { browserAuthProvider.signOut({ @@ -57,22 +60,24 @@ export function Router() { - ( - - )} - renderHCaptchaStatisticsDrawer={(isOpen) => ( - - )} - renderGovernanceBanner - /> + + ( + + )} + renderHCaptchaStatisticsDrawer={(isOpen) => ( + + )} + renderGovernanceBanner + /> + } key={routerProps.path} diff --git a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx index 4a8c8ada84..ba1cb12ec9 100644 --- a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx @@ -5,10 +5,8 @@ import { Typography } from '@mui/material'; import { type InputMask } from '@/shared/components/data-entry/input-masks'; import { useColorMode } from '@/shared/contexts/color-mode'; -export interface InputProps extends Omit< - TextFieldProps, - 'name' | 'error' | 'helperText' -> { +type OmittedProps = Omit; +export interface InputProps extends OmittedProps { name: string; label?: string; autoComplete?: string; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx index 25681beef6..f58d0b3cba 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx @@ -39,6 +39,12 @@ import MoonIconLight from '@/assets/icons/moon.svg'; import { useColorMode } from '@/shared/contexts/color-mode'; import WorkHeaderDark from '@/assets/icons-dark-mode/work-header.svg'; import WorkHeaderLight from '@/assets/icons/work-header.svg'; +import CopyIconLight from '@/assets/icons/content-copy.svg'; +import CopyIconDark from '@/assets/icons-dark-mode/content-copy.svg'; +import EditIconLight from '@/assets/icons/edit-icon.svg'; +import EditIconDark from '@/assets/icons-dark-mode/edit-icon.svg'; +import DeleteIconLight from '@/assets/icons/delete-icon.svg'; +import DeleteIconDark from '@/assets/icons-dark-mode/delete-icon.svg'; function HomepageLogoIcon() { const { isDarkMode } = useColorMode(); @@ -124,6 +130,18 @@ function MoonIcon() { const { isDarkMode } = useColorMode(); return isDarkMode ? : ; } +function CopyIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function EditIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function DeleteIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} export { HomepageLogoIcon, @@ -148,4 +166,7 @@ export { SunIcon, MoonIcon, WorkHeaderIcon, + CopyIcon, + EditIcon, + DeleteIcon, }; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx index 16958d5dc7..7a73af61b8 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx @@ -1,30 +1,49 @@ -import { DialogContent } from '@mui/material'; -import DialogMui from '@mui/material/Dialog'; +import { DialogContent, IconButton, Dialog } from '@mui/material'; import { useModal } from '../../../contexts/modal-context'; -import { ModalHeader } from './modal-header'; +import CloseIcon from '@mui/icons-material/Close'; export function GlobalModal() { - const { open, closeModal, showCloseButton, content, onTransitionExited } = - useModal(); + const { + open, + closeModal, + showCloseButton, + disableClose, + content, + onTransitionExited, + } = useModal(); return ( - - - {content} - + {showCloseButton && ( + + + + )} + + {content} + + ); } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx deleted file mode 100644 index 423a295366..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Grid from '@mui/material/Grid'; -import Button from '@mui/material/Button'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -interface ModalHeaderElementProps { - isVisible: boolean; - onClick: () => void; -} - -interface ModalHeaderProps { - closeButton?: ModalHeaderElementProps; -} - -export function ModalHeader({ closeButton }: Readonly) { - const { t } = useTranslation(); - return ( - - - {closeButton?.isVisible && ( - - - - )} - - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx b/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx index 276c85be50..daf1e32eab 100644 --- a/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx +++ b/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx @@ -10,9 +10,11 @@ interface ModalContextType { open: boolean; content: React.ReactNode; showCloseButton: boolean; + disableClose: boolean; openModal: ({ content, showCloseButton }: OpenModalProps) => void; closeModal: () => void; onTransitionExited: () => void; + setDisableClose: (disable: boolean) => void; } interface OpenModalProps { @@ -28,6 +30,7 @@ export function ModalProvider({ const [open, setOpen] = useState(false); const [content, setContent] = useState(null); const [showCloseButton, setShowCloseButton] = useState(true); + const [disableClose, setDisableClose] = useState(false); const openModal = useCallback( ({ @@ -36,14 +39,16 @@ export function ModalProvider({ }: OpenModalProps) => { setContent(_modalContent); setShowCloseButton(_showCloseButton ?? showCloseButton); + setDisableClose(false); setOpen(true); }, [showCloseButton] ); const closeModal = useCallback(() => { + if (disableClose) return; setOpen(false); - }, []); + }, [disableClose]); const onTransitionExited = useCallback(() => { setContent(null); @@ -54,11 +59,21 @@ export function ModalProvider({ open, content, showCloseButton, + disableClose, openModal, closeModal, onTransitionExited, + setDisableClose, }), - [open, content, showCloseButton, openModal, closeModal, onTransitionExited] + [ + open, + content, + showCloseButton, + disableClose, + openModal, + closeModal, + onTransitionExited, + ] ); return ( diff --git a/packages/apps/human-app/frontend/src/shared/env.ts b/packages/apps/human-app/frontend/src/shared/env.ts index b442081d05..3be4fb921a 100644 --- a/packages/apps/human-app/frontend/src/shared/env.ts +++ b/packages/apps/human-app/frontend/src/shared/env.ts @@ -5,6 +5,7 @@ const envSchema = z.object({ VITE_PRIVACY_POLICY_URL: z.string(), VITE_TERMS_OF_SERVICE_URL: z.string(), VITE_HUMAN_PROTOCOL_URL: z.string(), + VITE_STAKING_DASHBOARD_URL: z.string(), VITE_NAVBAR__LINK__PROTOCOL_URL: z.string(), VITE_NAVBAR__LINK__HOW_IT_WORK_URL: z.string(), VITE_HUMAN_SUPPORT_EMAIL: z.string(), diff --git a/packages/apps/human-app/frontend/src/shared/i18n/en.json b/packages/apps/human-app/frontend/src/shared/i18n/en.json index 91bae6249a..951559cb63 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -90,7 +90,8 @@ "pageCardError": { "reload": "Reload", "goHome": "Home Page" - } + }, + "copyToClipboard": "Copied" }, "homepage": { "humanApp": "HUMAN App", @@ -225,6 +226,47 @@ "review": "Under Review", "expired": "Expired", "abandoned": "Abandoned" + }, + "stakingStatusValues": { + "staked": "Staked", + "error": "Error", + "notStaked": "Not Staked" + }, + "stakingInfo": { + "promptShort": "Before you can start earning on the HUMAN App, you'll need to stake HMT.", + "prompt": "Before you can start earning on the HUMAN App, you’ll need to stake HMT. You can stake directly on our Staking Dashboard, or connect your API key having minimum balance of {{amount}} HMT in your exchange account to stake automatically via API KEY.", + "stakeHmt": "Stake HMT", + "stakingInfoValue": "Staking Info Value", + "stakedAmount": "Staked amount", + "readMore": "Read more", + "readLess": "Read less" + }, + "apiKeyData": { + "apiKey": "API KEY", + "exchange": "Exchange", + "apiSecret": "API Secret", + "apiKeyConnected": "Connected", + "apiKeyNotConnected": "Not connected", + "cancel": "Cancel", + "error": "Error", + "close": "Close", + "edit": "Edit", + "tryAgain": "Try Again", + "connectYourApiKey": "Connect your API KEY", + "connectApiKey": "Connect API KEY", + "connectSuccess": "You have successfully connected your API key", + "connectError": "Failed to connect API key.", + "editSuccess": "You have successfully edited your API key", + "editError": "Failed to edit API key.", + "deleteKeySuccess": "You have successfully deleted your API key", + "deleteKeyError": "Failed to delete API key.", + "modalDescription": "To stake HMT using your exchange balance, connect your exchange account by adding your API key and secret. Your credentials are encrypted and securely stored.", + "modalFooterAgreement": "By connecting your API KEY, you agree to HUMAN Protocol Terms of Service and consent to its Privacy Policy.", + "deleteApiKey": "Delete API KEY", + "deleteApiKeyConfirmation": "Are you sure you want to delete your API key?", + "deleteApiKeyDescription": "You can only solve jobs in the HUMAN App if you have staked HMT.", + "editApiKey": "Edit API KEY", + "saveChanges": "Save Changes" } }, "oraclesTable": { diff --git a/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx new file mode 100644 index 0000000000..5f23cd7d49 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useGetUiConfig } from '../hooks/use-get-ui-config'; +import { UiConfig } from '../services/ui-config.service'; + +interface UiConfigContextType { + uiConfig: UiConfig | undefined; + isUiConfigLoading: boolean; + isUiConfigError: boolean; + error: Error | null; +} + +const UiConfigContext = createContext( + undefined +); + +export function UiConfigProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { data, isLoading, error, isError } = useGetUiConfig(); + + const contextValue = useMemo( + () => ({ + uiConfig: data, + isUiConfigLoading: isLoading, + isUiConfigError: isError, + error, + }), + [data, isLoading, isError, error] + ); + + return ( + + {children} + + ); +} + +export const useUiConfig = () => { + const context = useContext(UiConfigContext); + if (!context) { + throw new Error('useUiConfig must be used within a UiConfigProvider'); + } + return context; +}; diff --git a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts index 4343540456..5103cc92c3 100644 --- a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts +++ b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts @@ -7,9 +7,11 @@ const apiPaths = { const uiConfigSchema = z.object({ chainIdsEnabled: z.array(z.number()), + stakingEligibilityEnabled: z.boolean().prefault(false), + minThreshold: z.string(), }); -type UiConfig = z.infer; +export type UiConfig = z.infer; async function getUiConfig() { try { diff --git a/packages/apps/human-app/frontend/src/shared/styles/theme.ts b/packages/apps/human-app/frontend/src/shared/styles/theme.ts index 871d14719e..44a04ae421 100644 --- a/packages/apps/human-app/frontend/src/shared/styles/theme.ts +++ b/packages/apps/human-app/frontend/src/shared/styles/theme.ts @@ -140,6 +140,13 @@ export const theme: ThemeOptions = { }, }, }, + MuiSkeleton: { + styleOverrides: { + root: { + transform: 'scale(1,1)', + }, + }, + }, }, breakpoints: { values: { diff --git a/packages/apps/human-app/frontend/vite.config.mjs b/packages/apps/human-app/frontend/vite.config.mjs index 40943d146b..436d6c86a8 100644 --- a/packages/apps/human-app/frontend/vite.config.mjs +++ b/packages/apps/human-app/frontend/vite.config.mjs @@ -22,6 +22,9 @@ const config = defineConfig({ }, build: { target: 'esnext', + commonjsOptions: { + include: [/core/, /human-protocol-sdk/, /node_modules/], + }, }, server: { host: '127.0.0.1', @@ -29,12 +32,14 @@ const config = defineConfig({ }, optimizeDeps: { include: [ + '@human-protocol/sdk', '@mui/material', '@emotion/react', '@emotion/styled', '@mui/material/Tooltip', '@mui/material/Paper', ], + force: true, }, }); diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 529154b0ed..6de9bbd087 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -58,6 +58,10 @@ import { OperatorController } from './modules/user-operator/operator.controller' import { OperatorModule } from './modules/user-operator/operator.module'; import { WorkerController } from './modules/user-worker/worker.controller'; import { WorkerModule } from './modules/user-worker/worker.module'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys/exchange-api-keys.module'; +import { ExchangeApiKeysController } from './modules/exchange-api-keys/exchange-api-keys.controller'; +import { StakingController } from './modules/staking/staking.controller'; +import { StakingModule } from './modules/staking/staking.module'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -147,6 +151,8 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAModule, AbuseModule, GovernanceModule, + ExchangeApiKeysModule, + StakingModule, ], controllers: [ AppController, @@ -162,6 +168,8 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAController, AbuseController, GovernanceController, + ExchangeApiKeysController, + StakingController, ], exports: [HttpModule], providers: [ diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index 7f7c548fb2..0dbbb0c2cb 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -141,6 +141,36 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.DELETE, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES]: { + endpoint: '/exchange-api-keys/supported', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.STAKE_SUMMARY]: { + endpoint: '/staking/summary', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.STAKE_CONFIG]: { + endpoint: '/staking/config', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { diff --git a/packages/apps/human-app/server/src/common/enums/http-method.ts b/packages/apps/human-app/server/src/common/enums/http-method.ts index 5a3bbf6de1..4dfcef38b9 100644 --- a/packages/apps/human-app/server/src/common/enums/http-method.ts +++ b/packages/apps/human-app/server/src/common/enums/http-method.ts @@ -1,4 +1,5 @@ export enum HttpMethod { GET = 'GET', POST = 'POST', + DELETE = 'DELETE', } diff --git a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts index bedd90d15d..63cc216389 100644 --- a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts +++ b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts @@ -22,6 +22,12 @@ export enum ReputationOracleEndpoints { SIGN_NDA = 'sign_nda', REPORT_ABUSE = 'report_abuse', GET_ABUSE_REPORTS = 'get_abuse_reports', + EXCHANGE_API_KEYS_ENROLL = 'exchange_api_keys_enroll', + EXCHANGE_API_KEYS_DELETE = 'exchange_api_keys_delete', + EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', + EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES = 'exchange_api_keys_supported_exchanges', + STAKE_SUMMARY = 'stake_summary', + STAKE_CONFIG = 'stake_config', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', diff --git a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts index 1a06cd60ee..5d79c29c28 100644 --- a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts +++ b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts @@ -44,6 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { status: string; wallet_address: string; reputation_network: string; + is_stake_eligible?: boolean; qualifications?: string[]; site_key?: string; email?: string; @@ -58,6 +59,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { wallet_address: payload.wallet_address, status: payload.status, reputation_network: payload.reputation_network, + is_stake_eligible: payload.is_stake_eligible, qualifications: payload.qualifications, site_key: payload.site_key, email: payload.email, diff --git a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts index 85da60b3c1..a0175c642d 100644 --- a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts +++ b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts @@ -10,6 +10,8 @@ export class JwtUserData { @AutoMap() reputation_network: string; @AutoMap() + is_stake_eligible?: boolean; + @AutoMap() email?: string; @AutoMap() qualifications?: string[]; diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index adebda27cf..618833409a 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -95,6 +95,17 @@ import { ReportAbuseParams, ReportedAbuseResponse, } from '../../modules/abuse/model/abuse.model'; +import { HttpMethod } from '../../common/enums/http-method'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + RetrieveExchangeApiKeysResponse, + SupportedExchangeResponse, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; +import { + StakeConfigResponse, + StakeSummaryResponse, +} from '../../modules/staking/model/staking.model'; @Injectable() export class ReputationOracleGateway { @@ -136,6 +147,77 @@ export class ReputationOracleGateway { const response = await lastValueFrom(this.httpService.request(options)); return response.data as T; } + + async enrollExchangeApiKeys( + command: EnrollExchangeApiKeysCommand, + ): Promise<{ id: number }> { + const enrollExchangeApiKeysData = this.mapper.map( + command, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + ); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL, + enrollExchangeApiKeysData, + command.token, + ); + options.url = `${options.url}/${command.exchangeName}`; + return this.handleRequestToReputationOracle<{ id: number }>(options); + } + + async deleteExchangeApiKeys(token: string) { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE, + undefined, + token, + ); + options.method = HttpMethod.DELETE; + return this.handleRequestToReputationOracle(options); + } + + async retrieveExchangeApiKeys( + token: string, + ): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE, + undefined, + token, + ); + return this.handleRequestToReputationOracle( + options, + ); + } + + async getStakeSummary(token: string): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.STAKE_SUMMARY, + undefined, + token, + ); + return this.handleRequestToReputationOracle(options); + } + + async getStakeConfig(): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.STAKE_CONFIG, + undefined, + ); + return this.handleRequestToReputationOracle(options); + } + + async supportedExchanges( + token: string, + ): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES, + undefined, + token, + ); + return this.handleRequestToReputationOracle( + options, + ); + } + async sendWorkerSignup(command: SignupWorkerCommand): Promise { const signupWorkerData = this.mapper.map( command, diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts index 1c147e2833..3488762b01 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts @@ -62,6 +62,10 @@ import { ReportAbuseData, ReportAbuseParams, } from '../../modules/abuse/model/abuse.model'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -166,6 +170,15 @@ export class ReputationOracleProfile extends AutomapperProfile { destination: new SnakeCaseNamingConvention(), }), ); + createMap( + mapper, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/main.ts b/packages/apps/human-app/server/src/main.ts index 6f1ea1f0dd..726bd19b9b 100644 --- a/packages/apps/human-app/server/src/main.ts +++ b/packages/apps/human-app/server/src/main.ts @@ -19,7 +19,7 @@ async function bootstrap() { if (envConfigService.isCorsEnabled) { app.enableCors({ origin: envConfigService.corsEnabledOrigin, - methods: ['GET', 'POST', 'OPTIONS', 'PUT'], + methods: ['GET', 'POST', 'OPTIONS', 'PUT', 'DELETE'], allowedHeaders: envConfigService.corsAllowedHeaders, }); } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..efb605dfa9 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Request, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { RequestWithUser } from '../../common/interfaces/jwt'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysResponse, + SupportedExchangeResponse, +} from './model/exchange-api-keys.model'; + +@ApiTags('Exchange-Api-Keys') +@ApiBearerAuth() +@Controller('/exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly service: ExchangeApiKeysService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiOperation({ summary: 'Enroll API keys for exchange' }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @ApiResponse({ status: 200, description: 'Exchange API keys enrolled' }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Param('exchange_name') exchangeName: string, + @Body() dto: EnrollExchangeApiKeysDto, + @Request() req: RequestWithUser, + ): Promise<{ id: number }> { + const command = this.mapper.map( + dto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysCommand, + ); + command.token = req.token; + command.exchangeName = exchangeName; + return this.service.enroll(command); + } + + @ApiOperation({ summary: 'Delete API keys for exchange' }) + @ApiResponse({ status: 204, description: 'Exchange API keys deleted' }) + @HttpCode(204) + @Delete('/') + async delete(@Request() req: RequestWithUser): Promise { + await this.service.delete(req.token); + } + + @ApiOperation({ + summary: 'Retrieve API keys for exchange', + }) + @Get('/') + async retrieve( + @Request() req: RequestWithUser, + ): Promise { + return this.service.retrieve(req.token); + } + + @ApiOperation({ summary: 'Get supported exchanges' }) + @Get('/supported-exchanges') + async getSupportedExchanges( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getSupportedExchanges(req.token); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts new file mode 100644 index 0000000000..5f65c8f475 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts @@ -0,0 +1,20 @@ +import { Mapper, createMap } from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap(mapper, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysCommand); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..3b6d627810 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ExchangeApiKeysController } from '../../modules/exchange-api-keys/exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; +import { ExchangeApiKeysProfile } from './exchange-api-keys.mapper.profile'; + +@Module({ + imports: [ReputationOracleModule], + controllers: [ExchangeApiKeysController], + providers: [ExchangeApiKeysService, ExchangeApiKeysProfile], + exports: [ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..712335441b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { + EnrollExchangeApiKeysCommand, + RetrieveExchangeApiKeysResponse, + SupportedExchangeResponse, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysService { + constructor(private readonly reputationOracle: ReputationOracleGateway) {} + + enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { + return this.reputationOracle.enrollExchangeApiKeys(command); + } + + delete(token: string): Promise { + return this.reputationOracle.deleteExchangeApiKeys(token); + } + + retrieve(token: string): Promise { + return this.reputationOracle.retrieveExchangeApiKeys(token); + } + + getSupportedExchanges(token: string): Promise { + return this.reputationOracle.supportedExchanges(token); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts new file mode 100644 index 0000000000..f07c049e26 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -0,0 +1,44 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class EnrollExchangeApiKeysDto { + @AutoMap() + @IsString() + @ApiProperty() + apiKey: string; + + @AutoMap() + @IsString() + @ApiProperty() + secretKey: string; +} + +export class EnrollExchangeApiKeysCommand { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; + token: string; + exchangeName: string; +} + +export class EnrollExchangeApiKeysData { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; +} + +export class RetrieveExchangeApiKeysResponse { + apiKey: string; + exchangeName: string; +} + +export class SupportedExchangeResponse { + @ApiProperty() + name: string; + + @ApiProperty() + displayName: string; +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts new file mode 100644 index 0000000000..e298642428 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts @@ -0,0 +1,96 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestWithUser } from '../../../common/interfaces/jwt'; +import { ExchangeApiKeysController } from '../exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysDtoFixture, + enrollExchangeApiKeysResponseFixture, + EXCHANGE_NAME, + retrieveExchangeApiKeysResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; +import { exchangeApiKeysServiceMock } from './exchange-api-keys.service.mock'; +import { ExchangeApiKeysProfile } from '../exchange-api-keys.mapper.profile'; + +describe('ExchangeApiKeysController', () => { + let controller: ExchangeApiKeysController; + let service: ExchangeApiKeysService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ExchangeApiKeysController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + ExchangeApiKeysService, + ExchangeApiKeysProfile, + { + provide: ExchangeApiKeysService, + useValue: exchangeApiKeysServiceMock, + }, + ], + }) + .overrideProvider(ExchangeApiKeysService) + .useValue(exchangeApiKeysServiceMock) + .compile(); + + controller = module.get( + ExchangeApiKeysController, + ); + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('enroll', () => { + it('should call service.enroll with mapped command and return id', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.enroll( + EXCHANGE_NAME, + enrollExchangeApiKeysDtoFixture, + req, + ); + expect(service.enroll).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should call service.delete with token', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.delete(req); + expect(service.delete).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(undefined); + }); + }); + + describe('retrieve', () => { + it('should call service.retrieve with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.retrieve(req); + expect(service.retrieve).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); + + describe('getSupportedExchanges', () => { + it('should call service.getSupportedExchanges with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getSupportedExchanges(req); + expect(service.getSupportedExchanges).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual( + exchangeApiKeysServiceMock.getSupportedExchanges(TOKEN), + ); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts new file mode 100644 index 0000000000..8cfda1ab77 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts @@ -0,0 +1,45 @@ +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysResponse, +} from '../model/exchange-api-keys.model'; + +export const EXCHANGE_NAME = 'mexc'; +export const TOKEN = 'test_user_token'; +export const API_KEY = 'test_api_key'; +export const API_SECRET = 'test_api_secret'; +export const ID = 123; + +export const enrollExchangeApiKeysDtoFixture: EnrollExchangeApiKeysDto = { + apiKey: API_KEY, + secretKey: API_SECRET, +}; + +export const enrollExchangeApiKeysCommandFixture: EnrollExchangeApiKeysCommand = + { + apiKey: API_KEY, + secretKey: API_SECRET, + token: TOKEN, + exchangeName: EXCHANGE_NAME, + }; + +export const enrollExchangeApiKeysResponseFixture = { + id: ID, +}; + +export const retrieveExchangeApiKeysResponseFixture: RetrieveExchangeApiKeysResponse = + { + apiKey: API_KEY, + exchangeName: EXCHANGE_NAME, + }; + +export const supportedExchangesResponseFixture = [ + { + name: 'mexc', + displayName: 'MEXC Global', + }, + { + name: 'gate', + displayName: 'Gate', + }, +]; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts new file mode 100644 index 0000000000..357e2142d0 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts @@ -0,0 +1,14 @@ +import { + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, + supportedExchangesResponseFixture, +} from './exchange-api-keys.fixtures'; + +export const exchangeApiKeysServiceMock = { + enroll: jest.fn().mockReturnValue(enrollExchangeApiKeysResponseFixture), + delete: jest.fn().mockResolvedValue(undefined), + retrieve: jest.fn().mockReturnValue(retrieveExchangeApiKeysResponseFixture), + getSupportedExchanges: jest + .fn() + .mockReturnValue(supportedExchangesResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..f29868a395 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts @@ -0,0 +1,89 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, + supportedExchangesResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; + +describe('ExchangeApiKeysService', () => { + let service: ExchangeApiKeysService; + let reputationOracleMock: Partial; + + beforeEach(async () => { + reputationOracleMock = { + enrollExchangeApiKeys: jest.fn(), + deleteExchangeApiKeys: jest.fn(), + retrieveExchangeApiKeys: jest.fn(), + getStakeSummary: jest.fn(), + supportedExchanges: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + { provide: ReputationOracleGateway, useValue: reputationOracleMock }, + ], + }).compile(); + + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enroll', () => { + it('should enroll exchange API keys and return id', async () => { + ( + reputationOracleMock.enrollExchangeApiKeys as jest.Mock + ).mockResolvedValue(enrollExchangeApiKeysResponseFixture); + const result = await service.enroll(enrollExchangeApiKeysCommandFixture); + expect(reputationOracleMock.enrollExchangeApiKeys).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should delete exchange API keys', async () => { + ( + reputationOracleMock.deleteExchangeApiKeys as jest.Mock + ).mockResolvedValue(undefined); + await service.delete(TOKEN); + expect(reputationOracleMock.deleteExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + }); + }); + + describe('retrieve', () => { + it('should retrieve exchange API keys', async () => { + ( + reputationOracleMock.retrieveExchangeApiKeys as jest.Mock + ).mockResolvedValue(retrieveExchangeApiKeysResponseFixture); + const result = await service.retrieve(TOKEN); + expect(reputationOracleMock.retrieveExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); + + describe('getSupportedExchanges', () => { + it('should retrieve supported exchanges', async () => { + (reputationOracleMock.supportedExchanges as jest.Mock).mockResolvedValue( + supportedExchangesResponseFixture, + ); + const result = await service.getSupportedExchanges(TOKEN); + expect(reputationOracleMock.supportedExchanges).toHaveBeenCalledWith( + TOKEN, + ); + expect(result).toEqual(supportedExchangesResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 3eba428370..bbfa432d1c 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -9,6 +9,7 @@ import { Post, Query, Request, + ForbiddenException, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; @@ -27,11 +28,16 @@ import { import { ChainId } from '@human-protocol/sdk'; import axios from 'axios'; import { JobStatus } from '../../common/enums/global-common'; +import logger from '../../logger'; @ApiTags('Job-Assignment') @ApiBearerAuth() @Controller('/assignment') export class JobAssignmentController { + private readonly logger = logger.child({ + context: JobAssignmentController.name, + }); + constructor( private readonly service: JobAssignmentService, @InjectMapper() private readonly mapper: Mapper, @@ -46,6 +52,10 @@ export class JobAssignmentController { @Body() jobAssignmentDto: JobAssignmentDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } // TODO: temporal - THIRSTYFI if (jobAssignmentDto.escrow_address === 'thirstyfi-task') { if (new Date(process.env.THIRSTYFI_TASK_EXPIRATION_DATE!) < new Date()) { @@ -80,8 +90,10 @@ export class JobAssignmentController { expires_at: process.env.THIRSTYFI_TASK_EXPIRATION_DATE ?? '', }; } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + this.logger.error('Failed to assign thirstyfi job', { + userId: req.user.user_id, + error, + }); throw new BadRequestException(error.response.data.error); } } @@ -103,6 +115,16 @@ export class JobAssignmentController { @Query() jobsAssignmentParamsDto: JobsFetchParamsDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } // TODO: temporal - THIRSTYFI if ( jobsAssignmentParamsDto.oracle_address === @@ -166,6 +188,10 @@ export class JobAssignmentController { @Body() dto: ResignJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = this.mapper.map(dto, ResignJobDto, ResignJobCommand); command.token = req.token; return this.service.resignJob(command); @@ -180,6 +206,10 @@ export class JobAssignmentController { @Body() dto: RefreshJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = new JobsFetchParamsCommand(); command.oracleAddress = dto.oracle_address; command.token = req.token; diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts index ae69ce4e12..f810cce1ca 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts @@ -26,6 +26,7 @@ import { TOKEN, } from './job-assignment.fixtures'; import { jobAssignmentServiceMock } from './job-assignment.service.mock'; +import { ForbiddenException } from '@nestjs/common'; const httpServiceMock = { request: jest.fn().mockImplementation((options) => { @@ -77,6 +78,7 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processJobAssignment).toHaveBeenCalledWith( command, @@ -88,32 +90,85 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; const result = await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(result).toEqual( jobAssignmentServiceMock.processJobAssignment(command), ); }); + it('should throw ForbiddenException if user is not stake eligible in assignJob', async () => { + const dto: JobAssignmentDto = jobAssignmentDtoFixture; + await expect( + controller.assignJob(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + it('should call service processGetAssignedJobs method with proper fields set', async () => { const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; const command: JobsFetchParamsCommand = jobsFetchParamsCommandFixture; await controller.getAssignedJobs(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processGetAssignedJobs).toHaveBeenCalledWith( command, ); }); + it('should return empty results if user is not stake eligible in getAssignedJobs', async () => { + const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; + const result = await controller.getAssignedJobs(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); + }); + it('should call service refreshAssigments method with proper fields set', async () => { const dto: RefreshJobDto = refreshJobDtoFixture; await controller.refreshAssigments(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.updateAssignmentsCache).toHaveBeenCalledWith({ oracleAddress: EXCHANGE_ORACLE_ADDRESS, token: TOKEN, }); }); + + it('should throw ForbiddenException if user is not stake eligible in refreshAssigments', async () => { + const dto: RefreshJobDto = refreshJobDtoFixture; + await expect( + controller.refreshAssigments(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + }); + + describe('resignAssigment', () => { + it('should throw ForbiddenException if user is not stake eligible in resignAssigment', async () => { + const dto = { assignment_id: '1' }; + await expect( + controller.resignAssigment( + dto as any, + { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser, + ), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); }); }); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts index be89ef3407..85fe4c11f4 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts @@ -51,6 +51,18 @@ export class JobsDiscoveryController { HttpStatus.FORBIDDEN, ); } + + // Require stake eligibility + if (!req.user?.is_stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } + // TODO: temporal - THIRSTYFI if ( jobsDiscoveryParamsDto.oracle_address === diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts index fb4e14b4bf..9b73a31176 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts @@ -14,6 +14,7 @@ import { dtoFixture, jobsDiscoveryParamsCommandFixture, responseFixture, + jobDiscoveryToken, } from './jobs-discovery.fixtures'; import { jobsDiscoveryServiceMock } from './jobs-discovery.service.mock'; @@ -73,7 +74,7 @@ describe('JobsDiscoveryController', () => { const dto = dtoFixture; const command = jobsDiscoveryParamsCommandFixture; await controller.getJobs(dto, { - user: { qualifications: [] }, + user: { qualifications: [], is_stake_eligible: true }, token: command.token, } as any); command.data.qualifications = []; @@ -90,6 +91,22 @@ describe('JobsDiscoveryController', () => { ).rejects.toThrow( new HttpException('Jobs discovery is disabled', HttpStatus.FORBIDDEN), ); + (configServiceMock as any).jobsDiscoveryFlag = true; + }); + + it('should return empty results if user is not stake eligible', async () => { + const dto = dtoFixture; + const result = await controller.getJobs(dto, { + user: { qualifications: [], is_stake_eligible: false }, + token: jobDiscoveryToken, + } as any); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); }); }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts new file mode 100644 index 0000000000..a6b6f9d95b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StakeSummaryResponse { + @ApiProperty() + exchange_stake: number; + + @ApiProperty({ name: 'on_chain_stake' }) + on_chain_stake: number; + + @ApiPropertyOptional() + exchange_error?: string | null; + + @ApiPropertyOptional() + on_chain_error?: string | null; +} + +export class StakeConfigResponse { + min_threshold: number; + eligibility_enabled: boolean; +} diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts new file mode 100644 index 0000000000..f92224d522 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts @@ -0,0 +1,50 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestWithUser } from '../../../common/interfaces/jwt'; +import { TOKEN } from './staking.fixtures'; +import { StakingService } from '../staking.service'; +import { StakingController } from '../staking.controller'; +import { stakingServiceMock } from './staking.service.mock'; + +describe('StakingController', () => { + let controller: StakingController; + let service: StakingService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StakingController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + StakingService, + { + provide: StakingService, + useValue: stakingServiceMock, + }, + ], + }) + .overrideProvider(StakingService) + .useValue(stakingServiceMock) + .compile(); + + controller = module.get(StakingController); + service = module.get(StakingService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getStakeSummary', () => { + it('should call service.getStakeSummary with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getStakeSummary(req); + expect(service.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakingServiceMock.getStakeSummary(TOKEN)); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts new file mode 100644 index 0000000000..93699eef96 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -0,0 +1,11 @@ +export const TOKEN = 'test_user_token'; + +export const stakeSummaryResponseFixture = { + exchange_stake: '1000', + on_chain_stake: '500', +}; + +export const stakeConfigResponseFixture = { + min_threshold: '1000', + eligibility_enabled: true, +}; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts new file mode 100644 index 0000000000..582f931236 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts @@ -0,0 +1,9 @@ +import { + stakeConfigResponseFixture, + stakeSummaryResponseFixture, +} from './staking.fixtures'; + +export const stakingServiceMock = { + getStakeSummary: jest.fn().mockReturnValue(stakeSummaryResponseFixture), + getStakeConfig: jest.fn().mockReturnValue(stakeConfigResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts new file mode 100644 index 0000000000..ad97851205 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { StakingService } from '../staking.service'; +import { + stakeConfigResponseFixture, + stakeSummaryResponseFixture, + TOKEN, +} from './staking.fixtures'; + +describe('StakingService', () => { + let service: StakingService; + let reputationOracleMock: Partial; + + beforeEach(async () => { + reputationOracleMock = { + getStakeSummary: jest.fn(), + getStakeConfig: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingService, + { provide: ReputationOracleGateway, useValue: reputationOracleMock }, + ], + }).compile(); + + service = module.get(StakingService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getStakeSummary', () => { + it('should retrieve stake summary', async () => { + (reputationOracleMock.getStakeSummary as jest.Mock).mockResolvedValue( + stakeSummaryResponseFixture, + ); + const result = await service.getStakeSummary(TOKEN); + expect(reputationOracleMock.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakeSummaryResponseFixture); + }); + }); + + describe('getStakeConfig', () => { + it('should retrieve stake config', async () => { + (reputationOracleMock.getStakeConfig as jest.Mock).mockResolvedValue( + stakeConfigResponseFixture, + ); + const result = await service.getStakeConfig(); + expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(); + expect(result).toEqual(stakeConfigResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts new file mode 100644 index 0000000000..f8bcdae90f --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts @@ -0,0 +1,25 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { Controller, Get, Request } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequestWithUser } from '../../common/interfaces/jwt'; +import { StakingService } from './staking.service'; +import { StakeSummaryResponse } from './model/staking.model'; + +@ApiTags('Staking') +@ApiBearerAuth() +@Controller('/staking') +export class StakingController { + constructor( + private readonly service: StakingService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiOperation({ summary: 'Get exchange API keys stake summary' }) + @Get('/summary') + async getStakeSummary( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getStakeSummary(req.token); + } +} diff --git a/packages/apps/human-app/server/src/modules/staking/staking.module.ts b/packages/apps/human-app/server/src/modules/staking/staking.module.ts new file mode 100644 index 0000000000..49bc184eda --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { StakingController } from './staking.controller'; +import { StakingService } from './staking.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; + +@Module({ + imports: [ReputationOracleModule], + controllers: [StakingController], + providers: [StakingService], + exports: [StakingService], +}) +export class StakingModule {} diff --git a/packages/apps/human-app/server/src/modules/staking/staking.service.ts b/packages/apps/human-app/server/src/modules/staking/staking.service.ts new file mode 100644 index 0000000000..820c8f9361 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { + StakeConfigResponse, + StakeSummaryResponse, +} from './model/staking.model'; + +@Injectable() +export class StakingService { + constructor(private readonly reputationOracle: ReputationOracleGateway) {} + + getStakeSummary(token: string): Promise { + return this.reputationOracle.getStakeSummary(token); + } + + getStakeConfig(): Promise { + return this.reputationOracle.getStakeConfig(); + } +} diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts index 95e4889355..c8df57939b 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts @@ -1,15 +1,22 @@ +import { ChainId } from '@human-protocol/sdk'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { UiConfigurationController } from './ui-configuration.controller'; + import { EnvironmentConfigService } from '../../common/config/environment-config.service'; -import { ChainId } from '@human-protocol/sdk'; +import { StakingService } from '../staking/staking.service'; +import { UiConfigurationController } from './ui-configuration.controller'; process.env.CHAIN_IDS_ENABLED = '80002,11155111'; describe('UiConfigurationController', () => { let controller: UiConfigurationController; + let stakingServiceMock: Pick, 'getStakeConfig'>; beforeAll(async () => { + stakingServiceMock = { + getStakeConfig: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ @@ -17,7 +24,13 @@ describe('UiConfigurationController', () => { isGlobal: true, }), ], - providers: [EnvironmentConfigService], + providers: [ + EnvironmentConfigService, + { + provide: StakingService, + useValue: stakingServiceMock, + }, + ], controllers: [UiConfigurationController], }).compile(); @@ -27,10 +40,16 @@ describe('UiConfigurationController', () => { }); it('should return proper config', async () => { + stakingServiceMock.getStakeConfig.mockResolvedValueOnce({ + eligibility_enabled: true, + min_threshold: Math.random(), + }); + const result = await controller.getConfig(); expect(result.chainIdsEnabled).toEqual([ ChainId.POLYGON_AMOY, ChainId.SEPOLIA, ]); + expect(result.stakingEligibilityEnabled).toBe(true); }); }); diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts index 0ed982fe27..0c4107eb01 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts @@ -3,6 +3,7 @@ import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { Public } from '../../common/decorators'; import { UiConfigResponseDto } from './ui-configuration.dto'; +import { StakingService } from '../staking/staking.service'; @Controller() @Public() @@ -10,6 +11,7 @@ import { UiConfigResponseDto } from './ui-configuration.dto'; export class UiConfigurationController { constructor( private readonly environmentConfigService: EnvironmentConfigService, + private readonly stakingService: StakingService, ) {} @ApiOperation({ summary: 'Retrieve UI configuration' }) @@ -17,11 +19,15 @@ export class UiConfigurationController { type: UiConfigResponseDto, description: 'UI Configuration object', }) - @Header('Cache-Control', 'public, max-age=3600') + @Header('Cache-Control', 'public, max-age=600') @Get('/ui-config') public async getConfig(): Promise { + const stakingRequirementConfig = await this.stakingService.getStakeConfig(); + return { chainIdsEnabled: this.environmentConfigService.chainIdsEnabled, + stakingEligibilityEnabled: stakingRequirementConfig.eligibility_enabled, + minThreshold: stakingRequirementConfig.min_threshold, }; } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts index 864757461b..6633c8cf49 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts @@ -9,4 +9,15 @@ export class UiConfigResponseDto { enumName: 'ChainId', }) chainIdsEnabled: ChainId[]; + + @ApiProperty({ + description: + 'Indicated if stake eligibility check enabled on app & oracles', + }) + stakingEligibilityEnabled: boolean; + + @ApiProperty({ + description: 'Minimum staking threshold required for eligibility', + }) + minThreshold: number; } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts index d14d2d39bd..2e99816674 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { UiConfigurationController } from './ui-configuration.controller'; +import { StakingModule } from '../staking/staking.module'; @Module({ + imports: [StakingModule], controllers: [UiConfigurationController], }) export class UiConfigurationModule {} diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index c3c6a028ce..80da4aa283 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -97,3 +97,6 @@ NDA_URL=https://humanprotocol.org # HUMAN App secret key for auth in RepO HUMAN_APP_SECRET_KEY=sk_example_1VwUpBMO8H0v4Pmu4TPiWFEwuMguW4PkozSban4Rfbc + +# Aes +AES_ENCRYPTION_KEY=e59a0bb854ff5b338fbfc86452690be2 diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index a064f1b5c9..a57b33c078 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -14,11 +14,13 @@ import { AbuseModule } from './modules/abuse'; import { AuthModule } from './modules/auth'; import { CronJobModule } from './modules/cron-job'; import { EscrowCompletionModule } from './modules/escrow-completion'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys'; import { HealthModule } from './modules/health'; import { KycModule } from './modules/kyc'; import { NDAModule } from './modules/nda'; import { QualificationModule } from './modules/qualification'; import { ReputationModule } from './modules/reputation'; +import { StakingModule } from './modules/staking'; import { UserModule } from './modules/user'; import { IncomingWebhookModule, @@ -72,6 +74,8 @@ import Environment from './utils/environment'; CronJobModule, UserModule, NDAModule, + ExchangeApiKeysModule, + StakingModule, EscrowCompletionModule, HealthModule, KycModule, diff --git a/packages/apps/reputation-oracle/server/src/common/constants/index.ts b/packages/apps/reputation-oracle/server/src/common/constants/index.ts index 1bacfb11fd..02d82593be 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -12,3 +12,20 @@ export const RESEND_EMAIL_VERIFICATION_PATH = export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; + +export enum SupportedExchange { + MEXC = 'mexc', + GATE = 'gate', +} + +export type SupportedExchangeInfo = { + name: SupportedExchange; + displayName: string; +}; + +export const SUPPORTED_EXCHANGES_INFO: readonly SupportedExchangeInfo[] = [ + { name: SupportedExchange.MEXC, displayName: 'MEXC' }, + { name: SupportedExchange.GATE, displayName: 'Gate' }, +] as const; + +export const DEFAULT_TIMEOUT_MS = 5000; diff --git a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts index 5408ab7dd1..c82683d25e 100644 --- a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts +++ b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts @@ -23,6 +23,10 @@ export class TransformInterceptor implements NestInterceptor { request.query = this.transformRequestData(request.query); } + if (request.params) { + request.params = this.transformRequestData(request.params); + } + return next.handle().pipe(map((data) => this.transformResponseData(data))); } diff --git a/packages/apps/reputation-oracle/server/src/config/config.module.ts b/packages/apps/reputation-oracle/server/src/config/config.module.ts index eb3e0f4af0..1adb75e47f 100644 --- a/packages/apps/reputation-oracle/server/src/config/config.module.ts +++ b/packages/apps/reputation-oracle/server/src/config/config.module.ts @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import { AuthConfigService } from './auth-config.service'; import { DatabaseConfigService } from './database-config.service'; import { EmailConfigService } from './email-config.service'; +import { EncryptionConfigService } from './encryption-config.service'; import { HCaptchaConfigService } from './hcaptcha-config.service'; import { KycConfigService } from './kyc-config.service'; import { NDAConfigService } from './nda-config.service'; @@ -12,6 +13,7 @@ import { ReputationConfigService } from './reputation-config.service'; import { S3ConfigService } from './s3-config.service'; import { ServerConfigService } from './server-config.service'; import { SlackConfigService } from './slack-config.service'; +import { StakingConfigService } from './staking-config.service'; import { Web3ConfigService } from './web3-config.service'; @Global() @@ -21,11 +23,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, @@ -35,11 +39,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, diff --git a/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts new file mode 100644 index 0000000000..ff5e8feb7b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EncryptionConfigService { + constructor(private configService: ConfigService) {} + + /** + * 32-byte key for AES encrpytion + */ + get aesEncryptionKey(): string { + return this.configService.getOrThrow('AES_ENCRYPTION_KEY'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 98dc73b932..78876e7ec8 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -81,10 +81,27 @@ export const envValidator = Joi.object({ KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), // Human App HUMAN_APP_SECRET_KEY: Joi.string().required(), + // Staking configuration + STAKING_ASSET: Joi.string().description( + 'Asset symbol to check for staking (default HMT)', + ), + STAKING_MIN_THRESHOLD: Joi.number() + .min(0) + .description('Minimum asset amount to qualify as staked'), + STAKING_TIMEOUT_MS: Joi.number() + .integer() + .min(100) + .description('HTTP timeout for exchange staking checks in ms'), + STAKING_ELIGIBILITY_ENABLED: Joi.string() + .valid('true', 'false') + .default('false') + .description('Enable or disable staking eligibility checks'), // Slack notifications ABUSE_SLACK_WEBHOOK_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) .required(), ABUSE_SLACK_OAUTH_TOKEN: Joi.string().required(), ABUSE_SLACK_SIGNING_SECRET: Joi.string().required(), + // Encryption + AES_ENCRYPTION_KEY: Joi.string().required().length(32), }); diff --git a/packages/apps/reputation-oracle/server/src/config/index.ts b/packages/apps/reputation-oracle/server/src/config/index.ts index 2e76538048..5d1318cd56 100644 --- a/packages/apps/reputation-oracle/server/src/config/index.ts +++ b/packages/apps/reputation-oracle/server/src/config/index.ts @@ -3,11 +3,13 @@ export * from './env-schema'; export { AuthConfigService } from './auth-config.service'; export { DatabaseConfigService } from './database-config.service'; export { EmailConfigService } from './email-config.service'; +export { EncryptionConfigService } from './encryption-config.service'; export { HCaptchaConfigService } from './hcaptcha-config.service'; export { KycConfigService } from './kyc-config.service'; export { NDAConfigService } from './nda-config.service'; export { PGPConfigService } from './pgp-config.service'; export { ReputationConfigService } from './reputation-config.service'; +export { StakingConfigService } from './staking-config.service'; export { S3ConfigService } from './s3-config.service'; export { ServerConfigService } from './server-config.service'; export { Web3ConfigService, Web3Network } from './web3-config.service'; diff --git a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts new file mode 100644 index 0000000000..174d3ed5d1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class StakingConfigService { + constructor(private readonly configService: ConfigService) {} + + /** + * Default asset symbol to check for staking eligibility. + * Default: 'HMT' + */ + get asset(): string { + return this.configService.get('STAKING_ASSET', 'HMT'); + } + + /** + * Minimum threshold (asset units) required for staking eligibility. + * Default: 1000 + */ + get minThreshold(): number { + return Number(this.configService.get('STAKING_MIN_THRESHOLD')) || 1000; + } + + /** + * Optional per-exchange HTTP timeout, in milliseconds. + * Default: 2000 + */ + get timeoutMs(): number { + return Number(this.configService.get('STAKING_TIMEOUT_MS')) || 2000; + } + + /** + * Feature flag to enable/disable staking eligibility enforcement. + * When disabled, eligibility will be treated as true unconditionally. + * Default: false + */ + get eligibilityEnabled(): boolean { + return ( + this.configService.get('STAKING_ELIGIBILITY_ENABLED', 'false') === + 'true' + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/database.module.ts b/packages/apps/reputation-oracle/server/src/database/database.module.ts index 093c5d7abf..c8bb6dc8f3 100644 --- a/packages/apps/reputation-oracle/server/src/database/database.module.ts +++ b/packages/apps/reputation-oracle/server/src/database/database.module.ts @@ -13,6 +13,7 @@ import { TokenEntity } from '@/modules/auth/token.entity'; import { CronJobEntity } from '@/modules/cron-job/cron-job.entity'; import { EscrowCompletionEntity } from '@/modules/escrow-completion/escrow-completion.entity'; import { EscrowPayoutsBatchEntity } from '@/modules/escrow-completion/escrow-payouts-batch.entity'; +import { ExchangeApiKeyEntity } from '@/modules/exchange-api-keys'; import { KycEntity } from '@/modules/kyc/kyc.entity'; import { QualificationEntity } from '@/modules/qualification/qualification.entity'; import { UserQualificationEntity } from '@/modules/qualification/user-qualification.entity'; @@ -71,6 +72,7 @@ import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; OutgoingWebhookEntity, EscrowCompletionEntity, EscrowPayoutsBatchEntity, + ExchangeApiKeyEntity, ReputationEntity, TokenEntity, UserEntity, diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts new file mode 100644 index 0000000000..3b5f7f91bd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExchangeApiKeys1761653939799 implements MigrationInterface { + name = 'ExchangeApiKeys1761653939799'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "hmt"."exchange_api_keys" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, "exchange_name" character varying(20) NOT NULL, "api_key" character varying(1000) NOT NULL, "secret_key" character varying(10000) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "PK_3751a8a0ef5354b32b06ea43983" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_96ee74195b058a1b55afc49f67" ON "hmt"."exchange_api_keys" ("user_id") `, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" ADD CONSTRAINT "FK_96ee74195b058a1b55afc49f673" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" DROP CONSTRAINT "FK_96ee74195b058a1b55afc49f673"`, + ); + await queryRunner.query( + `DROP INDEX "hmt"."IDX_96ee74195b058a1b55afc49f67"`, + ); + await queryRunner.query(`DROP TABLE "hmt"."exchange_api_keys"`); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index f613211882..bcb7cd27c6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -4,8 +4,10 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthConfigService } from '@/config'; import { HCaptchaModule } from '@/integrations/hcaptcha'; import { EmailModule } from '@/modules/email'; +import { ExchangeModule } from '@/modules/exchange'; +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; +import { StakingModule } from '@/modules/staking'; import { UserModule } from '@/modules/user'; -import { Web3Module } from '@/modules/web3'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -25,8 +27,10 @@ import { TokenRepository } from './token.repository'; }, }), }), - Web3Module, HCaptchaModule, + ExchangeModule, + ExchangeApiKeysModule, + StakingModule, EmailModule, ], providers: [JwtHttpStrategy, AuthService, TokenRepository], diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index 121e37346d..9606f53499 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -11,10 +11,12 @@ import { SignatureType, UserStatus, UserRole } from '@/common/enums'; import { AuthConfigService, NDAConfigService, + StakingConfigService, ServerConfigService, Web3ConfigService, } from '@/config'; import { EmailAction, EmailService } from '@/modules/email'; +import { StakingService } from '@/modules/staking'; import { SiteKeyRepository } from '@/modules/user'; import { UserEntity, UserRepository, UserService } from '@/modules/user'; import { generateOperator, generateWorkerUser } from '@/modules/user/fixtures'; @@ -41,6 +43,12 @@ const mockAuthConfigService: Omit = { forgotPasswordExpiresIn: 86400000, humanAppSecretKey: faker.string.alphanumeric({ length: 42 }), }; +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: 100, + asset: 'ETH', + timeoutMs: 2000, +}; const mockEmailService = createMock(); @@ -56,6 +64,7 @@ const mockSiteKeyRepository = createMock(); const mockTokenRepository = createMock(); const mockUserRepository = createMock(); const mockUserService = createMock(); +const mockStakingService = createMock(); describe('AuthService', () => { let service: AuthService; @@ -85,6 +94,8 @@ describe('AuthService', () => { { provide: UserRepository, useValue: mockUserRepository }, { provide: UserService, useValue: mockUserService }, { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, + { provide: StakingService, useValue: mockStakingService }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, ], }).compile(); @@ -626,6 +637,7 @@ describe('AuthService', () => { wallet_address: user.evmAddress, role: user.role, kyc_status: user.kyc?.status, + is_stake_eligible: true, nda_signed: user.ndaSignedUrl === mockNdaConfigService.latestNdaUrl, reputation_network: mockWeb3ConfigService.operatorAddress, qualifications: user.userQualifications @@ -635,6 +647,11 @@ describe('AuthService', () => { : [], }; + const spyOncheckStakeEligible = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(service as any, 'checkStakeEligible') + .mockImplementation(); + spyOncheckStakeEligible.mockResolvedValueOnce(true); const spyOnGenerateTokens = jest .spyOn(service, 'generateTokens') .mockImplementation(); @@ -651,10 +668,132 @@ describe('AuthService', () => { expectedJwtPayload, ); + expect(spyOncheckStakeEligible).toHaveBeenCalledTimes(1); + + spyOncheckStakeEligible.mockRestore(); spyOnGenerateTokens.mockRestore(); }); }); + describe('checkStakeEligible', () => { + it('returns true when feature flag disabled', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = false; + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect( + mockStakingService.getExchangeStakedBalance, + ).not.toHaveBeenCalled(); + expect(mockStakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance meets threshold (no on-chain call)', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(1500); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance below threshold but on-chain makes up the difference', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(400); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(600); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + }); + + it('returns false when no exchange keys and on-chain stake below threshold', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(0); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(500); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(false); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); + }); + + it('continues on exchange error and returns based on on-chain stake', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockStakingService.getExchangeStakedBalance.mockRejectedValueOnce( + new Error('network'), + ); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(1200); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); + }); + }); + describe('web3Auth', () => { it('should generate jwt payload for operator', async () => { const operator = generateOperator(); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index b116b089ae..24afae39c2 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -7,10 +7,12 @@ import { AuthConfigService, NDAConfigService, ServerConfigService, + StakingConfigService, Web3ConfigService, } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; +import { StakingService } from '@/modules/staking'; import { OperatorStatus, SiteKeyRepository, @@ -56,6 +58,8 @@ export class AuthService { private readonly userRepository: UserRepository, private readonly userService: UserService, private readonly web3ConfigService: Web3ConfigService, + private readonly stakingService: StakingService, + private readonly stakingConfigService: StakingConfigService, ) {} async signup(email: string, password: string): Promise { @@ -244,6 +248,8 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } + const stakeEligible = await this.checkStakeEligible(userEntity); + const jwtPayload = { email: userEntity.email, status: userEntity.status, @@ -253,6 +259,7 @@ export class AuthService { kyc_status: userEntity.kyc?.status, nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, + is_stake_eligible: stakeEligible, reputation_network: this.web3ConfigService.operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( @@ -265,6 +272,44 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } + private async checkStakeEligible( + userEntity: Web2UserEntity | UserEntity, + ): Promise { + if (!this.stakingConfigService.eligibilityEnabled) return true; + + let inspectedStakeAmount = 0; + + try { + const exchangeBalance = + await this.stakingService.getExchangeStakedBalance(userEntity.id); + inspectedStakeAmount += exchangeBalance; + } catch (err) { + this.logger.warn('Failed to query exchange balance; continuing', { + userId: userEntity.id, + error: err, + }); + } + + if ( + inspectedStakeAmount < this.stakingConfigService.minThreshold && + userEntity.evmAddress + ) { + try { + const onChainStake = await this.stakingService.getOnChainStakedBalance( + userEntity.evmAddress, + ); + inspectedStakeAmount += onChainStake; + } catch (err) { + this.logger.warn('Failed to query on-chain stake; continuing', { + userId: userEntity.id, + error: err, + }); + } + } + + return inspectedStakeAmount >= this.stakingConfigService.minThreshold; + } + async web3Auth(userEntity: OperatorUserEntity): Promise { /** * NOTE diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts new file mode 100644 index 0000000000..e9f856a784 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { EncryptionConfigService } from '@/config'; + +import { AesEncryptionService } from './aes-encryption.service'; +import { generateAesEncryptionKey } from './fixtures'; + +const HEX_FORMAT_REGEX = /[0-9a-f]+/; + +const mockGetAesEncryptionKey = jest.fn(); + +const mockEncryptionConfigService: Omit< + EncryptionConfigService, + 'configService' +> = { + get aesEncryptionKey() { + return mockGetAesEncryptionKey(); + }, +}; + +describe('AesEncryptionService', () => { + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + AesEncryptionService, + ], + }).compile(); + + aesEncryptionService = + moduleRef.get(AesEncryptionService); + }); + + beforeEach(() => { + mockGetAesEncryptionKey.mockReturnValue(generateAesEncryptionKey()); + }); + + it('should be defined', () => { + expect(aesEncryptionService).toBeDefined(); + }); + + it('should encrypt data and return envelope string', async () => { + const data = faker.lorem.lines(); + + const envelope = await aesEncryptionService.encrypt(Buffer.from(data)); + + expect(typeof envelope).toBe('string'); + + const [authTag, encrypted, iv] = envelope.split(':'); + + expect(authTag).toMatch(HEX_FORMAT_REGEX); + expect(authTag).toHaveLength(24); + + expect(encrypted).toMatch(HEX_FORMAT_REGEX); + + expect(iv).toHaveLength(32); + expect(iv).toMatch(HEX_FORMAT_REGEX); + }); + + it('should decrypt data encrypted by itslef', async () => { + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + const decrypted = await aesEncryptionService.decrypt(encrypted); + + expect(decrypted.toString()).toBe(data); + }); + + it('should fail to decrypt if different encryption key used', async () => { + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + + await expect(aesEncryptionService.decrypt(encrypted)).rejects.toThrow(); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts new file mode 100644 index 0000000000..d42ddbfa30 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts @@ -0,0 +1,100 @@ +import crypto from 'crypto'; + +import { Injectable } from '@nestjs/common'; + +import { EncryptionConfigService } from '@/config'; +import logger from '@/logger'; + +const ALGORITHM = 'aes-256-gcm'; +const GCM_IV_LENGTH_BYTES = 12; + +/** + * Security note: + * - Best practice is to use one encryption key per purpose (e.g., exchange API keys, PII, etc.). + * - At the moment, we only encrypt exchange API keys and therefore use a single key + * provided by EncryptionConfigService.aesEncryptionKey. + * - If/when we start encrypting different kinds of data, we should switch to per-purpose keys: + * - add dedicated config entries for each purpose (e.g., ENCRYPTION_USER_EXCHANGE_API_KEY, ...), + * - update this service so encrypt/decrypt accept an explicit encryptionKey parameter, + * - and ensure callers pass the correct key for the data they are encrypting/decrypting. + */ + +type EncryptionOutput = { + encrypted: Buffer; + iv: Buffer; + authTag: Buffer; +}; + +@Injectable() +export class AesEncryptionService { + private readonly logger = logger.child({ + context: AesEncryptionService.name, + }); + + constructor( + private readonly encryptionConfigService: EncryptionConfigService, + ) {} + + private composeEnvelopeString({ + encrypted, + authTag, + iv, + }: EncryptionOutput): string { + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${authTag.toString('hex')}`; + } + + private parseEnvelopeString(envelope: string): EncryptionOutput { + const [iv, encrypted, authTag] = envelope.split(':'); + + if (!iv || !encrypted || !authTag) { + throw new Error('Invalid AES envelope'); + } + + return { + iv: Buffer.from(iv, 'hex'), + encrypted: Buffer.from(encrypted, 'hex'), + authTag: Buffer.from(authTag, 'hex'), + }; + } + + async encrypt(data: Buffer): Promise { + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const iv = crypto.randomBytes(GCM_IV_LENGTH_BYTES); + + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return this.composeEnvelopeString({ + iv, + encrypted, + authTag, + }); + } + + async decrypt(envelope: string): Promise { + const { iv, encrypted, authTag } = this.parseEnvelopeString(envelope); + + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts index 623c981886..6583f9d356 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { Web3Module } from '@/modules/web3'; +import { AesEncryptionService } from './aes-encryption.service'; import { PgpEncryptionService } from './pgp-encryption.service'; @Module({ imports: [Web3Module], - providers: [PgpEncryptionService], - exports: [PgpEncryptionService], + providers: [AesEncryptionService, PgpEncryptionService], + exports: [AesEncryptionService, PgpEncryptionService], }) export class EncryptionModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts new file mode 100644 index 0000000000..656d5464ac --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -0,0 +1,14 @@ +import { faker } from '@faker-js/faker'; + +export const mockEncryptionConfigService = { + // 32-byte key for AES-256-GCM tests + aesEncryptionKey: generateAesEncryptionKey(), +}; + +/** + * Generates random key for AES encryption + * with the key length .expected by the app + */ +export function generateAesEncryptionKey(): string { + return faker.string.sample(32); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts new file mode 100644 index 0000000000..5a9252614e --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, Index, ManyToOne } from 'typeorm'; + +import { DATABASE_SCHEMA_NAME } from '@/common/constants'; +import { BaseEntity } from '@/database'; +import type { UserEntity } from '@/modules/user'; + +@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) +@Index(['userId'], { unique: true }) +export class ExchangeApiKeyEntity extends BaseEntity { + @Column('varchar', { length: 20 }) + exchangeName: string; + + @Column('varchar', { length: 1000 }) + apiKey: string; + + @Column('varchar', { length: 10000 }) + secretKey: string; + + @ManyToOne('UserEntity', { persistence: false, onDelete: 'CASCADE' }) + user?: UserEntity; + + @Column() + userId: number; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..df34ad7f2c --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,136 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + Param, + Post, + Req, + UseFilters, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import type { RequestWithUser } from '@/common/types'; +import Environment from '@/utils/environment'; + +import { + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysResponseDto, + EnrolledApiKeyDto, + ExchangeNameParamDto, + SupportedExchangeDto, +} from './exchange-api-keys.dto'; +import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; +import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; + +@ApiTags('Exchange API Keys') +@ApiBearerAuth() +@UseFilters(ExchangeApiKeysControllerErrorsFilter) +@Controller('exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + ) {} + + @ApiOperation({ + summary: 'Retrieve enrolled exchange with api key', + description: 'Returns the enrolled api key for exchange w/o secret key', + }) + @ApiResponse({ + status: 200, + type: EnrolledApiKeyDto, + }) + @Get('/') + async retrieveEnrolledApiKeys( + @Req() request: RequestWithUser, + ): Promise { + const userId = request.user.id; + + const apiKey = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKey) { + return null; + } + + return { + exchangeName: apiKey.exchangeName, + apiKey: apiKey.apiKey, + }; + } + + @ApiOperation({ + summary: 'Enroll API keys for exchange', + description: + 'Enrolls API keys for provided exchange. If keys already exist for exchange - updates them', + }) + @ApiResponse({ + status: 200, + description: 'Exchange API keys enrolled', + type: EnrollExchangeApiKeysResponseDto, + }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Req() request: RequestWithUser, + @Param() params: ExchangeNameParamDto, + @Body() data: EnrollExchangeApiKeysDto, + ): Promise { + const key = await this.exchangeApiKeysService.enroll({ + userId: request.user.id, + exchangeName: params.exchangeName, + apiKey: data.apiKey, + secretKey: data.secretKey, + }); + + return { id: key.id }; + } + + @ApiOperation({ + summary: 'Delete API keys', + }) + @ApiResponse({ + status: 204, + description: 'Exchange API keys deleted', + }) + @HttpCode(204) + @Delete('/') + async delete(@Req() request: RequestWithUser): Promise { + await this.exchangeApiKeysRepository.deleteByUser(request.user.id); + } + + @ApiOperation({ + summary: 'Retreive API keys for exchange', + description: + 'This functionality is purely for dev solely and works only in non-production environments', + }) + @Get('/exchange') + async retrieve(@Req() request: RequestWithUser): Promise { + if (!Environment.isDevelopment()) { + throw new ForbiddenException(); + } + + const apiKey = await this.exchangeApiKeysService.retrieve(request.user.id); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(request.user.id); + } + return apiKey; + } + + @ApiOperation({ summary: 'List supported exchanges' }) + @ApiResponse({ status: 200, type: SupportedExchangeDto, isArray: true }) + @Get('/supported') + async getSupportedExchanges(): Promise { + return this.exchangeApiKeysService.getSupportedExchanges(); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts new file mode 100644 index 0000000000..e9546e80d8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +import { SupportedExchange } from '@/common/constants'; + +export class EnrollExchangeApiKeysDto { + @ApiProperty({ name: 'api_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + apiKey: string; + + @ApiProperty({ name: 'secret_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(5000) + secretKey: string; +} + +export class ExchangeNameParamDto { + @ApiProperty({ + name: 'exchange_name', + enum: SupportedExchange, + }) + @IsEnum(SupportedExchange) + exchangeName: SupportedExchange; +} + +export class EnrollExchangeApiKeysResponseDto { + @ApiProperty() + id: number; +} + +export class EnrolledApiKeyDto { + @ApiProperty({ name: 'exchange_name' }) + exchangeName: string; + + @ApiProperty({ name: 'api_key' }) + apiKey: string; +} + +export class SupportedExchangeDto { + @ApiProperty({ name: 'name' }) + name: string; + + @ApiProperty({ name: 'display_name' }) + displayName: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts new file mode 100644 index 0000000000..d4d629d673 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -0,0 +1,54 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; + +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; +import { ExchangeApiClientError } from '../exchange/errors'; + +@Catch( + UserNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ExchangeApiKeyNotFoundError, +) +export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { + private readonly logger = logger.child({ + context: ExchangeApiKeysControllerErrorsFilter.name, + }); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + if ( + exception instanceof UserNotFoundError || + exception instanceof IncompleteKeySuppliedError || + exception instanceof KeyAuthorizationError + ) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception instanceof ExchangeApiClientError) { + status = HttpStatus.SERVICE_UNAVAILABLE; + } else if (exception instanceof ExchangeApiKeyNotFoundError) { + status = HttpStatus.NOT_FOUND; + } + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts new file mode 100644 index 0000000000..b16190ddd2 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts @@ -0,0 +1,19 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiKeyNotFoundError extends BaseError { + constructor(readonly userId: number) { + super('Exchange API key not found'); + } +} + +export class IncompleteKeySuppliedError extends BaseError { + constructor(readonly exchangeName: string) { + super('Incomplete credentials supplied for exchange'); + } +} + +export class KeyAuthorizationError extends BaseError { + constructor(readonly exchangeName: string) { + super("Provided API key can't be authorized on exchange"); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..5d7a416588 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { EncryptionModule } from '@/modules/encryption'; +import { ExchangeModule } from '@/modules/exchange/exchange.module'; +import { UserModule } from '@/modules/user'; + +import { ExchangeApiKeysController } from './exchange-api-keys.controller'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; + +@Module({ + imports: [ExchangeModule, EncryptionModule, UserModule], + providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], + controllers: [ExchangeApiKeysController], + exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts new file mode 100644 index 0000000000..ea0d264915 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { BaseRepository } from '@/database'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; + +@Injectable() +export class ExchangeApiKeysRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(ExchangeApiKeyEntity, dataSource); + } + + async findOneByUserId(userId: number): Promise { + if (!userId) { + throw new Error('Invalid arguments'); + } + return this.findOne({ + where: { userId }, + }); + } + + async deleteByUser(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + await this.delete({ userId }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..7869bf4f60 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -0,0 +1,217 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { SUPPORTED_EXCHANGES_INFO } from '@/common/constants'; +import { EncryptionConfigService } from '@/config/encryption-config.service'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { mockEncryptionConfigService } from '@/modules/encryption/fixtures'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; + +// eslint-disable-next-line import/order +import { ExchangeApiKeysService } from './exchange-api-keys.service'; +import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { KeyAuthorizationError } from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { + generateExchangeApiKey, + generateExchangeApiKeysData, +} from './fixtures'; +import { ExchangeClient } from '../exchange/types'; + +const mockUserRepository = createMock(); +const mockExchangeApiKeysRepository = createMock(); +const mockExchangeClient = createMock(); +const mockExchangeClientFactory = { + create: jest.fn().mockReturnValue(mockExchangeClient), +}; + +describe('ExchangeApiKeysService', () => { + let exchangeApiKeysService: ExchangeApiKeysService; + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + AesEncryptionService, + { provide: UserRepository, useValue: mockUserRepository }, + { + provide: ExchangeApiKeysRepository, + useValue: mockExchangeApiKeysRepository, + }, + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + { + provide: ExchangeClientFactory, + useValue: mockExchangeClientFactory, + }, + ], + }).compile(); + + exchangeApiKeysService = module.get( + ExchangeApiKeysService, + ); + aesEncryptionService = + module.get(AesEncryptionService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(exchangeApiKeysService).toBeDefined(); + }); + + describe('enroll', () => { + it.each([ + Object.assign(generateExchangeApiKeysData(), { userId: '' }), + Object.assign(generateExchangeApiKeysData(), { apiKey: '' }), + Object.assign(generateExchangeApiKeysData(), { secretKey: '' }), + ])('should throw if required param is missing [%#]', async (input) => { + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError.constructor).toBe(Error); + expect(thrownError.message).toBe('Invalid arguments'); + }); + + it('should throw if provided keys do not have required access', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(false); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(KeyAuthorizationError); + expect(thrownError.exchangeName).toBe(input.exchangeName); + }); + + it('should overwrite existing keys if user already has active ones', async () => { + const input = generateExchangeApiKeysData(); + const existingKey = generateExchangeApiKey(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce( + existingKey, + ); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + mockUserRepository.findOneById.mockResolvedValueOnce({ + id: input.userId, + } as UserEntity); + mockExchangeApiKeysRepository.updateOne.mockImplementation( + async (entity) => entity, + ); + + const updatedEntity = await exchangeApiKeysService.enroll(input); + + expect(mockExchangeApiKeysRepository.updateOne).toHaveBeenCalledWith( + existingKey, + ); + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + aesEncryptionService.decrypt(updatedEntity.apiKey), + aesEncryptionService.decrypt(updatedEntity.secretKey), + ]); + expect(decryptedApiKey.toString()).toBe(input.apiKey); + expect(decryptedSecretKey.toString()).toBe(input.secretKey); + }); + + it('should throw if user not exists', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(UserNotFoundError); + }); + + it('should insert encrypted keys if data is valid', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + mockUserRepository.findOneById.mockResolvedValueOnce({ + id: 1, + } as UserEntity); + + const input = generateExchangeApiKeysData(); + + const entity = await exchangeApiKeysService.enroll(input); + + expect(entity.userId).toBe(input.userId); + expect(entity.exchangeName).toBe(input.exchangeName); + expect(entity.apiKey).not.toBe(input.apiKey); + expect(entity.secretKey).not.toBe(input.secretKey); + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + aesEncryptionService.decrypt(entity.apiKey), + aesEncryptionService.decrypt(entity.secretKey), + ]); + + expect(decryptedApiKey.toString()).toBe(input.apiKey); + expect(decryptedSecretKey.toString()).toBe(input.secretKey); + }); + }); + + describe('retrieve', () => { + it('should return null if key not found for the user', async () => { + const { userId } = generateExchangeApiKeysData(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + + const result = await exchangeApiKeysService.retrieve(userId); + expect(result).toBeNull(); + }); + + it('should return decrypted keys', async () => { + const { userId, exchangeName, apiKey, secretKey } = + generateExchangeApiKeysData(); + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + aesEncryptionService.encrypt(Buffer.from(apiKey)), + aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce({ + exchangeName, + apiKey: encryptedApiKey, + secretKey: encryptedSecretKey, + } as ExchangeApiKeyEntity); + + const result = await exchangeApiKeysService.retrieve(userId); + + expect(result).not.toBeNull(); + expect(result!.apiKey).toBe(apiKey); + expect(result!.secretKey).toBe(secretKey); + expect( + mockExchangeApiKeysRepository.findOneByUserId, + ).toHaveBeenCalledWith(userId); + }); + }); + + describe('getSupportedExchanges', () => { + it('returns a copy of supported exchanges constant', () => { + const result = exchangeApiKeysService.getSupportedExchanges(); + + expect(result).toEqual(SUPPORTED_EXCHANGES_INFO); + expect(result).not.toBe(SUPPORTED_EXCHANGES_INFO); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..c59b852fdc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; + +import { + SUPPORTED_EXCHANGES_INFO, + SupportedExchange, + type SupportedExchangeInfo, +} from '@/common/constants'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { UserNotFoundError, UserRepository } from '@/modules/user'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { KeyAuthorizationError } from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly aesEncryptionService: AesEncryptionService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, + ) {} + + async enroll(input: { + userId: number; + exchangeName: SupportedExchange; + apiKey: string; + secretKey: string; + }): Promise { + const { userId, exchangeName, apiKey, secretKey } = input; + + if (!userId || !apiKey || !secretKey) { + throw new Error('Invalid arguments'); + } + + const currentKeys = + await this.exchangeApiKeysRepository.findOneByUserId(userId); + + const client = await this.exchangeClientFactory.create(exchangeName, { + apiKey, + secretKey, + }); + const hasRequiredAccess = await client.checkRequiredAccess(); + if (!hasRequiredAccess) { + throw new KeyAuthorizationError(exchangeName); + } + + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.encrypt(Buffer.from(apiKey)), + this.aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + if (currentKeys) { + currentKeys.exchangeName = exchangeName; + currentKeys.apiKey = encryptedApiKey; + currentKeys.secretKey = encryptedSecretKey; + + return this.exchangeApiKeysRepository.updateOne(currentKeys); + } + + const enrolledKey = new ExchangeApiKeyEntity(); + enrolledKey.userId = userId; + enrolledKey.exchangeName = exchangeName; + enrolledKey.apiKey = encryptedApiKey; + enrolledKey.secretKey = encryptedSecretKey; + await this.exchangeApiKeysRepository.createUnique(enrolledKey); + + return enrolledKey; + } + + async retrieve(userId: number): Promise<{ + exchangeName: string; + apiKey: string; + secretKey: string; + } | null> { + const entity = await this.exchangeApiKeysRepository.findOneByUserId(userId); + if (!entity) { + return null; + } + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.decrypt(entity.apiKey), + this.aesEncryptionService.decrypt(entity.secretKey), + ]); + + return { + exchangeName: entity.exchangeName, + apiKey: decryptedApiKey.toString(), + secretKey: decryptedSecretKey.toString(), + }; + } + + getSupportedExchanges(): SupportedExchangeInfo[] { + return SUPPORTED_EXCHANGES_INFO.map((exchange) => ({ ...exchange })); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts new file mode 100644 index 0000000000..8597aa47fc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker'; + +import { generateExchangeName } from '@/modules/exchange/fixtures'; + +import { ExchangeApiKeyEntity } from '../exchange-api-key.entity'; + +export function generateExchangeApiKeysData() { + return { + userId: faker.number.int(), + exchangeName: generateExchangeName(), + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; +} + +export function generateExchangeApiKey(): ExchangeApiKeyEntity { + const entity = { + id: faker.number.int(), + ...generateExchangeApiKeysData(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; + + return entity as ExchangeApiKeyEntity; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts new file mode 100644 index 0000000000..2736f37d6b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts @@ -0,0 +1,8 @@ +export { ExchangeApiKeysModule } from './exchange-api-keys.module'; +export { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +export { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +export { ExchangeApiKeysService } from './exchange-api-keys.service'; +export { + ExchangeApiKeyNotFoundError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts new file mode 100644 index 0000000000..d529464ffa --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts @@ -0,0 +1,15 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiClientError extends BaseError {} + +export class ExchangeProviderResponseError extends BaseError { + constructor(exchange: string, status: number, detail?: string) { + const exchangeLabel = `${exchange.toUpperCase()} API`; + const fallback = status ? `status ${status}` : 'an error'; + super( + detail + ? `${exchangeLabel} error: ${detail}` + : `${exchangeLabel} responded with ${fallback}`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts new file mode 100644 index 0000000000..035c2b0ef2 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import type { SupportedExchange } from '@/common/constants'; + +import { GateExchangeClient } from './gate-exchange.client'; +import { MexcExchangeClient } from './mexc-exchange.client'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +@Injectable() +export class ExchangeClientFactory { + async create( + exchange: SupportedExchange, + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ): Promise { + switch (exchange) { + case 'mexc': { + return new MexcExchangeClient(creds, options); + } + case 'gate': { + return new GateExchangeClient(creds, options); + } + default: + throw new Error(`Unsupported exchange: ${exchange}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts new file mode 100644 index 0000000000..ddc664fdc8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { ExchangeClientFactory } from './exchange-client.factory'; + +@Module({ + providers: [ExchangeClientFactory], + exports: [ExchangeClientFactory], +}) +export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts new file mode 100644 index 0000000000..bca953a18b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -0,0 +1,32 @@ +import { faker } from '@faker-js/faker'; + +import { SupportedExchange } from '@/common/constants'; + +export function generateGateAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return tokens.map((token) => ({ + currency: token, + available: faker.finance.amount(), + locked: faker.finance.amount(), + freeze: faker.finance.amount(), + })); +} + +export function generateMexcAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return { + balances: tokens.map((token) => ({ + asset: token, + free: faker.finance.amount(), + locked: faker.finance.amount(), + })), + }; +} + +export function generateExchangeName() { + return faker.helpers.enumValue(SupportedExchange); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts new file mode 100644 index 0000000000..40165d35a9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts @@ -0,0 +1 @@ +export * from './exchange'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts new file mode 100644 index 0000000000..9b7b60d150 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -0,0 +1,224 @@ +jest.mock('@/logger'); + +import crypto from 'crypto'; + +import { faker } from '@faker-js/faker'; +import nock from 'nock'; + +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; +import { generateGateAccountBalance } from './fixtures'; +import { + DEVELOP_GATE_API_BASE_URL, + GateExchangeClient, +} from './gate-exchange.client'; + +describe('GateExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + + afterEach(() => { + jest.resetAllMocks(); + nock.cleanAll(); + }); + + describe('signGateRequest', () => { + it('returns the expected signature for known input', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const method = faker.string.sample(); + const path = faker.string.sample(); + const query = faker.string.sample(); + const body = faker.string.sample(); + const now = faker.number.int(); + + const client = new GateExchangeClient({ apiKey, secretKey }); + + jest.useFakeTimers({ now }); + const { signature, timestamp } = client['signGateRequest']( + method, + path, + query, + body, + ); + jest.useRealTimers(); + + const bodyHash = crypto + .createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [ + method, + path, + query, + bodyHash, + String(Math.floor(now / 1000)), + ].join('\n'); + const expectedSignature = crypto + .createHmac('sha512', secretKey) + .update(payload) + .digest('hex'); + + expect(timestamp).toBe(String(Math.floor(now / 1000))); + expect(signature).toBe(expectedSignature); + }); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new GateExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const timeoutMs = faker.number.int(); + const client = new GateExchangeClient( + { apiKey, secretKey }, + { timeoutMs: timeoutMs }, + ); + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(timeoutMs); + }); + }); + + describe('checkRequiredAccess', () => { + const path = '/spot/accounts'; + + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(true); + }); + + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(false); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + let thrownError; + try { + await client.checkRequiredAccess(); + } catch (error) { + thrownError = error; + } + + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); + }); + }); + + describe('getAccountBalance', () => { + const path = '/spot/accounts'; + + it('throws ExchangeProviderResponseError with response body if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const errorPayload = { message: 'forbidden' }; + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403, errorPayload); + + const balancePromise = client.getAccountBalance(asset); + await expect(balancePromise).rejects.toThrow( + ExchangeProviderResponseError, + ); + + scope.done(); + }); + + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateGateAccountBalance(['OTHER'])); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns sum of available and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const balanceFixture = generateGateAccountBalance([asset]); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); + + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe( + parseFloat(balanceFixture[0].available) + + parseFloat(balanceFixture[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + let thrownError; + try { + await client.getAccountBalance(asset); + } catch (error) { + thrownError = error; + } + + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts new file mode 100644 index 0000000000..ad50394dea --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -0,0 +1,148 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; +import Environment from '@/utils/environment'; + +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; +import { fetchWithHandling } from './utils'; + +export const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +export const DEVELOP_GATE_API_BASE_URL = + 'https://api-testnet.gateapi.io/api/v4'; + +export class GateExchangeClient implements ExchangeClient { + readonly id = SupportedExchange.GATE; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly apiBaseUrl = Environment.isDevelopment() + ? DEVELOP_GATE_API_BASE_URL + : GATE_API_BASE_URL; + private readonly logger = logger.child({ + context: GateExchangeClient.name, + exchange: this.id, + }); + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for Gate'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + async checkRequiredAccess(): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = ''; + const body = ''; + const { signature, timestamp } = this.signGateRequest( + method, + `/api/v4${path}`, + query, + body, + ); + + const res = await fetchWithHandling( + `${this.apiBaseUrl}${path}`, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: timestamp, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); + + if (res.ok) return true; + return false; + } + + async getAccountBalance(asset: string): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = `currency=${encodeURIComponent(asset)}`; + const body = ''; + const requestPath = `/api/v4${path}`; + const { signature, timestamp } = this.signGateRequest( + method, + requestPath, + query, + body, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + const res = await fetchWithHandling( + url, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: timestamp, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); + + if (!res.ok) { + const errorBody = await res.json(); + throw new ExchangeProviderResponseError( + this.id, + res.status, + errorBody.message as string, + ); + } + + const data = (await res.json()) as Array<{ + currency: string; + available: string; + locked?: string; + freeze?: string; + }>; + + const normalize = (item: { + currency: string; + available: string; + locked?: string; + freeze?: string; + }) => { + const free = parseFloat(item.available) || 0; + const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; + return free + locked; + }; + + const entry = data.find((d) => d.currency === asset); + return entry ? normalize(entry) : 0; + } + + private signGateRequest( + method: string, + path: string, + query: string, + body: string, + ): { signature: string; timestamp: string } { + const timestamp = String(Math.floor(Date.now() / 1000)); + const bodyHash = createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [method, path, query, bodyHash, timestamp].join('\n'); + const signature = createHmac('sha512', this.secretKey) + .update(payload) + .digest('hex'); + return { signature, timestamp }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts new file mode 100644 index 0000000000..3a0424a866 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -0,0 +1,3 @@ +export { ExchangeModule } from './exchange.module'; +export { ExchangeClientFactory } from './exchange-client.factory'; +export * from './types'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts new file mode 100644 index 0000000000..5b91f88d3b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -0,0 +1,196 @@ +jest.mock('@/logger'); + +import { createHmac } from 'crypto'; + +import { faker } from '@faker-js/faker'; +import nock from 'nock'; + +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; +import { generateMexcAccountBalance } from './fixtures'; +import { MexcExchangeClient, MEXC_API_BASE_URL } from './mexc-exchange.client'; + +describe('MexcExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + + afterEach(() => { + jest.resetAllMocks(); + nock.cleanAll(); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new MexcExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const timeoutMs = faker.number.int(); + + const client = new MexcExchangeClient( + { apiKey, secretKey }, + { timeoutMs: timeoutMs }, + ); + + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(timeoutMs); + }); + }); + + describe('signQuery', () => { + it('getSignedQuery returns correct structure and signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + + const now = Date.now(); + jest.useFakeTimers({ now }); + const result = client['getSignedQuery'](); + jest.useRealTimers(); + + expect(result).toHaveProperty('query'); + expect(result).toHaveProperty('signature'); + expect(result.query).toBe(`timestamp=${now}&recvWindow=5000`); + + const expectedSignature = createHmac('sha256', secretKey) + .update(result.query) + .digest('hex'); + expect(result.signature).toBe(expectedSignature); + }); + }); + + describe('checkRequiredAccess', () => { + const path = '/account'; + + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(200); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(true); + }); + + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); + const result = await client.checkRequiredAccess(); + scope.done(); + expect(result).toBe(false); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + let thrownError: unknown; + try { + await client.checkRequiredAccess(); + } catch (error) { + thrownError = error; + } + + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); + }); + }); + + describe('getAccountBalance', () => { + const path = '/account'; + + it('throws ExchangeProviderResponseError with response detail if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const errorPayload = { msg: 'forbidden' }; + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(400, errorPayload); + + const balancePromise = client.getAccountBalance(asset); + await expect(balancePromise).rejects.toThrow( + ExchangeProviderResponseError, + ); + + scope.done(); + }); + + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateMexcAccountBalance(['OTHER'])); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe(0); + }); + + it('returns sum of free and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const balanceFixture = generateMexcAccountBalance([asset]); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); + const result = await client.getAccountBalance(asset); + scope.done(); + expect(result).toBe( + parseFloat(balanceFixture.balances[0].free) + + parseFloat(balanceFixture.balances[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); + let thrownError: unknown; + try { + await client.getAccountBalance(asset); + } catch (error) { + thrownError = error; + } + + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts new file mode 100644 index 0000000000..9c3b0d4460 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -0,0 +1,101 @@ +import { createHmac } from 'node:crypto'; + +import { SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; + +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; +import { fetchWithHandling } from './utils'; + +export const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; + +export class MexcExchangeClient implements ExchangeClient { + readonly id = SupportedExchange.MEXC; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly logger = logger.child({ + context: MexcExchangeClient.name, + exchange: this.id, + }); + readonly recvWindow = 5000; + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for MEXC'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + private signQuery(query: string): string { + return createHmac('sha256', this.secretKey).update(query).digest('hex'); + } + + async checkRequiredAccess(): Promise { + const path = '/account'; + const { query, signature } = this.getSignedQuery(); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const res = await fetchWithHandling( + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (res.ok) return true; + return false; + } + + async getAccountBalance(asset: string): Promise { + const path = '/account'; + const { query, signature } = this.getSignedQuery(); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const res = await fetchWithHandling( + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (!res.ok) { + const errorBody = await res.json(); + throw new ExchangeProviderResponseError( + this.id, + res.status, + errorBody.msg as string, + ); + } + const data = (await res.json()) as { + balances?: Array<{ asset: string; free: string; locked: string }>; + }; + const balances = data.balances || []; + const entry = balances.find((b) => b.asset === asset); + if (!entry) return 0; + const total = + (parseFloat(entry.free || '0') || 0) + + (parseFloat(entry.locked || '0') || 0); + return total; + } + + private getSignedQuery(): { + query: string; + signature: string; + } { + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + return { query, signature }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts new file mode 100644 index 0000000000..1fd5cfa68b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts @@ -0,0 +1,16 @@ +import type { SupportedExchange } from '@/common/constants'; + +export interface ExchangeClientCredentials { + apiKey: string; + secretKey: string; +} + +export interface ExchangeClientOptions { + timeoutMs?: number; +} + +export interface ExchangeClient { + readonly id: SupportedExchange; + checkRequiredAccess(): Promise; + getAccountBalance(asset: string): Promise; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts new file mode 100644 index 0000000000..8a2fd42fdc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -0,0 +1,27 @@ +import { DEFAULT_TIMEOUT_MS } from '@/common/constants'; +import Logger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; + +export async function fetchWithHandling( + url: string, + headers: HeadersInit, + logger: typeof Logger, + timeoutMs?: number, +): Promise { + try { + const res = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS), + }); + return res; + } catch (error) { + const message: string = `Failed to make request for exchange`; + logger.error(message, { + url, + error, + }); + throw new ExchangeApiClientError(message); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/index.ts b/packages/apps/reputation-oracle/server/src/modules/staking/index.ts new file mode 100644 index 0000000000..fc1296365f --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/index.ts @@ -0,0 +1,3 @@ +export { StakingModule } from './staking.module'; +export { StakingService } from './staking.service'; +export type { StakeSummaryData } from './types'; diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts new file mode 100644 index 0000000000..c4f711281d --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Req, UseFilters } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { Public } from '@/common/decorators'; +import type { RequestWithUser } from '@/common/types'; + +import { StakeConfigResponseDto, StakeSummaryResponseDto } from './staking.dto'; +import { StakingControllerErrorsFilter } from './staking.error-filter'; +import { StakingService } from './staking.service'; + +@ApiTags('Staking') +@UseFilters(StakingControllerErrorsFilter) +@Controller('staking') +export class StakingController { + constructor(private readonly stakingService: StakingService) {} + + @ApiBearerAuth() + @ApiOperation({ summary: 'Retrieve aggregated staking info' }) + @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) + @Get('/summary') + async getStakeSummary( + @Req() request: RequestWithUser, + ): Promise { + return this.stakingService.getStakeSummary(request.user.id); + } + + @Public() + @ApiOperation({ summary: 'Retrieve staking configuration' }) + @ApiResponse({ status: 200, type: StakeConfigResponseDto }) + @Get('/config') + async getStakeConfig(): Promise { + return this.stakingService.getStakeConfig(); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts new file mode 100644 index 0000000000..114430a9da --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StakeSummaryResponseDto { + @ApiProperty({ name: 'exchange_stake' }) + exchangeStake: string; + + @ApiProperty({ name: 'on_chain_stake' }) + onChainStake: string; + + @ApiPropertyOptional({ name: 'exchange_error' }) + exchangeError?: string; + + @ApiPropertyOptional({ name: 'on_chain_error' }) + onChainError?: string; +} + +export class StakeConfigResponseDto { + @ApiProperty({ name: 'min_threshold' }) + minThreshold: string; + + @ApiProperty({ name: 'eligibility_enabled' }) + eligibilityEnabled: boolean; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts new file mode 100644 index 0000000000..af660d4d70 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts @@ -0,0 +1,34 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; + +@Catch(UserNotFoundError) +export class StakingControllerErrorsFilter implements ExceptionFilter { + private readonly logger = logger.child({ + context: StakingControllerErrorsFilter.name, + }); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + if (exception instanceof UserNotFoundError) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts new file mode 100644 index 0000000000..a28244e510 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { ExchangeModule } from '@/modules/exchange/exchange.module'; +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; +import { UserModule } from '@/modules/user'; +import { Web3Module } from '@/modules/web3'; + +import { StakingController } from './staking.controller'; +import { StakingService } from './staking.service'; + +@Module({ + imports: [ExchangeApiKeysModule, ExchangeModule, UserModule, Web3Module], + providers: [StakingService], + controllers: [StakingController], + exports: [StakingService], +}) +export class StakingModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts new file mode 100644 index 0000000000..bac6576a7b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -0,0 +1,212 @@ +jest.mock('@human-protocol/sdk'); + +import { faker } from '@faker-js/faker'; +import { createMock } from '@golevelup/ts-jest'; +import { StakingClient } from '@human-protocol/sdk'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ethers } from 'ethers'; + +import { SupportedExchange } from '@/common/constants'; +import { StakingConfigService, Web3ConfigService } from '@/config'; +import { type ExchangeClient, ExchangeClientFactory } from '@/modules/exchange'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; +import { WalletWithProvider, Web3Service } from '@/modules/web3'; +import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; + +import { StakingService } from './staking.service'; + +const mockExchangeApiKeysService = createMock(); +const mockExchangeClientFactory = { + create: jest.fn(), +}; +const mockExchangeClient = createMock(); +const mockUserRepository = createMock(); +const mockWeb3Service = createMock(); +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: faker.number.int({ min: 1, max: 1000 }), + asset: 'HMT', + timeoutMs: faker.number.int({ min: 1000, max: 10000 }), +}; +const mockedStakingClient = jest.mocked(StakingClient); + +describe('StakingService', () => { + let stakingService: StakingService; + + beforeAll(async () => { + mockExchangeClientFactory.create.mockResolvedValue(mockExchangeClient); + mockExchangeClient.getAccountBalance.mockReset(); + mockWeb3Service.getSigner.mockReturnValue({ + provider: {}, + } as never); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingService, + { + provide: ExchangeApiKeysService, + useValue: mockExchangeApiKeysService, + }, + { + provide: ExchangeClientFactory, + useValue: mockExchangeClientFactory, + }, + { provide: UserRepository, useValue: mockUserRepository }, + { provide: Web3Service, useValue: mockWeb3Service }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, + { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, + ], + }).compile(); + + stakingService = module.get(StakingService); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + describe('getExchangeStakedBalance', () => { + const userId = faker.number.int(); + it('returns 0 when user has no exchange keys', async () => { + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); + + await expect( + stakingService.getExchangeStakedBalance(userId), + ).resolves.toBe(0); + }); + + it('returns balance fetched from exchange client', async () => { + const keys = { + exchangeName: SupportedExchange.GATE, + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; + const balance = faker.number.int(); + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(keys); + mockExchangeClient.getAccountBalance.mockResolvedValueOnce(balance); + + const result = await stakingService.getExchangeStakedBalance(userId); + expect(mockExchangeClientFactory.create).toHaveBeenCalledWith( + keys.exchangeName, + { + apiKey: keys.apiKey, + secretKey: keys.secretKey, + }, + { timeoutMs: mockStakingConfigService.timeoutMs }, + ); + expect(mockExchangeClient.getAccountBalance).toHaveBeenCalledWith( + mockStakingConfigService.asset, + ); + expect(result).toBe(balance); + }); + }); + + describe('getStakeSummary', () => { + const user = { + id: faker.number.int(), + evmAddress: faker.finance.ethereumAddress(), + }; + const onChainStake = faker.number.int(); + const exchangeStake = faker.number.int(); + let spyOnGetExchangeStakedBalance: jest.SpyInstance; + let spyOnGetOnChainStakedBalance: jest.SpyInstance; + + beforeAll(() => { + spyOnGetExchangeStakedBalance = jest + .spyOn(stakingService, 'getExchangeStakedBalance') + .mockImplementation(); + spyOnGetOnChainStakedBalance = jest + .spyOn(stakingService, 'getOnChainStakedBalance') + .mockImplementation(); + }); + + afterAll(() => { + spyOnGetExchangeStakedBalance.mockRestore(); + spyOnGetOnChainStakedBalance.mockRestore(); + }); + + it('throws when user is not found', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + await expect( + stakingService.getStakeSummary(user.id), + ).rejects.toBeInstanceOf(UserNotFoundError); + }); + + it('returns aggregated exchange and on-chain stakes', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(user as UserEntity); + spyOnGetExchangeStakedBalance.mockResolvedValueOnce(exchangeStake); + spyOnGetOnChainStakedBalance.mockResolvedValueOnce(onChainStake); + + const result = await stakingService.getStakeSummary(user.id); + + expect(spyOnGetExchangeStakedBalance).toHaveBeenCalledWith(user.id); + expect(spyOnGetOnChainStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + expect(result).toEqual({ + exchangeStake: exchangeStake.toString(), + onChainStake: onChainStake.toString(), + }); + }); + + it('skips on-chain lookup when user has no address', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce({ + ...user, + evmAddress: null, + } as UserEntity); + spyOnGetExchangeStakedBalance.mockResolvedValueOnce(exchangeStake); + + const result = await stakingService.getStakeSummary(user.id); + + expect(spyOnGetOnChainStakedBalance).not.toHaveBeenCalled(); + expect(result).toEqual({ + exchangeStake: exchangeStake.toString(), + onChainStake: '0', + }); + }); + }); + + describe('getOnChainStakedBalance', () => { + it('returns total staked and locked balance', async () => { + const address = faker.finance.ethereumAddress(); + const stakedAmount = ethers.toBigInt( + faker.number.int({ min: 500, max: 1000000 }), + ); + const lockedAmount = ethers.toBigInt( + faker.number.int({ min: 500, max: 999999 }), + ); + const mockProvider = {}; + mockWeb3Service.getSigner.mockReturnValueOnce({ + provider: mockProvider, + } as WalletWithProvider); + + const getStakerInfoMock = jest + .fn() + .mockResolvedValue({ stakedAmount, lockedAmount }); + mockedStakingClient.build.mockResolvedValueOnce({ + getStakerInfo: getStakerInfoMock, + } as unknown as StakingClient); + + const result = await stakingService.getOnChainStakedBalance(address); + + expect(mockedStakingClient.build).toHaveBeenCalledWith(mockProvider); + expect(getStakerInfoMock).toHaveBeenCalledWith(address); + expect(result).toBe( + Number(ethers.formatEther(stakedAmount + lockedAmount)), + ); + }); + }); + + describe('getStakeConfig', () => { + it('returns current staking configuration', async () => { + const result = await stakingService.getStakeConfig(); + + expect(result).toEqual({ + minThreshold: mockStakingConfigService.minThreshold.toString(), + eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, + }); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts new file mode 100644 index 0000000000..b99412ddb0 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -0,0 +1,112 @@ +import { StakingClient } from '@human-protocol/sdk'; +import { Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; + +import { SupportedExchange } from '@/common/constants'; +import { StakingConfigService, Web3ConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import { UserNotFoundError, UserRepository } from '@/modules/user'; +import { Web3Service } from '@/modules/web3'; +import { formatStake } from '@/utils/stake'; + +import { StakeConfigData, StakeSummaryData } from './types'; + +@Injectable() +export class StakingService { + private readonly logger = logger.child({ + context: StakingService.name, + }); + + constructor( + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, + private readonly web3Service: Web3Service, + private readonly stakingConfigService: StakingConfigService, + private readonly web3ConfigService: Web3ConfigService, + ) {} + + async getExchangeStakedBalance(userId: number): Promise { + const apiKeys = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKeys) { + return 0; + } + + const client = await this.exchangeClientFactory.create( + apiKeys.exchangeName as SupportedExchange, + { + apiKey: apiKeys.apiKey, + secretKey: apiKeys.secretKey, + }, + { timeoutMs: this.stakingConfigService.timeoutMs }, + ); + + return client.getAccountBalance(this.stakingConfigService.asset); + } + + async getOnChainStakedBalance(address: string): Promise { + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.web3Service.getSigner(chainId).provider; + + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); + + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } + + async getStakeSummary(userId: number): Promise { + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const summary: StakeSummaryData = { + exchangeStake: '0', + onChainStake: '0', + }; + + try { + summary.exchangeStake = formatStake( + await this.getExchangeStakedBalance(userId), + ); + } catch (error) { + summary.exchangeError = error.message + ? error.message + : 'Unable to fetch exchange stake'; + this.logger.warn('Failed to retrieve exchange stake', { + userId, + error, + }); + } + + if (user.evmAddress) { + try { + summary.onChainStake = formatStake( + await this.getOnChainStakedBalance(user.evmAddress), + ); + } catch (error) { + summary.onChainError = error.message + ? error.message + : 'Unable to fetch on-chain stake'; + this.logger.warn('Failed to retrieve on-chain stake', { + userId, + evmAddress: user.evmAddress, + error, + }); + } + } + + return summary; + } + + async getStakeConfig(): Promise { + return { + minThreshold: this.stakingConfigService.minThreshold.toString(), + eligibilityEnabled: this.stakingConfigService.eligibilityEnabled, + }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts new file mode 100644 index 0000000000..fcf1e16436 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts @@ -0,0 +1,11 @@ +export type StakeSummaryData = { + exchangeStake: string; + onChainStake: string; + exchangeError?: string; + onChainError?: string; +}; + +export type StakeConfigData = { + minThreshold: string; + eligibilityEnabled: boolean; +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index baa5f64a5f..768196b62a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; import { Web3ConfigService, Web3Network } from '@/config'; +import logger from '@/logger'; import type { Chain, WalletWithProvider } from './types'; @@ -22,6 +23,9 @@ export class Web3Service { private signersByChainId: { [chainId: number]: WalletWithProvider; } = {}; + private readonly logger = logger.child({ + context: Web3Service.name, + }); constructor(private readonly web3ConfigService: Web3ConfigService) { const privateKey = this.web3ConfigService.privateKey; diff --git a/packages/apps/reputation-oracle/server/src/utils/stake.ts b/packages/apps/reputation-oracle/server/src/utils/stake.ts new file mode 100644 index 0000000000..49988b7c22 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/utils/stake.ts @@ -0,0 +1,6 @@ +const stakeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 18, + notation: 'standard', + useGrouping: false, +}); +export const formatStake = (value: number) => stakeFormatter.format(value);