diff --git a/packages/apps/dashboard/server/src/common/constants/operator.ts b/packages/apps/dashboard/server/src/common/constants/operator.ts index 4415b4ef8c..e385f086cc 100644 --- a/packages/apps/dashboard/server/src/common/constants/operator.ts +++ b/packages/apps/dashboard/server/src/common/constants/operator.ts @@ -1,2 +1,3 @@ export const MIN_AMOUNT_STAKED = 1; -export const MAX_LEADERS_COUNT = 1000; +export const MAX_LEADERS_COUNT = 100; +export const REPUTATION_PLACEHOLDER = 'Not available'; diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index ddb88e7745..24e6de83c3 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -28,6 +28,7 @@ import { ReputationLevel } from '../../common/enums/reputation'; import { MAX_LEADERS_COUNT, MIN_AMOUNT_STAKED, + REPUTATION_PLACEHOLDER, } from '../../common/constants/operator'; import { GetOperatorsPaginationOptions } from 'src/common/types'; import { KVStoreDataDto } from './dto/details-response.dto'; @@ -65,7 +66,11 @@ export class DetailsService { operatorDto.chainId = chainId; operatorDto.balance = await this.getHmtBalance(chainId, address); - const { reputation } = await this.fetchReputation(chainId, address); + const { reputation } = await this.fetchOperatorReputation( + chainId, + address, + operatorDto.role, + ); operatorDto.reputation = reputation; return operatorDto; @@ -235,31 +240,40 @@ export class DetailsService { return operatorsFilter; } - private async fetchReputation( + private async fetchOperatorReputation( chainId: ChainId, address: string, + role?: string, ): Promise<{ address: string; reputation: string }> { try { const response = await firstValueFrom( - this.httpService.get( - `${this.configService.reputationSource}/reputation/${address}`, + this.httpService.get<{ level: string }[]>( + `${this.configService.reputationSource}/reputation`, { params: { chain_id: chainId, - roles: [ - Role.JobLauncher, - Role.ExchangeOracle, - Role.RecordingOracle, - Role.ReputationOracle, - ], + address, + roles: role + ? [role] + : [ + Role.JobLauncher, + Role.ExchangeOracle, + Role.RecordingOracle, + Role.ReputationOracle, + ], }, }, ), ); - return response.data; + + let reputation = REPUTATION_PLACEHOLDER; + if (response.data.length) { + reputation = response.data[0].level; + } + return { address, reputation }; } catch (error) { this.logger.error('Error fetching reputation:', error); - return { address, reputation: 'Not available' }; + return { address, reputation: REPUTATION_PLACEHOLDER }; } } @@ -268,7 +282,7 @@ export class DetailsService { orderBy?: OperatorsOrderBy, orderDirection?: OrderDirection, first?: number, - ): Promise<{ address: string; reputation: string }[]> { + ): Promise<{ address: string; level: string }[]> { try { const response = await firstValueFrom( this.httpService.get( @@ -304,10 +318,10 @@ export class DetailsService { private assignReputationsToOperators( operators: OperatorDto[], - reputations: { address: string; reputation: string }[], + reputations: { address: string; level: string }[], ): OperatorDto[] { const reputationMap = new Map( - reputations.map((rep) => [rep.address.toLowerCase(), rep.reputation]), + reputations.map((rep) => [rep.address.toLowerCase(), rep.level]), ); operators.forEach((operator) => { diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index 0d0eaf51e6..ead21997de 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -67,7 +67,7 @@ describe('DetailsService', () => { it('should fetch and return operators with reputations', async () => { const mockOperators = [{ address: '0x123', role: 'Reputation Oracle' }]; - const mockReputations = [{ address: '0x123', reputation: 'hign' }]; + const mockReputations = [{ address: '0x123', level: 'high' }]; jest .spyOn(OperatorUtils, 'getOperators') @@ -82,7 +82,7 @@ describe('DetailsService', () => { expect.objectContaining({ address: '0x123', role: 'Reputation Oracle', - reputation: 'hign', + reputation: 'high', }), ]); }); @@ -115,9 +115,9 @@ describe('DetailsService', () => { { address: '0xE', role: 'Recording Oracle' }, ]; const mockReputations = [ - { address: '0xB', reputation: 'high' }, - { address: '0xC', reputation: 'high' }, - { address: '0xD', reputation: 'medium' }, + { address: '0xB', level: 'high' }, + { address: '0xC', level: 'high' }, + { address: '0xD', level: 'medium' }, ]; const getOperatorsSpy = jest.spyOn(OperatorUtils, 'getOperators'); diff --git a/packages/apps/human-app/frontend/src/api/hooks/use-access-token-refresh.ts b/packages/apps/human-app/frontend/src/api/hooks/use-access-token-refresh.ts index 3532d0070e..2f2a291eee 100644 --- a/packages/apps/human-app/frontend/src/api/hooks/use-access-token-refresh.ts +++ b/packages/apps/human-app/frontend/src/api/hooks/use-access-token-refresh.ts @@ -43,10 +43,10 @@ export function useAccessTokenRefresh() { } } catch (error) { if (authType === 'web2' && web2User) { - web2SignOut(false); + web2SignOut({ throwExpirationModal: false }); } if (authType === 'web3' && web3User) { - web3SignOut(false); + web3SignOut({ throwExpirationModal: false }); } browserAuthProvider.signOut({ triggerSignOutSubscriptions: throwExpirationModalOnSignOut, diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index dccc2c8cd3..a8fa0e6415 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -5,7 +5,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import '@/shared/i18n/i18n'; import CssBaseline from '@mui/material/CssBaseline'; import { BrowserRouter } from 'react-router-dom'; -import { DisplayModal } from '@/shared/components/ui/modal/display-modal'; import { AuthProvider } from '@/modules/auth/context/auth-context'; import { Router } from '@/router/router'; import '@fontsource/inter'; @@ -19,6 +18,8 @@ import { JWTExpirationCheck } from '@/shared/contexts/jwt-expiration-check'; import { ColorModeProvider } from '@/shared/contexts/color-mode'; 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'; const root = document.getElementById('root'); if (!root) throw Error('root element is undefined'); @@ -39,14 +40,16 @@ createRoot(root).render( - - - - - - - - + + + + + + + + + + diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/components/wallet-connect-modal.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/components/wallet-connect-modal.tsx index d213b84901..7f2446b2c1 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/components/wallet-connect-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/components/wallet-connect-modal.tsx @@ -4,16 +4,15 @@ import { Trans } from 'react-i18next'; import { useEffect } from 'react'; import { Button } from '@/shared/components/ui/button'; import { ConnectWalletBtn } from '@/shared/components/ui/connect-wallet-btn'; -import { useModalStore } from '@/shared/components/ui/modal/modal.store'; import { useWalletConnect } from '@/shared/contexts/wallet-connect'; +import { breakpoints } from '@/shared/styles/breakpoints'; -export function WalletConnectModal() { - const { closeModal } = useModalStore(); +export function WalletConnectModal({ close }: Readonly<{ close: () => void }>) { const { isConnected } = useWalletConnect(); useEffect(() => { if (isConnected) { - closeModal(); + close(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- ... }, [isConnected]); @@ -21,7 +20,15 @@ export function WalletConnectModal() { return ( { - closeModal(); + close(); }} variant="outlined" > diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx index 6c54fff874..4dc7be0d0c 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx @@ -1,14 +1,6 @@ -/* eslint-disable camelcase -- ...*/ -import { useState, createContext, useEffect } from 'react'; -import { jwtDecode } from 'jwt-decode'; +/* eslint-disable camelcase */ import { z } from 'zod'; -import { useQueryClient } from '@tanstack/react-query'; -import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; -import { - ModalType, - useModalStore, -} from '@/shared/components/ui/modal/modal.store'; -import { type AuthTokensSuccessResponse } from '@/shared/schemas'; +import { createAuthProvider } from '@/shared/contexts/generic-auth-context'; export enum OperatorStatus { ACTIVE = 'active', @@ -26,121 +18,8 @@ const web3userDataSchema = z.object({ export type Web3UserData = z.infer; -type AuthStatus = 'loading' | 'error' | 'success' | 'idle'; -export interface Web3AuthenticatedUserContextType { - user: Web3UserData; - status: AuthStatus; - signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; - updateUserData: (updateUserDataPayload: Partial) => void; -} - -interface Web3UnauthenticatedUserContextType { - user: null; - status: AuthStatus; - signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; -} - -export const Web3AuthContext = createContext< - Web3AuthenticatedUserContextType | Web3UnauthenticatedUserContextType | null ->(null); - -export function Web3AuthProvider({ children }: { children: React.ReactNode }) { - const queryClient = useQueryClient(); - const { openModal } = useModalStore(); - const [web3AuthState, setWeb3AuthState] = useState<{ - user: Web3UserData | null; - status: AuthStatus; - }>({ user: null, status: 'loading' }); - - const displayExpirationModal = () => { - queryClient.setDefaultOptions({ queries: { enabled: false } }); - openModal({ - modalType: ModalType.EXPIRATION_MODAL, - displayCloseButton: false, - maxWidth: 'sm', - }); - }; - - const updateUserData = (updateUserDataPayload: Partial) => { - setWeb3AuthState((state) => { - if (!state.user) { - return state; - } - - const newUserData = { - ...state.user, - ...updateUserDataPayload, - }; - browserAuthProvider.setUserData(newUserData); - - return { - ...state, - user: newUserData, - }; - }); - }; - - const handleSignIn = () => { - try { - const accessToken = browserAuthProvider.getAccessToken(); - const authType = browserAuthProvider.getAuthType(); - - if (!accessToken || authType !== 'web3') { - setWeb3AuthState({ user: null, status: 'idle' }); - return; - } - const userData = jwtDecode(accessToken); - const validUserData = web3userDataSchema.parse(userData); - setWeb3AuthState({ user: validUserData, status: 'success' }); - browserAuthProvider.signOutSubscription = displayExpirationModal; - } catch (e) { - // eslint-disable-next-line no-console -- ... - console.error('Invalid Jwt payload:', e); - browserAuthProvider.signOut({ triggerSignOutSubscriptions: true }); - setWeb3AuthState({ user: null, status: 'error' }); - } - }; - - const signIn = (singIsSuccess: AuthTokensSuccessResponse) => { - browserAuthProvider.signIn(singIsSuccess, 'web3'); - handleSignIn(); - }; - - const signOut = (throwExpirationModal = true) => { - browserAuthProvider.signOut({ - triggerSignOutSubscriptions: throwExpirationModal, - }); - - setWeb3AuthState({ user: null, status: 'idle' }); - }; - - useEffect(() => { - handleSignIn(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {children} - - ); -} +export const { AuthContext: Web3AuthContext, AuthProvider: Web3AuthProvider } = + createAuthProvider({ + authType: 'web3', + schema: web3userDataSchema, + }); diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/hooks/use-wallet-connect-modal.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/hooks/use-wallet-connect-modal.tsx new file mode 100644 index 0000000000..fca2609a2a --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/hooks/use-wallet-connect-modal.tsx @@ -0,0 +1,15 @@ +import { useModal } from '@/shared/contexts/modal-context'; +import { WalletConnectModal } from '../components/wallet-connect-modal'; + +export function useWalletConnectModal() { + const { openModal, closeModal } = useModal(); + + return { + openModal: () => { + openModal({ + content: , + showCloseButton: false, + }); + }, + }; +} diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx index 0f5f5b746f..e39be66414 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx @@ -1,12 +1,13 @@ import { useLocation, Navigate } from 'react-router-dom'; import { createContext } from 'react'; import { routerPaths } from '@/router/router-paths'; -import type { Web3AuthenticatedUserContextType } from '@/modules/auth-web3/context/web3-auth-context'; import { PageCardLoader } from '@/shared/components/ui/page-card'; import { useWeb3Auth } from '@/modules/auth-web3/hooks/use-web3-auth'; +import { type AuthenticatedUserContextType } from '@/shared/contexts/generic-auth-context'; +import { type Web3UserData } from '../context/web3-auth-context'; export const Web3AuthenticatedUserContext = - createContext(null); + createContext | null>(null); export function RequireWeb3Auth({ children, 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 4a36360c6d..2742866200 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 @@ -1,154 +1,21 @@ -/* eslint-disable camelcase -- ... */ -import { useState, createContext, useEffect } from 'react'; -import { jwtDecode } from 'jwt-decode'; +/* eslint-disable camelcase */ import { z } from 'zod'; -import { useQueryClient } from '@tanstack/react-query'; -import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; -import { - ModalType, - useModalStore, -} from '@/shared/components/ui/modal/modal.store'; -import { type AuthTokensSuccessResponse } from '@/shared/schemas'; +import { createAuthProvider } from '@/shared/contexts/generic-auth-context'; -const extendableUserDataSchema = z.object({ +const userDataSchema = z.object({ site_key: z.string().optional().nullable(), kyc_status: z.string().optional().nullable(), wallet_address: z.string().optional().nullable(), status: z.enum(['active', 'pending']), + email: z.string(), + user_id: z.number(), + reputation_network: z.string(), + exp: z.number(), }); -const userDataSchema = z - .object({ - email: z.string(), - user_id: z.number(), - reputation_network: z.string(), - email_notifications: z.boolean().optional(), // TODO that should be verified when email notifications feature is done - exp: z.number(), - }) - .merge(extendableUserDataSchema); - export type UserData = z.infer; -export type UpdateUserDataPayload = z.infer; - -type AuthStatus = 'loading' | 'error' | 'success' | 'idle'; -export interface AuthenticatedUserContextType { - user: UserData; - status: AuthStatus; - signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; - updateUserData: (updateUserDataPayload: UpdateUserDataPayload) => void; -} - -interface UnauthenticatedUserContextType { - user: null; - status: AuthStatus; - signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; -} - -export const AuthContext = createContext< - AuthenticatedUserContextType | UnauthenticatedUserContextType | null ->(null); - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const queryClient = useQueryClient(); - const { openModal } = useModalStore(); - const [authState, setAuthState] = useState<{ - user: UserData | null; - status: AuthStatus; - }>({ user: null, status: 'loading' }); - - const displayExpirationModal = () => { - queryClient.setDefaultOptions({ queries: { enabled: false } }); - openModal({ - modalType: ModalType.EXPIRATION_MODAL, - displayCloseButton: false, - maxWidth: 'sm', - }); - }; - - const updateUserData = (updateUserDataPayload: UpdateUserDataPayload) => { - setAuthState((state) => { - if (!state.user) { - return state; - } - - const newUserData = { - ...state.user, - ...updateUserDataPayload, - }; - browserAuthProvider.setUserData(newUserData); - return { - ...state, - user: newUserData, - }; - }); - }; - - const handleSignIn = () => { - try { - const accessToken = browserAuthProvider.getAccessToken(); - const authType = browserAuthProvider.getAuthType(); - const savedUserData = browserAuthProvider.getUserData(); - - if (!accessToken || authType !== 'web2') { - setAuthState({ user: null, status: 'idle' }); - return; - } - const userData = jwtDecode(accessToken); - const userDataWithSavedData = savedUserData.data - ? { ...userData, ...savedUserData.data } - : userData; - - const validUserData = userDataSchema.parse(userDataWithSavedData); - setAuthState({ user: validUserData, status: 'success' }); - browserAuthProvider.signOutSubscription = displayExpirationModal; - } catch (e) { - // eslint-disable-next-line no-console -- ... - console.error('Invalid Jwt payload:', e); - browserAuthProvider.signOut({ triggerSignOutSubscriptions: true }); - setAuthState({ user: null, status: 'error' }); - } - }; - - const signIn = (singIsSuccess: AuthTokensSuccessResponse) => { - browserAuthProvider.signIn(singIsSuccess, 'web2'); - handleSignIn(); - }; - - const signOut = (throwExpirationModal = true) => { - browserAuthProvider.signOut({ - triggerSignOutSubscriptions: throwExpirationModal, - }); - setAuthState({ user: null, status: 'idle' }); - }; - - useEffect(() => { - handleSignIn(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {children} - - ); -} +export const { AuthContext, AuthProvider } = createAuthProvider({ + authType: 'web2', + schema: userDataSchema, +}); diff --git a/packages/apps/human-app/frontend/src/modules/auth/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/auth/hooks/index.ts new file mode 100644 index 0000000000..07e811485a --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/auth/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-auth'; +export * from './use-authenticated-user'; diff --git a/packages/apps/human-app/frontend/src/modules/auth/index.ts b/packages/apps/human-app/frontend/src/modules/auth/index.ts new file mode 100644 index 0000000000..0e551ad9cd --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/auth/index.ts @@ -0,0 +1 @@ +export { useAuth, useAuthenticatedUser } from './hooks/index'; diff --git a/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx b/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx index dbc6a13b5b..413c8b4a69 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx @@ -2,11 +2,12 @@ import { useLocation, Navigate } from 'react-router-dom'; import { createContext } from 'react'; import { useAuth } from '@/modules/auth/hooks/use-auth'; import { routerPaths } from '@/router/router-paths'; -import type { AuthenticatedUserContextType } from '@/modules/auth/context/auth-context'; import { PageCardLoader } from '@/shared/components/ui/page-card'; +import { type AuthenticatedUserContextType } from '@/shared/contexts/generic-auth-context'; +import { type UserData } from '../context/auth-context'; export const AuthenticatedUserContext = - createContext(null); + createContext | null>(null); export function RequireAuth({ children }: Readonly<{ children: JSX.Element }>) { const auth = useAuth(); diff --git a/packages/apps/human-app/frontend/src/modules/signup/operator/views/connect-wallet.page.tsx b/packages/apps/human-app/frontend/src/modules/signup/operator/views/connect-wallet.page.tsx index a6f89751fc..53a5e85ec3 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/operator/views/connect-wallet.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/operator/views/connect-wallet.page.tsx @@ -6,15 +6,11 @@ import { useWalletConnect } from '@/shared/contexts/wallet-connect'; import { Alert } from '@/shared/components/ui/alert'; import { getErrorMessageForError } from '@/shared/errors'; import { Button } from '@/shared/components/ui/button'; -import { - ModalType, - useModalStore, -} from '@/shared/components/ui/modal/modal.store'; import { routerPaths } from '@/router/router-paths'; +import { useWalletConnectModal } from '@/modules/auth-web3/hooks/use-wallet-connect-modal'; export function ConnectWalletOperatorPage() { const { t } = useTranslation(); - const { openModal } = useModalStore(); const { isConnected, web3ProviderMutation: { @@ -22,6 +18,7 @@ export function ConnectWalletOperatorPage() { status: web3ProviderStatus, }, } = useWalletConnect(); + const { openModal } = useWalletConnectModal(); const getAlert = () => { if (web3ProviderStatus === 'error') @@ -51,12 +48,7 @@ export function ConnectWalletOperatorPage() { {t('operator.connectWallet.description')} - diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-filter-modal.tsx similarity index 89% rename from packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-filter-modal.tsx index 29373aa307..8edb04806b 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-filter-modal.tsx @@ -4,7 +4,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { Divider, IconButton, Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import CloseIcon from '@mui/icons-material/Close'; -import type { Dispatch, SetStateAction } from 'react'; import { HumanLogoIcon } from '@/shared/components/ui/icons'; import { useHandleMainNavIconClick } from '@/shared/hooks/use-handle-main-nav-icon-click'; import { useColorMode } from '@/shared/contexts/color-mode'; @@ -12,24 +11,20 @@ import { AvailableJobsNetworkFilter, AvailableJobsJobTypeFilter, } from './components'; -import { AvailableJobsRewardAmountSortMobile } from './components/mobile'; +import { AvailableJobsRewardAmountSortMobile } from './components/mobile/available-jobs-reward-amount-sort-mobile'; -interface DrawerMobileViewProps { - setIsMobileFilterDrawerOpen: Dispatch>; +interface AvailableJobsFilterModalProps { chainIdsEnabled: number[]; + close: () => void; } -export function AvailableJobsDrawerMobileView({ - setIsMobileFilterDrawerOpen, +export function AvailableJobsFilterModal({ chainIdsEnabled, -}: Readonly) { + close, +}: Readonly) { const handleMainNavIconClick = useHandleMainNavIconClick(); const { colorPalette } = useColorMode(); const { t } = useTranslation(); - const handleCloseDrawer = () => { - setIsMobileFilterDrawerOpen(false); - }; - return ( @@ -69,7 +64,7 @@ export function AvailableJobsDrawerMobileView({ void; chainIdsEnabled: number[]; } -export function AvailableJobsView({ - handleOpenMobileFilterDrawer, - chainIdsEnabled, -}: AvailableJobsTableView) { +export function AvailableJobsView({ chainIdsEnabled }: AvailableJobsTableView) { const isMobile = useIsMobile(); return isMobile ? ( - + ) : ( ); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx index e056ed10ce..fdefb14217 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx @@ -3,17 +3,13 @@ import { Button } from '@/shared/components/ui/button'; import { FiltersButtonIcon } from '@/shared/components/ui/icons'; import { useJobsFilterStore } from '../../../hooks'; import { EscrowAddressSearchForm } from '../../../components'; +import { useAvailableJobsFilterModal } from '../../hooks/use-available-jobs-filter-modal'; import { AvailableJobsListMobile } from './available-jobs-list-mobile'; -interface AvailableJobsTableMobileProps { - handleOpenMobileFilterDrawer: () => void; -} - -export function AvailableJobsTableMobile({ - handleOpenMobileFilterDrawer, -}: Readonly) { +export function AvailableJobsTableMobile() { const { t } = useTranslation(); const { setSearchEscrowAddress } = useJobsFilterStore(); + const { openModal } = useAvailableJobsFilterModal(); return ( <> @@ -26,7 +22,7 @@ export function AvailableJobsTableMobile({ /> - ) : null} + )} ); diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts deleted file mode 100644 index fe5ae32916..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { create } from 'zustand'; -import type { ReactNode } from 'react'; -import type { DialogProps as DialogMuiProps } from '@mui/material/Dialog'; - -export enum ModalType { - WALLET_CONNECT = 'WALLET_CONNECT', - EXPIRATION_MODAL = 'EXPIRATION_MODAL', -} -interface ModalState { - isModalOpen: boolean; - modalType: ModalType | undefined; - maxWidth?: DialogMuiProps['maxWidth']; - openModal: (args: { - modalType: ModalType; - additionalContent?: ReactNode; - maxWidth?: DialogMuiProps['maxWidth']; - displayCloseButton?: boolean; - }) => void; - closeModal: () => void; - additionalContent: ReactNode; - displayCloseButton?: boolean; -} - -export const useModalStore = create((set) => ({ - isModalOpen: false, - modalType: undefined, - additionalContent: undefined, - displayCloseButton: undefined, - maxWidth: undefined, - openModal: ({ - modalType, - additionalContent, - maxWidth, - displayCloseButton = true, - }) => { - set(() => ({ - isModalOpen: true, - modalType, - displayCloseButton, - maxWidth, - additionalContent, - })); - }, - closeModal: () => { - set(() => ({ - isModalOpen: false, - displayCloseButton: undefined, - modalType: undefined, - })); - }, -})); diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.tsx deleted file mode 100644 index 691b088552..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import DialogMui from '@mui/material/Dialog'; -import type { DialogProps as DialogMuiProps } from '@mui/material/Dialog'; -import { DialogContent } from '@mui/material'; - -interface ModalProps extends Omit { - isOpen: boolean; -} - -export function Modal({ - children, - isOpen, - maxWidth = 'xl', - ...rest -}: ModalProps) { - return ( - - {children} - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/contexts/generic-auth-context.tsx b/packages/apps/human-app/frontend/src/shared/contexts/generic-auth-context.tsx new file mode 100644 index 0000000000..9e72fccfba --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/contexts/generic-auth-context.tsx @@ -0,0 +1,138 @@ +import React, { createContext, useState, useEffect } from 'react'; +import { jwtDecode } from 'jwt-decode'; +import { type ZodType } from 'zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; +import { type AuthTokensSuccessResponse } from '@/shared/schemas'; +import { type UserData } from '@/modules/auth/context/auth-context'; +import { type Web3UserData } from '@/modules/auth-web3/context/web3-auth-context'; +import { type AuthType } from '../types/browser-auth-provider'; +import { useExpirationModal } from '../hooks'; + +export type AuthStatus = 'loading' | 'error' | 'success' | 'idle'; + +interface SignOutOptions { + throwExpirationModal?: boolean; + status?: AuthStatus; +} + +interface AuthContextType { + status: AuthStatus; + signOut: (options?: SignOutOptions) => void; + signIn: (signInSuccess: AuthTokensSuccessResponse) => void; + updateUserData: (update: Partial) => void; +} + +export interface AuthenticatedUserContextType extends AuthContextType { + user: T; +} + +export interface UnauthenticatedUserContextType extends AuthContextType { + user: null; +} + +export function createAuthProvider(config: { + authType: AuthType; + schema: ZodType; +}) { + const { authType, schema } = config; + const AuthContext = createContext< + AuthenticatedUserContextType | UnauthenticatedUserContextType | null + >(null); + + function AuthProvider({ children }: Readonly<{ children: React.ReactNode }>) { + const queryClient = useQueryClient(); + const [authState, setAuthState] = useState<{ + user: T | null; + status: AuthStatus; + }>({ + user: null, + status: 'loading', + }); + const { openModal } = useExpirationModal(); + + const displayExpirationModal = () => { + queryClient.setDefaultOptions({ queries: { enabled: false } }); + openModal(); + }; + + const handleSignIn = () => { + try { + const accessToken = browserAuthProvider.getAccessToken(); + const currentAuthType = browserAuthProvider.getAuthType(); + + if (!accessToken || currentAuthType !== authType) { + setAuthState({ user: null, status: 'idle' }); + return; + } + const userData = jwtDecode(accessToken); + + const validUserData = schema.parse(userData); + + setAuthState({ user: validUserData, status: 'success' }); + + browserAuthProvider.signOutSubscription = displayExpirationModal; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Invalid JWT payload:', e); + signOut({ + status: 'error', + }); + } + }; + + const signIn = (signInSuccess: AuthTokensSuccessResponse) => { + browserAuthProvider.signIn(signInSuccess, authType); + handleSignIn(); + }; + + const signOut = (options?: SignOutOptions) => { + const { throwExpirationModal = true, status = 'idle' } = options ?? {}; + + browserAuthProvider.signOut({ + triggerSignOutSubscriptions: throwExpirationModal, + }); + setAuthState({ user: null, status }); + }; + + const updateUserData = (update: Partial) => { + setAuthState((prev) => { + if (!prev.user) return prev; + const newUserData = { ...prev.user, ...update }; + browserAuthProvider.setUserData(newUserData); + return { ...prev, user: newUserData }; + }); + }; + + useEffect(() => { + handleSignIn(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); + } + + return { AuthContext, AuthProvider }; +} 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 new file mode 100644 index 0000000000..276c85be50 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx @@ -0,0 +1,77 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +interface ModalContextType { + open: boolean; + content: React.ReactNode; + showCloseButton: boolean; + openModal: ({ content, showCloseButton }: OpenModalProps) => void; + closeModal: () => void; + onTransitionExited: () => void; +} + +interface OpenModalProps { + content: React.ReactNode; + showCloseButton?: boolean; +} + +const ModalContext = createContext(undefined); + +export function ModalProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const [open, setOpen] = useState(false); + const [content, setContent] = useState(null); + const [showCloseButton, setShowCloseButton] = useState(true); + + const openModal = useCallback( + ({ + content: _modalContent, + showCloseButton: _showCloseButton, + }: OpenModalProps) => { + setContent(_modalContent); + setShowCloseButton(_showCloseButton ?? showCloseButton); + setOpen(true); + }, + [showCloseButton] + ); + + const closeModal = useCallback(() => { + setOpen(false); + }, []); + + const onTransitionExited = useCallback(() => { + setContent(null); + }, []); + + const contextValue = useMemo( + () => ({ + open, + content, + showCloseButton, + openModal, + closeModal, + onTransitionExited, + }), + [open, content, showCloseButton, openModal, closeModal, onTransitionExited] + ); + + return ( + + {children} + + ); +} + +export const useModal = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error('useModal must be used within a ModalProvider'); + } + return context; +}; diff --git a/packages/apps/human-app/frontend/src/shared/hooks/index.ts b/packages/apps/human-app/frontend/src/shared/hooks/index.ts index 2818199440..59707564b6 100644 --- a/packages/apps/human-app/frontend/src/shared/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/shared/hooks/index.ts @@ -6,3 +6,4 @@ export * from './use-notification'; export * from './use-is-mobile'; export * from './use-reset-mutation-errors'; export * from './use-web3-provider'; +export * from './use-expiration-modal'; diff --git a/packages/apps/human-app/frontend/src/shared/hooks/use-expiration-modal.tsx b/packages/apps/human-app/frontend/src/shared/hooks/use-expiration-modal.tsx new file mode 100644 index 0000000000..894e768797 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/hooks/use-expiration-modal.tsx @@ -0,0 +1,15 @@ +import { useModal } from '@/shared/contexts/modal-context'; +import { ExpirationModal } from '../components/expiration-modal'; + +export function useExpirationModal() { + const { openModal } = useModal(); + + return { + openModal: () => { + openModal({ + content: , + showCloseButton: false, + }); + }, + }; +} diff --git a/packages/apps/human-app/frontend/src/shared/hooks/use-handle-main-nav-icon-click.tsx b/packages/apps/human-app/frontend/src/shared/hooks/use-handle-main-nav-icon-click.tsx index 1e81c7d114..d3577d378d 100644 --- a/packages/apps/human-app/frontend/src/shared/hooks/use-handle-main-nav-icon-click.tsx +++ b/packages/apps/human-app/frontend/src/shared/hooks/use-handle-main-nav-icon-click.tsx @@ -1,25 +1,26 @@ import { useNavigate } from 'react-router-dom'; -import { useWeb3Auth } from '@/modules/auth-web3/hooks/use-web3-auth'; -import { useAuth } from '@/modules/auth/hooks/use-auth'; import { routerPaths } from '@/router/router-paths'; import { useHomePageState } from '../contexts/homepage-state/use-homepage-state'; +import { browserAuthProvider } from '../contexts/browser-auth-provider'; export const useHandleMainNavIconClick = () => { const navigate = useNavigate(); - const { user: web3User } = useWeb3Auth(); - const { user: web2Auth } = useAuth(); const { setPageView } = useHomePageState(); const handleIconClick = () => { - if (web3User) { + const type = browserAuthProvider.getAuthType(); + const isAuthenticated = browserAuthProvider.isAuthenticated; + + if (type === 'web3' && isAuthenticated) { navigate(routerPaths.operator.profile); return; } - if (web2Auth) { + if (type === 'web2' && isAuthenticated) { navigate(routerPaths.worker.profile); return; } + setPageView('welcome'); navigate(routerPaths.homePage); }; 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 d964f6ea18..469e3b32e8 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -1,5 +1,4 @@ export const DATABASE_SCHEMA_NAME = 'hmt'; -export const INITIAL_REPUTATION = 0; export const JWT_STRATEGY_NAME = 'jwt-http'; export const CVAT_RESULTS_ANNOTATIONS_FILENAME = 'resulting_annotations.zip'; diff --git a/packages/apps/reputation-oracle/server/src/common/enums/index.ts b/packages/apps/reputation-oracle/server/src/common/enums/index.ts index f2453fe149..f75da941d6 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/index.ts @@ -1,5 +1,4 @@ export * from './job'; -export * from './reputation'; export * from './webhook'; export * from './collection'; export * from './hcaptcha'; diff --git a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts index 27dd3b2222..35997fc93b 100644 --- a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts @@ -30,11 +30,11 @@ export class AuthConfigService { } /** - * The expiration time (in seconds) for refresh tokens. - * Default: 3600 + * The expiration time (in ms) for refresh tokens. + * Default: 3600000 */ get refreshTokenExpiresIn(): number { - return +this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', 3600); + return +this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', 3600) * 1000; } /** diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1744732021099-UniqueReputations.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1744732021099-UniqueReputations.ts new file mode 100644 index 0000000000..29dca04ffa --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1744732021099-UniqueReputations.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UniqueReputations1744732021099 implements MigrationInterface { + name = 'UniqueReputations1744732021099'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5012dff596f037415a1370a0cb" ON "hmt"."reputation" ("chain_id", "address", "type") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "hmt"."IDX_5012dff596f037415a1370a0cb"`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts index f47f4b0349..3e57cfff30 100644 --- a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.module.ts @@ -4,13 +4,12 @@ import { AbuseSlackBot } from './abuse.slack-bot'; import { AbuseRepository } from './abuse.repository'; import { AbuseService } from './abuse.service'; import { Web3Module } from '../web3/web3.module'; -import { ReputationModule } from '../reputation/reputation.module'; import { WebhookOutgoingModule } from '../webhook/webhook-outgoing.module'; import { AbuseController } from './abuse.controller'; import { AbuseSlackAuthGuard } from './abuse.slack-auth.guard'; @Module({ - imports: [HttpModule, Web3Module, ReputationModule, WebhookOutgoingModule], + imports: [HttpModule, Web3Module, WebhookOutgoingModule], providers: [ AbuseRepository, AbuseService, diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts index 141fcec665..2701768a86 100644 --- a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.spec.ts @@ -8,14 +8,13 @@ import { OperatorUtils, StakingClient, } from '@human-protocol/sdk'; -import { EscrowData } from '@human-protocol/sdk/dist/graphql'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { EventType, ReputationEntityType } from '../../common/enums'; + +import { EventType } from '../../common/enums'; import { PostgresErrorCodes } from '../../common/enums/database'; import { DatabaseError } from '../../common/errors/database'; import { ServerConfigService } from '../../config/server-config.service'; -import { ReputationService } from '../reputation/reputation.service'; import { generateTestnetChainId } from '../web3/fixtures'; import { Web3Service } from '../web3/web3.service'; import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; @@ -29,7 +28,6 @@ const fakeAddress = faker.finance.ethereumAddress(); const mockAbuseRepository = createMock(); const mockAbuseSlackBot = createMock(); -const mockReputationService = createMock(); const mockWeb3Service = createMock(); const mockWebhookOutgoingService = createMock(); @@ -60,10 +58,6 @@ describe('AbuseService', () => { useValue: mockWeb3Service, }, { provide: AbuseRepository, useValue: mockAbuseRepository }, - { - provide: ReputationService, - useValue: mockReputationService, - }, { provide: WebhookOutgoingService, useValue: mockWebhookOutgoingService, @@ -115,7 +109,7 @@ describe('AbuseService', () => { mockAbuseRepository.findOneById.mockResolvedValueOnce(abuseEntity); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ launcher: fakeAddress, - } as EscrowData); + } as any); const amount = faker.number.int(); mockedOperatorUtils.getOperator.mockResolvedValueOnce({ amountStaked: BigInt(amount), @@ -235,10 +229,10 @@ describe('AbuseService', () => { mockedEscrowUtils.getEscrow .mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData) + } as any) .mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator .mockResolvedValueOnce({ webhookUrl: webhookUrl1, @@ -269,7 +263,7 @@ describe('AbuseService', () => { mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator.mockResolvedValueOnce({ webhookUrl: webhookUrl1, } as IOperator); @@ -298,7 +292,7 @@ describe('AbuseService', () => { ); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator.mockResolvedValueOnce({ webhookUrl: webhookUrl1, } as IOperator); @@ -325,10 +319,10 @@ describe('AbuseService', () => { mockedEscrowUtils.getEscrow .mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData) + } as any) .mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator .mockResolvedValueOnce({ webhookUrl: webhookUrl1, @@ -397,7 +391,7 @@ describe('AbuseService', () => { } as unknown as StakingClient); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ launcher: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator.mockResolvedValueOnce({ webhookUrl: webhookUrl1, } as IOperator); @@ -441,20 +435,14 @@ describe('AbuseService', () => { ); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ exchangeOracle: fakeAddress, - } as EscrowData); + } as any); mockedOperatorUtils.getOperator.mockResolvedValueOnce({ webhookUrl: webhookUrl1, } as IOperator); - mockReputationService.decreaseReputation.mockResolvedValueOnce(undefined); await abuseService.processClassifiedAbuses(); expect(mockAbuseRepository.findClassified).toHaveBeenCalledTimes(1); - expect(mockReputationService.decreaseReputation).toHaveBeenCalledWith( - mockAbuseEntities[0].chainId, - mockAbuseEntities[0].user?.evmAddress, - ReputationEntityType.WORKER, - ); expect(mockAbuseRepository.updateOne).toHaveBeenCalledWith({ ...mockAbuseEntities[0], status: AbuseStatus.COMPLETED, diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts index 5dc9e16ab7..97721e9b46 100644 --- a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.service.ts @@ -6,11 +6,10 @@ import { } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { ethers } from 'ethers'; -import { EventType, ReputationEntityType } from '../../common/enums'; +import { EventType } from '../../common/enums'; import { isDuplicatedError } from '../../common/errors/database'; import { ServerConfigService } from '../../config/server-config.service'; import logger from '../../logger'; -import { ReputationService } from '../reputation/reputation.service'; import { Web3Service } from '../web3/web3.service'; import { WebhookOutgoingService } from '../webhook/webhook-outgoing.service'; import { AbuseEntity } from './abuse.entity'; @@ -35,7 +34,6 @@ export class AbuseService { private readonly abuseRepository: AbuseRepository, private readonly web3Service: Web3Service, private readonly serverConfigService: ServerConfigService, - private readonly reputationService: ReputationService, private readonly webhookOutgoingService: WebhookOutgoingService, ) {} @@ -256,11 +254,6 @@ export class AbuseService { } } } else { - await this.reputationService.decreaseReputation( - chainId, - abuseEntity.user?.evmAddress as string, - ReputationEntityType.WORKER, - ); const webhookPayload = { chainId: chainId, escrowAddress: escrowAddress, diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts index 07d989b415..cecaaf3c10 100644 --- a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.slack-bot.spec.ts @@ -1,10 +1,12 @@ +import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; import { HttpService } from '@nestjs/axios'; + +import { createHttpServiceMock } from '../../../test/mock-creators/nest'; import { SlackConfigService } from '../../config/slack-config.service'; + import { AbuseSlackBot } from './abuse.slack-bot'; -import { faker } from '@faker-js/faker'; import { AbuseDecision } from './constants'; -import { createHttpServiceMock } from '../../../test/mock-creators/nest'; const mockHttpService = createHttpServiceMock(); 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 9c2b8b23b0..db811119bd 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 @@ -40,7 +40,7 @@ const mockAuthConfigService = { jwtPrivateKey: privateKey, jwtPublicKey: publicKey, accessTokenExpiresIn: 600, - refreshTokenExpiresIn: 3600, + refreshTokenExpiresIn: 3600000, verifyEmailTokenExpiresIn: 86400000, forgotPasswordExpiresIn: 86400000, humanAppEmail: faker.internet.email(), diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts index 87a6140aa8..f3188347dd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts @@ -417,7 +417,7 @@ describe('escrowCompletionService', () => { }); describe('processPaidEscrowCompletion', () => { - let assessReputationScoresMock: jest.SpyInstance, + let assessEscrowPartiesMock: jest.SpyInstance, createOutgoingWebhookMock: jest.SpyInstance; let escrowCompletionEntity1: Partial, escrowCompletionEntity2: Partial; @@ -446,8 +446,8 @@ describe('escrowCompletionService', () => { retriesCount: 0, }; - assessReputationScoresMock = jest - .spyOn(reputationService, 'assessReputationScores') + assessEscrowPartiesMock = jest + .spyOn(reputationService, 'assessEscrowParties') .mockResolvedValue(); createOutgoingWebhookMock = jest @@ -474,7 +474,7 @@ describe('escrowCompletionService', () => { status: EscrowCompletionStatus.COMPLETED, }), ); - expect(assessReputationScoresMock).toHaveBeenCalledTimes(1); + expect(assessEscrowPartiesMock).toHaveBeenCalledTimes(1); }); it('should handle errors during entity processing without skipping remaining entities', async () => { @@ -495,7 +495,7 @@ describe('escrowCompletionService', () => { await escrowCompletionService.processPaidEscrowCompletion(); expect(updateOneMock).toHaveBeenCalledTimes(3); - expect(assessReputationScoresMock).toHaveBeenCalledTimes(2); + expect(assessEscrowPartiesMock).toHaveBeenCalledTimes(2); }); it('should mark the escrow completion as FAILED if retries exceed the threshold', async () => { @@ -510,7 +510,7 @@ describe('escrowCompletionService', () => { ); escrowCompletionEntity1.retriesCount = MOCK_MAX_RETRY_COUNT; - assessReputationScoresMock.mockRejectedValueOnce(error); + assessEscrowPartiesMock.mockRejectedValueOnce(error); await escrowCompletionService.processPaidEscrowCompletion(); @@ -570,7 +570,7 @@ describe('escrowCompletionService', () => { await escrowCompletionService.processPaidEscrowCompletion(); expect(updateOneMock).toHaveBeenCalledTimes(1); - expect(assessReputationScoresMock).toHaveBeenCalledTimes(0); + expect(assessEscrowPartiesMock).toHaveBeenCalledTimes(0); expect(createOutgoingWebhookMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts index 912d7e8a78..9d272879a9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts @@ -204,7 +204,7 @@ export class EscrowCompletionService { // TODO: Technically it's possible that the escrow completion could occur before the reputation scores are assessed, // and the app might go down during this window. Currently, there isn’t a clear approach to handle this situation. // Consider revisiting this section to explore potential solutions to improve resilience in such scenarios. - await this.reputationService.assessReputationScores( + await this.reputationService.assessEscrowParties( chainId, escrowAddress, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts index 88ca58730f..4365a245de 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts @@ -33,7 +33,7 @@ import { SaveResultDto, } from './payout.interface'; import * as httpUtils from '../../utils/http'; -import { getRequestType } from '../../utils/manifest'; +import { getJobRequestType } from '../../utils/manifest'; import { assertValidJobRequestType } from '../../utils/type-guards'; import { MissingManifestUrlError } from '../../common/errors/manifest'; @@ -68,7 +68,7 @@ export class PayoutService { const manifest = await this.storageService.downloadJsonLikeData(manifestUrl); - const requestType = getRequestType(manifest).toLowerCase(); + const requestType = getJobRequestType(manifest).toLowerCase(); assertValidJobRequestType(requestType); @@ -105,7 +105,7 @@ export class PayoutService { const manifest = await this.storageService.downloadJsonLikeData(manifestUrl); - const requestType = getRequestType(manifest).toLowerCase(); + const requestType = getJobRequestType(manifest).toLowerCase(); assertValidJobRequestType(requestType); diff --git a/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/constants.ts similarity index 67% rename from packages/apps/reputation-oracle/server/src/common/enums/reputation.ts rename to packages/apps/reputation-oracle/server/src/modules/reputation/constants.ts index 1c65e7ac88..5fb4bc69fc 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/constants.ts @@ -1,3 +1,9 @@ +export enum ReputationLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', +} + export enum ReputationEntityType { WORKER = 'worker', JOB_LAUNCHER = 'job_launcher', @@ -6,13 +12,11 @@ export enum ReputationEntityType { REPUTATION_ORACLE = 'reputation_oracle', } -export enum ReputationLevel { - LOW = 'low', - MEDIUM = 'medium', - HIGH = 'high', -} - export enum ReputationOrderBy { - CREATED_AT = 'created_at', - REPUTATION_POINTS = 'reputation_points', + CREATED_AT = 'createdAt', + REPUTATION_POINTS = 'reputationPoints', } + +export const MAX_REPUTATION_ITEMS_PER_PAGE = 100; + +export const INITIAL_REPUTATION = 0; diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts new file mode 100644 index 0000000000..c5c6a40622 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts @@ -0,0 +1,27 @@ +import { faker } from '@faker-js/faker'; + +import { generateTestnetChainId } from '../web3/fixtures'; + +import { ReputationEntityType } from './constants'; +import { ReputationEntity } from './reputation.entity'; + +const REPUTATION_ENTITY_TYPES = Object.values(ReputationEntityType); +export function generateReputationEntityType(): ReputationEntityType { + return faker.helpers.arrayElement(REPUTATION_ENTITY_TYPES); +} + +export function generateRandomScorePoints(): number { + return faker.number.int({ min: 1, max: 42 }); +} + +export function generateReputationEntity(score?: number): ReputationEntity { + return { + id: faker.number.int(), + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + reputationPoints: score || generateRandomScorePoints(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.controller.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.controller.ts index 1238d19588..ab488aafa9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.controller.ts @@ -1,95 +1,73 @@ -import { Controller, Get, Param, Query, UseFilters } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiQuery, - ApiParam, -} from '@nestjs/swagger'; +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + import { Public } from '../../common/decorators'; -import { ReputationService } from './reputation.service'; + +import { MAX_REPUTATION_ITEMS_PER_PAGE, ReputationOrderBy } from './constants'; import { - ReputationDto, - ReputationGetAllQueryDto, - ReputationGetParamsDto, - ReputationGetQueryDto, + GetReputationQueryOrderBy, + GetReputationsQueryDto, + ReputationResponseDto, } from './reputation.dto'; -import { ReputationErrorFilter } from './reputation.error.filter'; +import { ReputationService } from './reputation.service'; +import { SortDirection } from 'src/common/enums'; + +function mapReputationOrderBy( + queryOrderBy: GetReputationQueryOrderBy, +): ReputationOrderBy { + const orderByMap = { + [GetReputationQueryOrderBy.CREATED_AT]: ReputationOrderBy.CREATED_AT, + [GetReputationQueryOrderBy.REPUTATION_POINTS]: + ReputationOrderBy.REPUTATION_POINTS, + }; + + return orderByMap[queryOrderBy]; +} @Public() @ApiTags('Reputation') @Controller('reputation') -@UseFilters(ReputationErrorFilter) export class ReputationController { constructor(private readonly reputationService: ReputationService) {} @ApiOperation({ - summary: 'Get All Reputations', - description: 'Endpoint to get all reputations.', + summary: 'Get Reputations', + description: 'Endpoint to get reputations', }) @ApiResponse({ status: 200, description: 'Reputations retrieved successfully', - type: ReputationDto, + type: ReputationResponseDto, isArray: true, }) @Get() async getReputations( - @Query() query: ReputationGetAllQueryDto, - ): Promise { - const { chainId, roles, orderBy, orderDirection, first, skip } = query; - const reputations = await this.reputationService.getReputations( + @Query() query: GetReputationsQueryDto, + ): Promise { + const { chainId, + address, roles, - orderBy, - orderDirection, - first, + orderBy = GetReputationQueryOrderBy.CREATED_AT, + orderDirection = SortDirection.DESC, + first = MAX_REPUTATION_ITEMS_PER_PAGE, skip, - ); - return reputations; - } - - @ApiOperation({ - summary: 'Get reputation by address', - description: 'Endpoint to get reputation by address', - }) - @ApiParam({ - name: 'address', - description: 'Address for the reputation query', - type: String, - required: true, - }) - @ApiQuery({ - name: 'chain_id', - description: 'Chain ID for filtering the reputation', - type: Number, - required: true, - }) - @ApiResponse({ - status: 200, - description: 'Reputation retrieved successfully', - type: ReputationDto, - }) - @ApiResponse({ - status: 404, - description: 'Not Found. Could not find the requested content', - }) - /** - * TODO: Refactor its usages to be part of getAll endpoint - * where you pass single address and delete this route - */ - @Get('/:address') - async getReputation( - @Param() params: ReputationGetParamsDto, - @Query() query: ReputationGetQueryDto, - ): Promise { - const { chainId } = query; - const { address } = params; + } = query; - const reputation = await this.reputationService.getReputation( - chainId, - address, + const reputations = await this.reputationService.getReputations( + { + chainId, + address, + types: roles, + }, + { + orderBy: mapReputationOrderBy(orderBy), + orderDirection, + first, + skip, + }, ); - return reputation; + + return reputations; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts index a46a59e3f4..d78e3b2697 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts @@ -1,53 +1,35 @@ import { ChainId } from '@human-protocol/sdk'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsEthereumAddress, - IsNumber, - IsOptional, - IsString, - Min, -} from 'class-validator'; import { Transform } from 'class-transformer'; +import { IsEthereumAddress, IsOptional, Max, Min } from 'class-validator'; + +import { SortDirection } from '../../common/enums'; +import { IsChainId, IsLowercasedEnum } from '../../common/validators'; + import { + MAX_REPUTATION_ITEMS_PER_PAGE, ReputationEntityType, ReputationLevel, - ReputationOrderBy, - SortDirection, -} from '../../common/enums'; -import { IsChainId, IsLowercasedEnum } from '../../common/validators'; - -export class ReputationCreateDto { - @ApiProperty({ name: 'chain_id' }) - @IsChainId() - public chainId: ChainId; +} from './constants'; - @ApiProperty() - @IsString() - public address: string; - - @ApiProperty({ name: 'reputation_points' }) - @IsNumber() - public reputationPoints: number; - - @ApiProperty() - @IsLowercasedEnum(ReputationEntityType) - public type: ReputationEntityType; +export enum GetReputationQueryOrderBy { + CREATED_AT = 'created_at', + REPUTATION_POINTS = 'reputation_points', } -export class ReputationUpdateDto { - @ApiProperty({ name: 'reputation_points' }) - @IsNumber() - public reputationPoints: number; -} - -export class ReputationGetAllQueryDto { +export class GetReputationsQueryDto { @ApiPropertyOptional({ enum: ChainId, name: 'chain_id', }) @IsChainId() @IsOptional() - public chainId?: ChainId; + chainId?: ChainId; + + @ApiPropertyOptional() + @IsEthereumAddress() + @IsOptional() + address?: string; @ApiPropertyOptional({ type: [ReputationEntityType], @@ -55,70 +37,61 @@ export class ReputationGetAllQueryDto { name: 'roles', }) /** - * NOTE: Order here matters + * NOTE: Order of decorators here matters * - * Query param is string if single value and array if multiple + * Query param is parsed as string if single value passed + * and as array if multiple */ @Transform(({ value }) => (Array.isArray(value) ? value : [value])) @IsLowercasedEnum(ReputationEntityType, { each: true }) @IsOptional() - public roles?: ReputationEntityType[]; + roles?: ReputationEntityType[]; @ApiPropertyOptional({ - enum: ReputationOrderBy, - default: ReputationOrderBy.CREATED_AT, + name: 'order_by', + enum: GetReputationQueryOrderBy, + default: GetReputationQueryOrderBy.CREATED_AT, }) - @IsLowercasedEnum(ReputationOrderBy) + @IsLowercasedEnum(GetReputationQueryOrderBy) @IsOptional() - public orderBy?: ReputationOrderBy; + orderBy?: GetReputationQueryOrderBy; @ApiPropertyOptional({ + name: 'order_direction', enum: SortDirection, default: SortDirection.DESC, }) @IsLowercasedEnum(SortDirection) @IsOptional() - public orderDirection?: SortDirection; + orderDirection?: SortDirection; - @ApiPropertyOptional({ type: Number }) + @ApiPropertyOptional({ + type: Number, + default: MAX_REPUTATION_ITEMS_PER_PAGE, + }) @IsOptional() @Min(1) + @Max(MAX_REPUTATION_ITEMS_PER_PAGE) @Transform(({ value }) => Number(value)) - public first?: number; + first?: number; @ApiPropertyOptional({ type: Number }) @IsOptional() @Min(0) @Transform(({ value }) => Number(value)) - public skip?: number; -} - -export class ReputationGetParamsDto { - @ApiProperty() - @IsEthereumAddress() - public address: string; + skip?: number; } -export class ReputationGetQueryDto { +export class ReputationResponseDto { @ApiProperty({ enum: ChainId, name: 'chain_id' }) - @IsChainId() - public chainId: ChainId; -} - -export class ReputationDto { - @ApiProperty({ enum: ChainId, name: 'chain_id' }) - @IsChainId() chainId: ChainId; @ApiProperty() - @IsEthereumAddress() address: string; @ApiProperty({ enum: ReputationLevel }) - @IsLowercasedEnum(ReputationLevel) - reputation: ReputationLevel; + level: ReputationLevel; @ApiProperty({ enum: ReputationEntityType }) - @IsLowercasedEnum(ReputationEntityType) role: ReputationEntityType; } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.entity.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.entity.ts index bf36b93e97..85073f2225 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.entity.ts @@ -1,23 +1,25 @@ -import { Column, Entity } from 'typeorm'; +import { Column, Entity, Index } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -import { ReputationEntityType } from '../../common/enums'; + +import { ReputationEntityType } from './constants'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'reputation' }) +@Index(['chainId', 'address', 'type'], { unique: true }) export class ReputationEntity extends BaseEntity { @Column({ type: 'int' }) - public chainId: number; + chainId: number; @Column({ type: 'varchar' }) - public address: string; - - @Column({ type: 'int' }) - public reputationPoints: number; + address: string; @Column({ type: 'enum', enum: ReputationEntityType, }) - public type: ReputationEntityType; + type: ReputationEntityType; + + @Column({ type: 'int' }) + reputationPoints: number; } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.filter.ts deleted file mode 100644 index a568c2b9fa..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.filter.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpStatus, -} from '@nestjs/common'; -import { Request, Response } from 'express'; - -import { ReputationError, ReputationErrorMessage } from './reputation.error'; -import logger from '../../logger'; - -@Catch(ReputationError) -export class ReputationErrorFilter implements ExceptionFilter { - private readonly logger = logger.child({ - context: ReputationErrorFilter.name, - }); - - catch(exception: ReputationError, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - let status = HttpStatus.BAD_REQUEST; - - if (exception.message === ReputationErrorMessage.NOT_FOUND) { - status = HttpStatus.NOT_FOUND; - } - - this.logger.error('Reputation error', exception); - - 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/reputation/reputation.error.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.ts deleted file mode 100644 index 1db32a4695..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ChainId } from '@human-protocol/sdk'; -import { BaseError } from '../../common/errors/base'; - -export enum ReputationErrorMessage { - NOT_FOUND = 'Reputation not found', -} - -export class ReputationError extends BaseError { - chainId: ChainId; - address: string; - constructor( - message: ReputationErrorMessage, - chainId: ChainId, - address: string, - ) { - super(message); - this.chainId = chainId; - this.address = address; - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts deleted file mode 100644 index 9d17d72f73..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ChainId } from '@human-protocol/sdk'; -import { - AudinoManifest, - CvatManifest, - FortuneManifest, -} from '../../common/interfaces/manifest'; - -export interface RequestAction { - assessWorkerReputationScores: ( - chainId: ChainId, - escrowAddress: string, - manifest?: FortuneManifest | CvatManifest | AudinoManifest, - ) => Promise; -} diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.module.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.module.ts index 8252728d36..0149f84788 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.module.ts @@ -1,7 +1,6 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { StorageModule } from '../storage/storage.module'; import { Web3Module } from '../web3/web3.module'; import { ReputationService } from './reputation.service'; @@ -9,7 +8,7 @@ import { ReputationRepository } from './reputation.repository'; import { ReputationController } from './reputation.controller'; @Module({ - imports: [HttpModule, StorageModule, Web3Module], + imports: [HttpModule, Web3Module], controllers: [ReputationController], providers: [ReputationService, ReputationRepository], exports: [ReputationService], diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.repository.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.repository.ts index 34c0dc8cf9..ed7310ef61 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.repository.ts @@ -1,74 +1,71 @@ import { ChainId } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; -import { DataSource, ILike, In } from 'typeorm'; +import { DataSource, FindManyOptions, In } from 'typeorm'; + +import { SortDirection } from '../../common/enums'; import { BaseRepository } from '../../database/base.repository'; + +import { ReputationEntityType, ReputationOrderBy } from './constants'; import { ReputationEntity } from './reputation.entity'; -import { - ReputationEntityType, - ReputationOrderBy, - SortDirection, -} from '../../common/enums'; + +export type ExclusiveReputationCriteria = { + chainId: number; + address: string; + type: ReputationEntityType; +}; @Injectable() export class ReputationRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { super(ReputationEntity, dataSource); } - public findOneByAddress(address: string): Promise { + findExclusive({ + chainId, + address, + type, + }: ExclusiveReputationCriteria): Promise { return this.findOne({ - where: { address }, - }); - } - - public findOneByAddressAndChainId( - address: string, - chainId: ChainId, - ): Promise { - return this.findOne({ - where: { address: ILike(address), chainId }, - }); - } - - public findByChainId(chainId?: ChainId): Promise { - return this.find({ - where: chainId && { chainId }, - order: { - createdAt: SortDirection.DESC, + where: { + chainId, + address, + type, }, }); } - public findByChainIdAndTypes( - chainId?: ChainId, - types?: ReputationEntityType[], - orderBy?: ReputationOrderBy, - orderDirection?: SortDirection, - first?: number, - skip?: number, + findPaginated( + filters: { + address?: string; + chainId?: ChainId; + types?: ReputationEntityType[]; + }, + options?: { + orderBy?: ReputationOrderBy; + orderDirection?: SortDirection; + first?: number; + skip?: number; + }, ): Promise { - const mapOrderBy = ReputationRepository.mapOrderBy( - orderBy || ReputationOrderBy.CREATED_AT, - ); + const query: FindManyOptions['where'] = {}; + if (filters.chainId) { + query.chainId = filters.chainId; + } + if (filters.types) { + query.type = In(filters.types); + } + if (filters.address) { + query.address = filters.address; + } return this.find({ - where: { - ...(chainId && { chainId }), - ...(types && types.length > 0 && { type: In(types) }), - }, + where: query, order: { - [mapOrderBy]: orderDirection || SortDirection.DESC, + [options?.orderBy || ReputationOrderBy.CREATED_AT]: + options?.orderDirection || SortDirection.ASC, }, - ...(skip && { skip }), - ...(first && { take: first }), + take: options?.first || 10, + skip: options?.skip, }); } - - private static mapOrderBy(orderBy: ReputationOrderBy): string { - const orderByMap = { - [ReputationOrderBy.CREATED_AT]: 'createdAt', - [ReputationOrderBy.REPUTATION_POINTS]: 'reputationPoints', - }; - return orderByMap[orderBy] || orderByMap[ReputationOrderBy.CREATED_AT]; - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts index 68f3c5b04a..5acbf3da73 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.spec.ts @@ -1,505 +1,396 @@ +jest.mock('@human-protocol/sdk'); + import { createMock } from '@golevelup/ts-jest'; -import { ChainId, EscrowClient } from '@human-protocol/sdk'; -import { ConfigService } from '@nestjs/config'; +import { faker } from '@faker-js/faker'; +import { EscrowClient } from '@human-protocol/sdk'; import { Test } from '@nestjs/testing'; -import { ReputationService } from './reputation.service'; -import { ReputationRepository } from './reputation.repository'; -import { ReputationEntity } from './reputation.entity'; -import { - MOCK_ADDRESS, - MOCK_FILE_URL, - mockConfig, -} from '../../../test/constants'; -import { - JobRequestType, - ReputationEntityType, - ReputationLevel, - SolutionError, -} from '../../common/enums'; -import { Web3Service } from '../web3/web3.service'; -import { StorageService } from '../storage/storage.service'; + import { ReputationConfigService } from '../../config/reputation-config.service'; -import { ReputationError, ReputationErrorMessage } from './reputation.error'; import { Web3ConfigService } from '../../config/web3-config.service'; -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - EscrowClient: { - build: jest.fn().mockImplementation(() => ({ - getJobLauncherAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getExchangeOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getResultsUrl: jest.fn().mockResolvedValue(MOCK_FILE_URL), - getManifestUrl: jest.fn().mockResolvedValue(MOCK_FILE_URL), - })), - }, -})); +import { + generateTestnetChainId, + mockWeb3ConfigService, +} from '../web3/fixtures'; +import { Web3Service } from '../web3/web3.service'; -describe('ReputationService', () => { - let reputationService: ReputationService, - reputationRepository: ReputationRepository, - storageService: StorageService; +import { + generateRandomScorePoints, + generateReputationEntity, + generateReputationEntityType, +} from './fixtures'; +import { ReputationService } from './reputation.service'; +import { ReputationRepository } from './reputation.repository'; +import { ReputationEntityType } from './constants'; - const signerMock = { - address: MOCK_ADDRESS, - getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }), - }; +const mockReputationRepository = createMock(); - beforeEach(async () => { +const LOW_REPUTATION_SCORE = faker.number.int({ min: 100, max: 300 }); +const HIGH_REPUTATION_SCORE = faker.number.int({ + min: LOW_REPUTATION_SCORE * 2, + max: LOW_REPUTATION_SCORE * 3, +}); +const mockReputationConfigService: Omit< + ReputationConfigService, + 'configService' +> = { + lowLevel: LOW_REPUTATION_SCORE, + highLevel: HIGH_REPUTATION_SCORE, +}; + +const mockedEscrowClient = jest.mocked(EscrowClient); + +describe('ReputationService', () => { + let service: ReputationService; + + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ + ReputationService, { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => mockConfig[key]), - getOrThrow: jest.fn((key: string) => { - if (!mockConfig[key]) { - throw new Error(`Configuration key "${key}" does not exist`); - } - return mockConfig[key]; - }), - }, + provide: ReputationConfigService, + useValue: mockReputationConfigService, }, - Web3ConfigService, { - provide: Web3Service, - useValue: { - getSigner: jest.fn().mockReturnValue(signerMock), - }, + provide: ReputationRepository, + useValue: mockReputationRepository, }, - { provide: StorageService, useValue: createMock() }, - ReputationService, { - provide: ReputationRepository, - useValue: createMock(), + provide: Web3ConfigService, + useValue: mockWeb3ConfigService, }, - ReputationConfigService, + Web3Service, ], }).compile(); - reputationService = moduleRef.get(ReputationService); - reputationRepository = moduleRef.get(ReputationRepository); - storageService = moduleRef.get(StorageService); + service = moduleRef.get(ReputationService); }); - describe('assessReputationScores', () => { - const chainId = ChainId.LOCALHOST; - const escrowAddress = 'mockEscrowAddress'; - - describe('fortune', () => { - it('should assess reputation scores', async () => { - const manifest = { - requestType: JobRequestType.FORTUNE, - }; - const finalResults = [ - { workerAddress: 'worker1', error: undefined }, - { workerAddress: 'worker2', error: SolutionError.Duplicated }, - ]; + afterEach(() => { + jest.resetAllMocks(); + }); - jest - .spyOn(storageService, 'downloadJsonLikeData') - .mockResolvedValueOnce(manifest) - .mockResolvedValueOnce(finalResults); + describe('getReputations', () => { + it('should return reputations data with proper score level', async () => { + const withLowScore = generateReputationEntity(LOW_REPUTATION_SCORE); + const withMediumScore = generateReputationEntity( + faker.number.int({ + min: LOW_REPUTATION_SCORE + 1, + max: HIGH_REPUTATION_SCORE - 1, + }), + ); + const withHighScore = generateReputationEntity(HIGH_REPUTATION_SCORE); + + mockReputationRepository.findPaginated.mockResolvedValueOnce([ + withLowScore, + withMediumScore, + withHighScore, + ]); + + const reputations = await service.getReputations({}); + expect(reputations[0]).toEqual({ + chainId: withLowScore.chainId, + address: withLowScore.address, + role: withLowScore.type, + level: 'low', + }); + expect(reputations[1]).toEqual({ + chainId: withMediumScore.chainId, + address: withMediumScore.address, + role: withMediumScore.type, + level: 'medium', + }); + expect(reputations[2]).toEqual({ + chainId: withHighScore.chainId, + address: withHighScore.address, + role: withHighScore.type, + level: 'high', + }); + }); + }); - jest.spyOn(reputationService, 'increaseReputation').mockResolvedValue(); - jest.spyOn(reputationService, 'decreaseReputation').mockResolvedValue(); + describe('reputation adjustments', () => { + beforeEach(() => { + /** + * Shallow copy is necessary here in order to + * properly compare mock call arguments, + * because jest hold a reference on object + */ + mockReputationRepository.createUnique.mockImplementationOnce( + async (entity) => ({ ...entity }), + ); + }); - await reputationService.assessReputationScores(chainId, escrowAddress); + describe('increaseReputation', () => { + it.each([faker.number.float(), faker.number.int() * -1])( + 'throws for invalid score points [%#]', + async (score) => { + await expect( + service.increaseReputation( + { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }, + score, + ), + ).rejects.toThrow( + 'Adjustable reputation points must be positive integer', + ); + }, + ); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.JOB_LAUNCHER, - ); + it('creates entity if not exists and increases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - 'worker1', - ReputationEntityType.WORKER, - ); + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }; + const score = generateRandomScorePoints(); - expect(reputationService.decreaseReputation).toHaveBeenCalledWith( - chainId, - 'worker2', - ReputationEntityType.WORKER, - ); + await service.increaseReputation(criteria, score); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.EXCHANGE_ORACLE, + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), ); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.RECORDING_ORACLE, + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: score, + }), ); }); - }); - - describe('cvat', () => { - const manifest = { - requestType: JobRequestType.IMAGE_BOXES, - data: { - data_url: MOCK_FILE_URL, - }, - annotation: { - labels: [{ name: 'cat' }, { name: 'dog' }], - description: 'Description', - type: JobRequestType.IMAGE_BOXES, - job_size: 10, - max_time: 10, - }, - validation: { - min_quality: 0.95, - val_size: 10, - gt_url: MOCK_FILE_URL, - }, - job_bounty: '10', - }; - - (EscrowClient.build as any).mockImplementation(() => ({ - getJobLauncherAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getExchangeOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getResultsUrl: jest.fn().mockResolvedValue(MOCK_FILE_URL), - getIntermediateResultsUrl: jest.fn().mockResolvedValue(MOCK_FILE_URL), - getManifestUrl: jest.fn().mockResolvedValue(MOCK_ADDRESS), - })); - - it('should assess reputation scores', async () => { - const annotationMeta = { - jobs: [ - { - id: 1, - job_id: 1, - annotator_wallet_address: 'worker1', - annotation_quality: 0.96, - }, - { - id: 2, - job_id: 2, - annotator_wallet_address: 'worker2', - annotation_quality: 0.94, - }, - ], - results: [ - { - id: 1, - job_id: 1, - annotator_wallet_address: 'worker1', - annotation_quality: 0.96, - }, - { - id: 2, - job_id: 2, - annotator_wallet_address: 'worker2', - annotation_quality: 0.94, - }, - ], - }; - - jest - .spyOn(storageService, 'downloadJsonLikeData') - .mockResolvedValueOnce(manifest) - .mockResolvedValueOnce(annotationMeta); - - jest.spyOn(reputationService, 'increaseReputation').mockResolvedValue(); - jest.spyOn(reputationService, 'decreaseReputation').mockResolvedValue(); - await reputationService.assessReputationScores(chainId, escrowAddress); + it('creates entity if not exists and increases reputation for current reputation oracle', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.JOB_LAUNCHER, - ); + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }; + const score = generateRandomScorePoints(); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - 'worker1', - ReputationEntityType.WORKER, - ); + await service.increaseReputation(criteria, score); - expect(reputationService.decreaseReputation).toHaveBeenCalledWith( - chainId, - 'worker2', - ReputationEntityType.WORKER, + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: mockReputationConfigService.highLevel, + }), ); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.EXCHANGE_ORACLE, + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: score + mockReputationConfigService.highLevel, + }), ); + }); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.RECORDING_ORACLE, + it('increases reputation if entity already exists', async () => { + const reputationEntity = generateReputationEntity(); + mockReputationRepository.findExclusive.mockResolvedValueOnce( + reputationEntity, ); - }); - }); - }); - describe('increaseReputation', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - const type = ReputationEntityType.WORKER; + const criteria = { + chainId: reputationEntity.chainId, + address: reputationEntity.address, + type: reputationEntity.type, + }; + const score = generateRandomScorePoints(); + const initialEntityScore = reputationEntity.reputationPoints; - it('should create a new reputation entity if not found', async () => { - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(undefined as any); - jest.spyOn(reputationRepository, 'createUnique'); + await service.increaseReputation(criteria, score); - await reputationService.increaseReputation(chainId, address, type); + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationRepository.createUnique).toHaveBeenCalledWith({ - chainId, - address, - reputationPoints: 1, - type, + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: initialEntityScore + score, + }), + ); }); }); - it('should create a new reputation entity with Reputation Oracle type if not found', async () => { - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(undefined as any); - jest.spyOn(reputationRepository, 'createUnique'); - - await reputationService.increaseReputation( - chainId, - address, - ReputationEntityType.REPUTATION_ORACLE, - ); - - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, + describe('decreaseReputation', () => { + it.each([faker.number.float(), faker.number.int() * -1])( + 'throws for invalid score points [%#]', + async (score) => { + await expect( + service.decreaseReputation( + { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }, + score, + ), + ).rejects.toThrow( + 'Adjustable reputation points must be positive integer', + ); + }, ); - expect(reputationRepository.createUnique).toHaveBeenCalledWith({ - chainId, - address, - reputationPoints: 700, - type: ReputationEntityType.REPUTATION_ORACLE, - }); - }); - it('should increase reputation points if entity found', async () => { - const reputationEntity: Partial = { - address, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, - }; + it('creates entity if not exists and decreases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); + mockReputationRepository.createUnique.mockImplementationOnce( + async (entity) => ({ ...entity }), + ); - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }; + const score = generateRandomScorePoints(); - await reputationService.increaseReputation(chainId, address, type); + await service.decreaseReputation(criteria, score); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationEntity.reputationPoints).toBe(2); - expect(reputationRepository.updateOne).toHaveBeenCalled(); - }); - }); + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), + ); - describe('decreaseReputation', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - const type = ReputationEntityType.WORKER; + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: -score, + }), + ); + }); - it('should create a new reputation entity if not found', async () => { - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(undefined as any); - jest.spyOn(reputationRepository, 'createUnique'); + it('decreases reputation if entity already exists', async () => { + const reputationEntity = generateReputationEntity(); + mockReputationRepository.findExclusive.mockResolvedValueOnce( + reputationEntity, + ); - await reputationService.decreaseReputation(chainId, address, type); + const criteria = { + chainId: reputationEntity.chainId, + address: reputationEntity.address, + type: reputationEntity.type, + }; + const score = generateRandomScorePoints(); + const initialEntityScore = reputationEntity.reputationPoints; - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationRepository.createUnique).toHaveBeenCalledWith({ - chainId, - address, - reputationPoints: 0, - type, - }); - }); + await service.decreaseReputation(criteria, score); - it('should decrease reputation points if entity found', async () => { - const reputationEntity: Partial = { - address, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, - }; + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: initialEntityScore - score, + }), + ); + }); - await reputationService.decreaseReputation(chainId, address, type); + it('should not decrease reputation for current reputation oracle', async () => { + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }; + const score = generateRandomScorePoints(); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationEntity.reputationPoints).toBe(0); - expect(reputationRepository.updateOne).toHaveBeenCalled(); - }); + await service.decreaseReputation(criteria, score); - it('should return if called for Reputation Oracle itself', async () => { - const reputationEntity: Partial = { - address, - reputationPoints: 701, - type: ReputationEntityType.RECORDING_ORACLE, - }; - - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); - - await reputationService.decreaseReputation( - chainId, - address, - ReputationEntityType.REPUTATION_ORACLE, - ); + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationEntity.reputationPoints).toBe(701); - expect(reputationRepository.updateOne).toHaveBeenCalledTimes(0); + expect(mockReputationRepository.decrement).not.toHaveBeenCalled(); + }); }); }); - describe('getReputationLevel', () => { - it('should return LOW if reputation points are less than 300', () => { - expect(reputationService.getReputationLevel(299)).toBe( - ReputationLevel.LOW, - ); - }); - it('should return MEDIUM if reputation points are less than 700', () => { - expect(reputationService.getReputationLevel(699)).toBe( - ReputationLevel.MEDIUM, - ); - }); + describe('assessEscrowParties', () => { + let spyOnIncreaseReputation: jest.SpyInstance; - it('should return HIGH if reputation points are greater than 700', () => { - expect(reputationService.getReputationLevel(701)).toBe( - ReputationLevel.HIGH, - ); + beforeAll(() => { + spyOnIncreaseReputation = jest.spyOn(service, 'increaseReputation'); }); - }); - - describe('getReputation', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - - it('should return HIGH reputation for Reputation Oracle Address', async () => { - const reputationEntity: Partial = { - chainId, - address, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, - }; - - jest - .spyOn(reputationRepository, 'findOneByAddressAndChainId') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); - - const result = await reputationService.getReputation(chainId, address); - - const resultReputation = { - chainId, - address, - reputation: ReputationLevel.HIGH, - role: ReputationEntityType.REPUTATION_ORACLE, - }; - expect(result).toEqual(resultReputation); + beforeEach(() => { + spyOnIncreaseReputation.mockImplementation(); }); - it('should return reputation entity', async () => { - const NOT_ORACLE_ADDRESS = '0x0000000000000000000000000000000000000000'; - const reputationEntity: Partial = { - chainId, - address: NOT_ORACLE_ADDRESS, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, - }; - - jest - .spyOn(reputationRepository, 'findOneByAddressAndChainId') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); - - const result = await reputationService.getReputation( - chainId, - NOT_ORACLE_ADDRESS, - ); - - const resultReputation = { - chainId, - address: NOT_ORACLE_ADDRESS, - reputation: ReputationLevel.LOW, - role: ReputationEntityType.RECORDING_ORACLE, - }; - - expect( - reputationRepository.findOneByAddressAndChainId, - ).toHaveBeenCalledWith(NOT_ORACLE_ADDRESS, chainId); - - expect(result).toEqual(resultReputation); + afterAll(() => { + spyOnIncreaseReputation.mockRestore(); }); - it('should handle reputation not found', async () => { - const NOT_ORACLE_ADDRESS = '0x0000000000000000000000000000000000000000'; - jest - .spyOn(reputationRepository, 'findOneByAddressAndChainId') - .mockResolvedValueOnce(null); - - await expect( - reputationService.getReputation(chainId, NOT_ORACLE_ADDRESS), - ).rejects.toThrow( - new ReputationError(ReputationErrorMessage.NOT_FOUND, chainId, address), + it('should increase reputation for escrow oracles by one', async () => { + const jobLauncherAddress = faker.finance.ethereumAddress(); + const exchangeOracleAddress = faker.finance.ethereumAddress(); + const recordingOracleAddress = faker.finance.ethereumAddress(); + + mockedEscrowClient.build.mockResolvedValueOnce({ + getExchangeOracleAddress: jest + .fn() + .mockResolvedValueOnce(exchangeOracleAddress), + getJobLauncherAddress: jest + .fn() + .mockResolvedValueOnce(jobLauncherAddress), + getRecordingOracleAddress: jest + .fn() + .mockResolvedValueOnce(recordingOracleAddress), + } as unknown as EscrowClient); + + const chainId = generateTestnetChainId(); + const escrowAddress = faker.finance.ethereumAddress(); + + await service.assessEscrowParties(chainId, escrowAddress); + + expect(spyOnIncreaseReputation).toHaveBeenCalledTimes(4); + expect(spyOnIncreaseReputation).toHaveBeenCalledWith( + { + chainId, + address: jobLauncherAddress, + type: ReputationEntityType.JOB_LAUNCHER, + }, + 1, + ); + expect(spyOnIncreaseReputation).toHaveBeenCalledWith( + { + chainId, + address: exchangeOracleAddress, + type: ReputationEntityType.EXCHANGE_ORACLE, + }, + 1, + ); + expect(spyOnIncreaseReputation).toHaveBeenCalledWith( + { + chainId, + address: recordingOracleAddress, + type: ReputationEntityType.RECORDING_ORACLE, + }, + 1, + ); + expect(spyOnIncreaseReputation).toHaveBeenCalledWith( + { + chainId, + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }, + 1, ); - }); - }); - - describe('getReputations', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - - it('should return all reputations', async () => { - const reputationEntity: Partial = { - chainId, - address, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, - }; - - jest - .spyOn(reputationRepository, 'findByChainIdAndTypes') - .mockResolvedValueOnce([reputationEntity as ReputationEntity]); - - const result = await reputationService.getReputations(); - - const resultReputation = { - chainId, - address, - reputation: ReputationLevel.LOW, - role: ReputationEntityType.RECORDING_ORACLE, - }; - - expect(reputationRepository.findByChainIdAndTypes).toHaveBeenCalled(); - expect(result).toEqual([resultReputation]); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts index 008110bdba..f40c5cf111 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts @@ -1,46 +1,43 @@ import { Injectable } from '@nestjs/common'; -import { ChainId } from '@human-protocol/sdk'; +import { ChainId, EscrowClient } from '@human-protocol/sdk'; + +import { SortDirection } from '../../common/enums'; +import { isDuplicatedError } from '../../common/errors/database'; +import { ReputationConfigService } from '../../config/reputation-config.service'; +import { Web3ConfigService } from '../../config/web3-config.service'; + +import { Web3Service } from '../web3/web3.service'; + import { - AUDINO_VALIDATION_META_FILENAME, - CVAT_VALIDATION_META_FILENAME, INITIAL_REPUTATION, -} from '../../common/constants'; -import { - JobRequestType, ReputationEntityType, ReputationLevel, ReputationOrderBy, - SolutionError, - SortDirection, -} from '../../common/enums'; -import { ReputationRepository } from './reputation.repository'; -import { ReputationDto } from './reputation.dto'; -import { StorageService } from '../storage/storage.service'; -import { Web3Service } from '../web3/web3.service'; -import { EscrowClient } from '@human-protocol/sdk'; -import { - AudinoAnnotationMeta, - AudinoAnnotationMetaResult, - CvatAnnotationMeta, - CvatAnnotationMetaResults, - FortuneFinalResult, -} from '../../common/interfaces/job-result'; -import { RequestAction } from './reputation.interface'; -import { getRequestType } from '../../utils/manifest'; -import { - AudinoManifest, - CvatManifest, - JobManifest, -} from '../../common/interfaces/manifest'; -import { ReputationConfigService } from '../../config/reputation-config.service'; -import { Web3ConfigService } from '../../config/web3-config.service'; +} from './constants'; import { ReputationEntity } from './reputation.entity'; -import { ReputationError, ReputationErrorMessage } from './reputation.error'; +import { + ReputationRepository, + type ExclusiveReputationCriteria, +} from './reputation.repository'; + +type ReputationData = { + chainId: ChainId; + address: string; + level: ReputationLevel; + role: ReputationEntityType; +}; + +function assertAdjustableReputationPoints(points: number) { + if (points > 0 && Number.isInteger(points)) { + return; + } + + throw new Error('Adjustable reputation points must be positive integer'); +} @Injectable() export class ReputationService { constructor( - private readonly storageService: StorageService, private readonly reputationRepository: ReputationRepository, private readonly reputationConfigService: ReputationConfigService, private readonly web3ConfigService: Web3ConfigService, @@ -48,298 +45,80 @@ export class ReputationService { ) {} /** - * Perform reputation assessment based on the completion status of a job and its associated entities. - * Retrieves necessary data from the escrow client, including manifest and final results URLs, - * and delegates reputation adjustments to specialized methods. - * @param chainId The ID of the blockchain chain. - * @param escrowAddress The address of the escrow contract. - * @returns {Promise} A Promise indicating the completion of reputation assessment. + * Determines the reputation level based on the reputation points */ - public async assessReputationScores( - chainId: ChainId, - escrowAddress: string, - ): Promise { - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); - - const manifestUrl = await escrowClient.getManifestUrl(escrowAddress); - - const manifest = - await this.storageService.downloadJsonLikeData(manifestUrl); - - const requestType = getRequestType(manifest); - - const { assessWorkerReputationScores } = - this.createReputationSpecificActions[requestType]; - - // Assess reputation scores for the job launcher entity. - // Increases the reputation score for the job launcher. - const jobLauncherAddress = - await escrowClient.getJobLauncherAddress(escrowAddress); - await this.increaseReputation( - chainId, - jobLauncherAddress, - ReputationEntityType.JOB_LAUNCHER, - ); - - await assessWorkerReputationScores(chainId, escrowAddress, manifest); - - // Assess reputation scores for the exchange oracle entity. - // Decreases or increases the reputation score for the exchange oracle based on job completion. - const exchangeOracleAddress = - await escrowClient.getExchangeOracleAddress(escrowAddress); - await this.increaseReputation( - chainId, - exchangeOracleAddress, - ReputationEntityType.EXCHANGE_ORACLE, - ); - - // Assess reputation scores for the recording oracle entity. - // Decreases or increases the reputation score for the recording oracle based on job completion status. - const recordingOracleAddress = - await escrowClient.getRecordingOracleAddress(escrowAddress); - await this.increaseReputation( - chainId, - recordingOracleAddress, - ReputationEntityType.RECORDING_ORACLE, - ); - - const reputationOracleAddress = this.web3ConfigService.operatorAddress; - await this.increaseReputation( - chainId, - reputationOracleAddress, - ReputationEntityType.REPUTATION_ORACLE, - ); - } - - private createReputationSpecificActions: Record< - JobRequestType, - RequestAction - > = { - [JobRequestType.FORTUNE]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - ): Promise => this.processFortune(chainId, escrowAddress), - }, - [JobRequestType.IMAGE_BOXES]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise => this.processCvat(chainId, escrowAddress, manifest), - }, - [JobRequestType.IMAGE_POINTS]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise => this.processCvat(chainId, escrowAddress, manifest), - }, - [JobRequestType.IMAGE_BOXES_FROM_POINTS]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise => this.processCvat(chainId, escrowAddress, manifest), - }, - [JobRequestType.IMAGE_SKELETONS_FROM_BOXES]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise => this.processCvat(chainId, escrowAddress, manifest), - }, - [JobRequestType.IMAGE_POLYGONS]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise => this.processCvat(chainId, escrowAddress, manifest), - }, - [JobRequestType.AUDIO_TRANSCRIPTION]: { - assessWorkerReputationScores: async ( - chainId: ChainId, - escrowAddress: string, - manifest: AudinoManifest, - ): Promise => this.processAudino(chainId, escrowAddress, manifest), - }, - }; - - private async processFortune( - chainId: ChainId, - escrowAddress: string, - ): Promise { - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); - - const finalResultsUrl = await escrowClient.getResultsUrl(escrowAddress); - const finalResults = - await this.storageService.downloadJsonLikeData( - finalResultsUrl, - ); - - // Assess reputation scores for workers based on the final results of a job. - // Decreases or increases worker reputation based on the success or failure of their contributions. - await Promise.all( - finalResults.map(async (result) => { - if (result.error) { - if (result.error === SolutionError.Duplicated) - await this.decreaseReputation( - chainId, - result.workerAddress, - ReputationEntityType.WORKER, - ); - } else { - await this.increaseReputation( - chainId, - result.workerAddress, - ReputationEntityType.WORKER, - ); - } - }), - ); - } - - private async processCvat( - chainId: ChainId, - escrowAddress: string, - manifest: CvatManifest, - ): Promise { - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); - - const intermediateResultsUrl = - await escrowClient.getIntermediateResultsUrl(escrowAddress); - - const annotations = - await this.storageService.downloadJsonLikeData( - `${intermediateResultsUrl}/${CVAT_VALIDATION_META_FILENAME}`, - ); - - // Assess reputation scores for workers based on the annoation quality. - // Decreases or increases worker reputation based on comparison annoation quality to minimum threshold. - await Promise.all( - annotations.results.map(async (result: CvatAnnotationMetaResults) => { - if (result.annotation_quality < manifest.validation.min_quality) { - await this.decreaseReputation( - chainId, - result.annotator_wallet_address, - ReputationEntityType.WORKER, - ); - } else { - await this.increaseReputation( - chainId, - result.annotator_wallet_address, - ReputationEntityType.WORKER, - ); - } - }), - ); - } - - private async processAudino( - chainId: ChainId, - escrowAddress: string, - manifest: AudinoManifest, - ): Promise { - const signer = this.web3Service.getSigner(chainId); - const escrowClient = await EscrowClient.build(signer); - - const intermediateResultsUrl = - await escrowClient.getIntermediateResultsUrl(escrowAddress); + private getReputationLevel(reputationPoints: number): ReputationLevel { + if (reputationPoints <= this.reputationConfigService.lowLevel) { + return ReputationLevel.LOW; + } - const annotations = - await this.storageService.downloadJsonLikeData( - `${intermediateResultsUrl}/${AUDINO_VALIDATION_META_FILENAME}`, - ); + if (reputationPoints >= this.reputationConfigService.highLevel) { + return ReputationLevel.HIGH; + } - // Assess reputation scores for workers based on the annoation quality. - // Decreases or increases worker reputation based on comparison annoation quality to minimum threshold. - await Promise.all( - annotations.results.map(async (result: AudinoAnnotationMetaResult) => { - if (result.annotation_quality < manifest.validation.min_quality) { - await this.decreaseReputation( - chainId, - result.annotator_wallet_address, - ReputationEntityType.WORKER, - ); - } else { - await this.increaseReputation( - chainId, - result.annotator_wallet_address, - ReputationEntityType.WORKER, - ); - } - }), - ); + return ReputationLevel.MEDIUM; } /** - * Increases the reputation points of a specified entity on a given blockchain chain. - * If the entity doesn't exist in the database, it creates a new entry with initial reputation points. - * @param chainId The ID of the blockchain chain. - * @param address The address of the entity. - * @param type The type of reputation entity. - * @returns {Promise} A Promise indicating the completion of reputation increase. + * Increases the reputation points of a specified entity on a given chain. + * If the entity doesn't exist in the database - creates it first. */ - public async increaseReputation( - chainId: ChainId, - address: string, - type: ReputationEntityType, + async increaseReputation( + { chainId, address, type }: ExclusiveReputationCriteria, + points: number, ): Promise { - const reputationEntity = - await this.reputationRepository.findOneByAddress(address); + assertAdjustableReputationPoints(points); - if (!reputationEntity) { - const reputationEntity = new ReputationEntity(); - reputationEntity.chainId = chainId; - reputationEntity.address = address; - reputationEntity.reputationPoints = INITIAL_REPUTATION + 1; - reputationEntity.type = type; + const searchCriteria = { chainId, address, type }; + + let existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); + if (!existingEntity) { + let initialReputation = INITIAL_REPUTATION; if ( type === ReputationEntityType.REPUTATION_ORACLE && address === this.web3ConfigService.operatorAddress ) { - reputationEntity.reputationPoints = - this.reputationConfigService.highLevel; + initialReputation = this.reputationConfigService.highLevel; } - this.reputationRepository.createUnique(reputationEntity); - return; - } + const reputationEntity = new ReputationEntity(); + reputationEntity.chainId = chainId; + reputationEntity.address = address; + reputationEntity.type = type; + reputationEntity.reputationPoints = initialReputation; + + try { + existingEntity = + await this.reputationRepository.createUnique(reputationEntity); + } catch (error) { + /** + * Safety-belt for cases where operation is executed concurrently + * in absense of distributed lock + */ + if (isDuplicatedError(error)) { + existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); + } - reputationEntity.reputationPoints += 1; + throw error; + } + } - await this.reputationRepository.updateOne(reputationEntity); + existingEntity.reputationPoints += points; + await this.reputationRepository.updateOne(existingEntity); } /** - * Decreases the reputation points of a specified entity on a given blockchain chain. - * If the entity doesn't exist in the database, it creates a new entry with initial reputation points. - * @param chainId The ID of the blockchain chain. - * @param address The address of the entity. - * @param type The type of reputation entity. - * @returns {Promise} A Promise indicating the completion of reputation decrease. + * Decreases the reputation points of a specified entity on a given chain. + * If the entity doesn't exist in the database - creates it first. */ - public async decreaseReputation( - chainId: ChainId, - address: string, - type: ReputationEntityType, + async decreaseReputation( + { chainId, address, type }: ExclusiveReputationCriteria, + points: number, ): Promise { - const reputationEntity = - await this.reputationRepository.findOneByAddress(address); - - if (!reputationEntity) { - const reputationEntity = new ReputationEntity(); - reputationEntity.chainId = chainId; - reputationEntity.address = address; - reputationEntity.reputationPoints = INITIAL_REPUTATION; - reputationEntity.type = type; - this.reputationRepository.createUnique(reputationEntity); - return; - } + assertAdjustableReputationPoints(points); if ( type === ReputationEntityType.REPUTATION_ORACLE && @@ -348,109 +127,101 @@ export class ReputationService { return; } - if (reputationEntity.reputationPoints === INITIAL_REPUTATION) { - return; - } - - reputationEntity.reputationPoints -= 1; + const searchCriteria = { chainId, address, type }; - await this.reputationRepository.updateOne(reputationEntity); - } + let existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); - /** - * Retrieves the reputation data for a specific entity on a given blockchain chain. - * @param chainId The ID of the blockchain chain. - * @param address The address of the entity. - * @returns {Promise} A Promise containing the reputation data. - * @throws NotFoundException if the reputation data for the entity is not found. - */ - public async getReputation( - chainId: ChainId, - address: string, - ): Promise { - // https://github.com/humanprotocol/human-protocol/issues/1047 - if (address === this.web3ConfigService.operatorAddress) { - return { - chainId, - address, - reputation: ReputationLevel.HIGH, - role: ReputationEntityType.REPUTATION_ORACLE, - }; - } - - const reputationEntity = - await this.reputationRepository.findOneByAddressAndChainId( - address, - chainId, - ); - - if (!reputationEntity) { - throw new ReputationError( - ReputationErrorMessage.NOT_FOUND, - chainId, - address, - ); - } - - return { - chainId: reputationEntity.chainId, - address: reputationEntity.address, - reputation: this.getReputationLevel(reputationEntity.reputationPoints), - role: reputationEntity.type, - }; - } + if (!existingEntity) { + const reputationEntity = new ReputationEntity(); + reputationEntity.chainId = chainId; + reputationEntity.address = address; + reputationEntity.type = type; + reputationEntity.reputationPoints = INITIAL_REPUTATION; - /** - * Determines the reputation level based on the reputation points. - * @param reputationPoints The reputation points of an entity. - * @returns {ReputationLevel} The reputation level. - */ - public getReputationLevel(reputationPoints: number): ReputationLevel { - if (reputationPoints <= this.reputationConfigService.lowLevel) { - return ReputationLevel.LOW; - } + try { + existingEntity = + await this.reputationRepository.createUnique(reputationEntity); + } catch (error) { + /** + * Safety-belt for cases where operation is executed concurrently + * in absense of distributed lock + */ + if (isDuplicatedError(error)) { + existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); + } - if (reputationPoints >= this.reputationConfigService.highLevel) { - return ReputationLevel.HIGH; + throw error; + } } - return ReputationLevel.MEDIUM; + existingEntity.reputationPoints -= points; + await this.reputationRepository.updateOne(existingEntity); } /** - * Retrieves reputation data for entities on a given blockchain chain, optionally filtered by chain ID and roles. - * Supports pagination and sorting by reputation points. - * - * @param chainId Optional. The ID of the blockchain chain. - * @param types Optional. An array of roles to filter by. - * @param orderBy Optional. The field to order the results by (e.g., reputation points). - * @param orderDirection Optional. The direction to sort the results (e.g., ascending or descending). - * @param first Number of records to retrieve. - * @param skip Number of records to skip. - * @returns A Promise containing an array of reputation data. + * Retrieves reputation data for entities on a given chain, + * optionally filtered by different params. */ - public async getReputations( - chainId?: ChainId, - types?: ReputationEntityType[], - orderBy?: ReputationOrderBy, - orderDirection?: SortDirection, - first?: number, - skip?: number, - ): Promise { - const reputations = await this.reputationRepository.findByChainIdAndTypes( - chainId, - types, - orderBy, - orderDirection, - first, - skip, + async getReputations( + filter: { + address?: string; + chainId?: ChainId; + types?: ReputationEntityType[]; + }, + options?: { + orderBy?: ReputationOrderBy; + orderDirection?: SortDirection; + first?: number; + skip?: number; + }, + ): Promise { + const reputations = await this.reputationRepository.findPaginated( + filter, + options, ); return reputations.map((reputation) => ({ chainId: reputation.chainId, address: reputation.address, - reputation: this.getReputationLevel(reputation.reputationPoints), role: reputation.type, + level: this.getReputationLevel(reputation.reputationPoints), })); } + + async assessEscrowParties( + chainId: ChainId, + escrowAddress: string, + ): Promise { + const signer = this.web3Service.getSigner(chainId); + const escrowClient = await EscrowClient.build(signer); + + const [jobLauncherAddress, exchangeOracleAddress, recordingOracleAddress] = + await Promise.all([ + escrowClient.getJobLauncherAddress(escrowAddress), + escrowClient.getExchangeOracleAddress(escrowAddress), + escrowClient.getRecordingOracleAddress(escrowAddress), + ]); + + const reputationTypeToAddress = new Map([ + [ReputationEntityType.JOB_LAUNCHER, jobLauncherAddress], + [ReputationEntityType.EXCHANGE_ORACLE, exchangeOracleAddress], + [ReputationEntityType.RECORDING_ORACLE, recordingOracleAddress], + [ + ReputationEntityType.REPUTATION_ORACLE, + this.web3ConfigService.operatorAddress, + ], + ]); + + for (const [ + reputationEntityType, + address, + ] of reputationTypeToAddress.entries()) { + await this.increaseReputation( + { chainId, address, type: reputationEntityType }, + 1, + ); + } + } } diff --git a/packages/apps/reputation-oracle/server/src/utils/manifest.ts b/packages/apps/reputation-oracle/server/src/utils/manifest.ts index 206f9bc323..dda48ddea6 100644 --- a/packages/apps/reputation-oracle/server/src/utils/manifest.ts +++ b/packages/apps/reputation-oracle/server/src/utils/manifest.ts @@ -2,18 +2,18 @@ import { JobManifest } from '../common/interfaces/manifest'; import { JobRequestType } from '../common/enums'; import { UnsupportedManifestTypeError } from '../common/errors/manifest'; -export function getRequestType(manifest: JobManifest): JobRequestType { - let requestType: JobRequestType | undefined; +export function getJobRequestType(manifest: JobManifest): JobRequestType { + let jobRequestType: JobRequestType | undefined; if ('requestType' in manifest) { - requestType = manifest.requestType; + jobRequestType = manifest.requestType; } else if ('annotation' in manifest) { - requestType = manifest.annotation.type; + jobRequestType = manifest.annotation.type; } - if (!requestType) { - throw new UnsupportedManifestTypeError(requestType); + if (!jobRequestType) { + throw new UnsupportedManifestTypeError(jobRequestType); } - return requestType; + return jobRequestType; } diff --git a/packages/apps/staking/src/App.tsx b/packages/apps/staking/src/App.tsx index f193f68694..3752da1df3 100644 --- a/packages/apps/staking/src/App.tsx +++ b/packages/apps/staking/src/App.tsx @@ -1,15 +1,16 @@ -import React from 'react'; +import { FC } from 'react'; import { Route, Routes, Navigate } from 'react-router-dom'; + import Dashboard from './pages/Dashboard'; -import Home from './pages/Home'; -import { useAccount } from 'wagmi'; +import KVStore from './pages/KVStore'; +import { ROUTES } from './constants'; -const App: React.FC = () => { - const { isConnected } = useAccount(); +const App: FC = () => { return ( - : } /> - } /> + } /> + } /> + } /> ); }; diff --git a/packages/apps/staking/src/assets/DiscordIcon.tsx b/packages/apps/staking/src/assets/DiscordIcon.tsx deleted file mode 100644 index 7aefd29e27..0000000000 --- a/packages/apps/staking/src/assets/DiscordIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; - -const DiscordIcon: React.FC = (props) => { - return ( - - - - ); -}; - -export default DiscordIcon; diff --git a/packages/apps/staking/src/assets/KVStoreIcon.tsx b/packages/apps/staking/src/assets/KVStoreIcon.tsx deleted file mode 100644 index 86c9bca45a..0000000000 --- a/packages/apps/staking/src/assets/KVStoreIcon.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; -import { FC } from 'react'; - -export const KVStoreIcon: FC = (props) => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/apps/staking/src/assets/bag.png b/packages/apps/staking/src/assets/bag.png deleted file mode 100644 index 9145a460d8..0000000000 Binary files a/packages/apps/staking/src/assets/bag.png and /dev/null differ diff --git a/packages/apps/staking/src/assets/fund-crypto.png b/packages/apps/staking/src/assets/fund-crypto.png deleted file mode 100644 index d7676f75a0..0000000000 Binary files a/packages/apps/staking/src/assets/fund-crypto.png and /dev/null differ diff --git a/packages/apps/staking/src/assets/human.png b/packages/apps/staking/src/assets/human.png deleted file mode 100644 index 694e88c066..0000000000 Binary files a/packages/apps/staking/src/assets/human.png and /dev/null differ diff --git a/packages/apps/staking/src/assets/logo.svg b/packages/apps/staking/src/assets/logo.svg index d673726b04..229d24a86f 100644 --- a/packages/apps/staking/src/assets/logo.svg +++ b/packages/apps/staking/src/assets/logo.svg @@ -1,15 +1,13 @@ - - - - - - - - - + + + + + + + + - - - + + diff --git a/packages/apps/staking/src/assets/styles/_breadcrumbs.scss b/packages/apps/staking/src/assets/styles/_breadcrumbs.scss deleted file mode 100644 index 3d21ae1cb6..0000000000 --- a/packages/apps/staking/src/assets/styles/_breadcrumbs.scss +++ /dev/null @@ -1,11 +0,0 @@ -.breadcrumbs{ - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 62px; - - @media(max-width: 1100px) { - margin-bottom: 24px; - } - -} diff --git a/packages/apps/staking/src/assets/styles/_footer.scss b/packages/apps/staking/src/assets/styles/_footer.scss index be78326a45..79de8106be 100644 --- a/packages/apps/staking/src/assets/styles/_footer.scss +++ b/packages/apps/staking/src/assets/styles/_footer.scss @@ -1,25 +1,29 @@ @use 'const'; -footer{ - min-height: 124px; - margin-top: auto; - @media(max-width: 1100px) { - padding: 0 24px; +footer { + + @media(max-width: 600px) { flex-direction: column-reverse; justify-content: center; align-items: center; background-color: const.$maWhite; + margin-top: 32px; } .footer-wrapper { display: flex; - padding: 32px 18px; + padding: 32px 44px; flex-direction: row; justify-content: space-between; align-items: stretch; - @media (max-width: 1100px) { + @media (min-width: 601px) and (max-width: 900px) { + padding: 32px 24px; + } + + @media (max-width: 600px) { flex-direction: column-reverse; + padding: 32px 16px; } } @@ -33,42 +37,17 @@ footer{ justify-content: flex-start; gap: 30px; margin-bottom: 0; - align-items: flex-end; + align-items: center; @media (max-width: 600px) { justify-content: space-between; align-items: flex-start; margin-bottom: 32px; } - - @media (max-width: 1100px) { - margin-bottom: 32px; - } } - .footer-link, .footer-icon{ + .footer-link, .footer-icon { display: flex; flex-wrap: wrap; - span { - cursor: pointer; - font-size: 12px; - } - svg { - font-size: 32px; - cursor: pointer; - } - } - - @media (max-width: 1100px) { - .footer-link-wrapper{ - .footer-link{ - flex-direction: column; - } - } - } - - .footer-link, .footer-icon{ - justify-content: flex-start; - align-items: flex-start; } } diff --git a/packages/apps/staking/src/assets/styles/_header.scss b/packages/apps/staking/src/assets/styles/_header.scss deleted file mode 100644 index 348816444b..0000000000 --- a/packages/apps/staking/src/assets/styles/_header.scss +++ /dev/null @@ -1,72 +0,0 @@ -.header-toolbar { - height: 82px; - justify-content: space-between; - - .header-list-link { - display: flex; - gap: 16px; - align-items: center; - .header-link { - font-weight: 600; - padding: 6px 8px; - cursor: pointer; - font-size: 14px; - } - } - - .mobile-icon { - display: none; - } - - .logo-mobile, - .search-header-mobile { - display: none; - } - - @media (max-width: 1300px) { - .logo { - display: none; - } - .logo-mobile { - display: block; - } - } - - @media (max-width: 1280px) { - height: 62px; - .mobile-icon, - .search-header-mobile { - display: block; - } - .header-list-link, - .search-header { - display: none; - } - - .logo-mobile { - margin-left: 24px; - } - - .mobile-icon { - margin-right: 16px; - } - } -} - -.header-mobile-menu { - padding: 20px 10px; - .header-list-link { - display: flex; - gap: 16px; - flex-direction: column; - .header-link { - font-weight: 600; - padding: 6px 8px; - font-size: 14px; - } - - span { - cursor: pointer; - } - } -} diff --git a/packages/apps/staking/src/assets/styles/_home-page.scss b/packages/apps/staking/src/assets/styles/_home-page.scss deleted file mode 100644 index a728df3150..0000000000 --- a/packages/apps/staking/src/assets/styles/_home-page.scss +++ /dev/null @@ -1,188 +0,0 @@ -@use 'const'; - -.home-page-header { - color: const.$white; - .home-page-search { - margin-top: 24px; - } -} - -.home-page-boxes { - display: flex; - gap: 24px; - margin-top: 62px; - - .home-page-box { - color: const.$primary; - background-color: const.$white; - border-radius: 16px; - padding: 24px 32px; - width: calc(38% - 24px); - - @media (max-width: 660px) { - padding: 24px 16px; - } - - .box-title { - font-size: 14px; - height: 32px; - margin-bottom: 24px; - display: flex; - align-items: center; - justify-content: space-between; - } - .box-content { - display: flex; - .count { - font-size: 20px; - font-weight: 500; - margin-top: 3px; - } - .box-icon { - margin-right: 10px; - } - } - } - - .home-page-box:first-child { - width: 24%; - } - @media (max-width: 1250px) { - flex-wrap: wrap; - .home-page-box, - .home-page-box:first-child { - width: 100%; - } - } -} - -.home-page-find { - font-size: 14px; - margin-top: 32px; - display: flex; - gap: 32px; - align-items: center; - height: 60px; - white-space: nowrap; - span { - display: flex; - align-items: center; - gap: 16px; - } - @media (max-width: 1100px) { - span { - min-width: 120px; - } - } -} - -.home-page-find-title-mobile { - margin-top: 32px; - font-size: 14px; - @media (max-width: 1100px) { - display: block; - } -} - -.home-page-table-header { - background-color: const.$whiteSolid; - height: 72px; - color: #320a8d; - text-transform: uppercase; - border: 0; - margin-bottom: 15px; - - th { - font-size: 12px; - } - .icon-table { - display: flex; - align-items: center; - gap: 8px; - } -} - -.home-page-table-row { - td { - padding: 32px 16px; - font-size: 16px; - } - - .icon-table { - background-color: const.$groundwaterOpacity; - display: flex; - align-items: center; - justify-content: center; - border-radius: 10px; - width: 52px; - height: 52px; - } - - .reputation-table { - padding: 4px 8px; - border-radius: 16px; - font-size: 13px; - display: inline; - } - - .reputation-table-medium { - color: const.$medium; - border: 1px solid const.$mediumBorder; - } - - .reputation-table-low { - color: const.$low; - border: 1px solid const.$lowBorder; - } - - .reputation-table-high { - color: const.$high; - border: 1px solid const.$highBorder; - } - .reputation-table-soon { - color: const.$soon; - border: 1px solid const.$soonBorder; - } - - &:hover { - transition: opacity 8ms; - opacity: 0.8; - } -} - -#network-select { - svg { - display: none; - } -} - -.select-item { - display: flex; - gap: 10px; -} - -.mobile-select { - width: 270px; - background-color: const.$whiteSolid; - padding: 10px; - display: none; - margin-bottom: 20px; - @media (max-width: 1100px) { - display: block; - } -} - -.table-filter-select { - @media (max-width: 1100px) { - div { - display: none; - } - } - .mobile-title { - display: none; - font-weight: 400; - @media (max-width: 1100px) { - display: block; - } - } -} diff --git a/packages/apps/staking/src/assets/styles/_page-wrapper.scss b/packages/apps/staking/src/assets/styles/_page-wrapper.scss index 3af8e6bf55..15a1952ae5 100644 --- a/packages/apps/staking/src/assets/styles/_page-wrapper.scss +++ b/packages/apps/staking/src/assets/styles/_page-wrapper.scss @@ -1,51 +1,40 @@ @use 'const'; -.page-wrapper { - margin: 0 56px; - min-height: calc(100vh - 206px); - border-bottom-left-radius: 20px; - border-bottom-right-radius: 20px; - display: flex; - flex-direction: column; - padding: 14px 0; - height: 100vh; +.layout { + min-height: 100dvh; - @media (max-width: 1280px) { - border-radius: 0; - height: auto; + & .MuiToolbar-root { + padding: 0; } .container { - margin: auto; - padding: 30px 16px 100px; - - @media (max-width: 1100px) { - padding: 30px 16px; + margin-left: auto; + margin-right: auto; + padding: 0px 56px; + + @media (min-width: 601px) and (max-width: 1200px) { + padding: 0px 40px; } - } - - @media (max-width: 1280px) { - min-height: calc(100vh - 386px); - margin: 0 24px; - } - @media (max-width: 600px) { - margin: 0; + @media (max-width: 600px) { + padding: 0px 24px + } } } .violet-header { - z-index: 10; border-radius: 20px; background-size: 100% 100%; - background: linear-gradient(to bottom, const.$primary 252px, const.$maWhite 1px); + background: linear-gradient(to bottom, const.$primary 254px, const.$maWhite 1px); + min-height: calc(100dvh - 212px); + padding-top: 32px; @media (max-width: 600px) { - border-top-left-radius: 0; - border-top-right-radius: 0; + border-radius: 0; + height: auto; + margin-left: -24px; + margin-right: -24px; + padding-top: 16px; + padding-bottom: 32px; } -} - -.standard-background { - background-color: const.$maWhite; } \ No newline at end of file diff --git a/packages/apps/staking/src/assets/styles/_shadow-icon.scss b/packages/apps/staking/src/assets/styles/_shadow-icon.scss deleted file mode 100644 index bf50fc4162..0000000000 --- a/packages/apps/staking/src/assets/styles/_shadow-icon.scss +++ /dev/null @@ -1,29 +0,0 @@ -.shadow-icon{ - display: flex; - gap: 16px; - height: 90px; - &__icon { - width: 65px; - display: flex; - justify-content: center; - align-items: center; - } - img, svg { - transform: translateY(17%); - width: 130px; - height: 130px; - } - span{ - padding-top: 20px; - font-size: 28px; - font-weight: 600; - } - - @media (max-width: 400px) { - span { - padding-top: 30px; - font-size: 20px; - font-weight: 500; - } - } -} diff --git a/packages/apps/staking/src/assets/styles/color-palette.ts b/packages/apps/staking/src/assets/styles/color-palette.ts index 0d1b0e298e..3e54c5766f 100644 --- a/packages/apps/staking/src/assets/styles/color-palette.ts +++ b/packages/apps/staking/src/assets/styles/color-palette.ts @@ -29,7 +29,7 @@ export const colorPalette = { light: '#FFD54F', }, error: { - main: '#FFB300', + main: '#fa2a75', light: '#F20D5F', }, fog: { diff --git a/packages/apps/staking/src/assets/styles/main.scss b/packages/apps/staking/src/assets/styles/main.scss index 4658e923ee..233a0f6bb3 100644 --- a/packages/apps/staking/src/assets/styles/main.scss +++ b/packages/apps/staking/src/assets/styles/main.scss @@ -1,7 +1,3 @@ @use 'const'; -@use 'header'; @use 'page-wrapper'; -@use 'footer'; -@use 'home-page'; -@use 'breadcrumbs'; -@use 'shadow-icon'; \ No newline at end of file +@use 'footer'; \ No newline at end of file diff --git a/packages/apps/staking/src/assets/user.png b/packages/apps/staking/src/assets/user.png deleted file mode 100644 index ca40b9c906..0000000000 Binary files a/packages/apps/staking/src/assets/user.png and /dev/null differ diff --git a/packages/apps/staking/src/components/Account/index.tsx b/packages/apps/staking/src/components/Account/index.tsx index 1f5784fc35..8bceaf5c9d 100644 --- a/packages/apps/staking/src/components/Account/index.tsx +++ b/packages/apps/staking/src/components/Account/index.tsx @@ -1,85 +1,120 @@ +import { FC, useState } from 'react'; import { Avatar, - Box, Button, + Popover, Typography, useMediaQuery, useTheme, } from '@mui/material'; import { useAccount, useDisconnect, useEnsAvatar, useEnsName } from 'wagmi'; -export function Account() { - const { address, connector } = useAccount(); +import { formatAddress } from '../../utils/string'; +import { AvatarIcon, ChevronIcon, PowerIcon } from '../../icons'; + +const Account: FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + const { address } = useAccount(); const { disconnect } = useDisconnect(); const { data: ensName } = useEnsName({ address }); const { data: ensAvatar } = useEnsAvatar({ name: ensName! }); const theme = useTheme(); - const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); - const isMediumScreen = useMediaQuery(theme.breakpoints.down(1800)); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const formattedAddress = formatAddress(address); - const formattedAddress = isMediumScreen ? formatAddress(address) : address; + const handleClosePopover = () => setAnchorEl(null); return ( - - + - - - {ensName ? `${ensName} (${formattedAddress})` : formattedAddress} - - - Connected to {connector?.name} + {formattedAddress} - - - + - Disconnect - - + + + ); -} +}; -function formatAddress(address?: string) { - if (!address) return ''; - return `${address.slice(0, 6)}…${address.slice(-4)}`; -} +export default Account; diff --git a/packages/apps/staking/src/components/Amount/index.tsx b/packages/apps/staking/src/components/Amount/index.tsx new file mode 100644 index 0000000000..697994ddf8 --- /dev/null +++ b/packages/apps/staking/src/components/Amount/index.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Typography } from '@mui/material'; + +import { formatHmtAmount } from '../../utils/string'; + +type Props = { + amount: string | number; + isConnected: boolean; + size?: 'sm' | 'lg'; +}; + +const Amount: FC = ({ amount, isConnected, size = 'sm' }) => { + if (!isConnected) { + return ( + + -- + + ); + } + + return ( + + {formatHmtAmount(amount)} + + ); +}; + +export default Amount; diff --git a/packages/apps/staking/src/components/BalanceCard/index.tsx b/packages/apps/staking/src/components/BalanceCard/index.tsx index cae44465b3..80f69804fc 100644 --- a/packages/apps/staking/src/components/BalanceCard/index.tsx +++ b/packages/apps/staking/src/components/BalanceCard/index.tsx @@ -1,46 +1,42 @@ +import { FC } from 'react'; +import { Box, Divider, Typography } from '@mui/material'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; -import { Box, Paper, Typography } from '@mui/material'; -import React from 'react'; +import { useAccount } from 'wagmi'; + import { useStakeContext } from '../../contexts/stake'; import { colorPalette } from '../../assets/styles/color-palette'; import CustomTooltip from '../CustomTooltip'; +import CardWrapper from '../CardWrapper'; +import NetworkStatus from '../NetworkStatus'; +import Amount from '../Amount'; -const BalanceCard: React.FC = () => { +const BalanceCard: FC = () => { const { tokenBalance } = useStakeContext(); + const { isConnected } = useAccount(); return ( - - + + + + + Wallet Balance HMT + + + - - - Wallet Balance - - - - - {tokenBalance} HMT - - + + + ); }; diff --git a/packages/apps/staking/src/components/CardWrapper/index.tsx b/packages/apps/staking/src/components/CardWrapper/index.tsx new file mode 100644 index 0000000000..bb41a076e8 --- /dev/null +++ b/packages/apps/staking/src/components/CardWrapper/index.tsx @@ -0,0 +1,35 @@ +import { FC, PropsWithChildren } from 'react'; +import Paper from '@mui/material/Paper'; + +type Props = { + size?: 'sm' | 'lg'; +}; + +const CardWrapper: FC> = ({ + children, + size = 'sm', +}) => { + return ( + + {children} + + ); +}; + +export default CardWrapper; diff --git a/packages/apps/staking/src/components/Footer/Footer.tsx b/packages/apps/staking/src/components/Footer/index.tsx similarity index 51% rename from packages/apps/staking/src/components/Footer/Footer.tsx rename to packages/apps/staking/src/components/Footer/index.tsx index 3bbbb718e2..ec99d78a54 100644 --- a/packages/apps/staking/src/components/Footer/Footer.tsx +++ b/packages/apps/staking/src/components/Footer/index.tsx @@ -1,11 +1,25 @@ import { FC } from 'react'; +import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import TwitterIcon from '@mui/icons-material/Twitter'; import LinkedInIcon from '@mui/icons-material/LinkedIn'; import GitHubIcon from '@mui/icons-material/GitHub'; import TelegramIcon from '@mui/icons-material/Telegram'; -import DiscordIcon from '../../assets/DiscordIcon'; +import { Link } from '@mui/material'; +import { styled } from '@mui/material/styles'; + import { colorPalette } from '../../assets/styles/color-palette'; +import { DiscordIcon } from '../../icons'; + +const SocialMediaIconButton = styled(IconButton)({ + padding: 0, + color: colorPalette.sky.main, + + '&:hover': { + background: 'none', + color: 'inherit', + }, +}); const Footer: FC = () => { const handleClick = (url: string) => { @@ -13,79 +27,77 @@ const Footer: FC = () => { }; return ( -