From a94c39393e4ab24cb1c43d00de6d878dda74ebec Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Mon, 21 Apr 2025 19:44:39 +0200 Subject: [PATCH 1/6] [HUMAN App] chore: remove boilerplate for auth contexts (#3284) --- .../src/api/hooks/use-access-token-refresh.ts | 4 +- packages/apps/human-app/frontend/src/main.tsx | 2 +- .../auth-web3/context/web3-auth-context.tsx | 136 +-------------- .../auth-web3/providers/require-web3-auth.tsx | 5 +- .../src/modules/auth/context/auth-context.tsx | 156 ++---------------- .../modules/auth/providers/require-auth.tsx | 5 +- .../shared/contexts/generic-auth-context.tsx | 146 ++++++++++++++++ 7 files changed, 175 insertions(+), 279 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/shared/contexts/generic-auth-context.tsx 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..4d89fb3bc1 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -6,7 +6,6 @@ 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'; import '@fontsource/inter/400.css'; @@ -19,6 +18,7 @@ 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 { AuthProvider } from './modules/auth/context/auth-context'; const root = document.getElementById('root'); if (!root) throw Error('root element is undefined'); 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..8492460058 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,7 @@ -/* eslint-disable camelcase -- ...*/ -import { useState, createContext, useEffect } from 'react'; -import { jwtDecode } from 'jwt-decode'; +/* eslint-disable camelcase */ +// web3-auth.tsx 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 +19,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/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..92754f633c 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,22 @@ -/* eslint-disable camelcase -- ... */ -import { useState, createContext, useEffect } from 'react'; -import { jwtDecode } from 'jwt-decode'; +/* eslint-disable camelcase */ +// web2-auth.tsx 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/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/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..eb833ab87c --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/contexts/generic-auth-context.tsx @@ -0,0 +1,146 @@ +// generic-auth-provider.tsx +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 { + ModalType, + useModalStore, +} from '@/shared/components/ui/modal/modal.store'; +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'; + +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 { openModal } = useModalStore(); + const [authState, setAuthState] = useState<{ + user: T | null; + status: AuthStatus; + }>({ + user: null, + status: 'loading', + }); + + const displayExpirationModal = () => { + queryClient.setDefaultOptions({ queries: { enabled: false } }); + openModal({ + modalType: ModalType.EXPIRATION_MODAL, + displayCloseButton: false, + maxWidth: 'sm', + }); + }; + + 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 }; +} From 49ac115cd9cb41ca9be8acbbbfd294c373f97d59 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Tue, 22 Apr 2025 11:47:21 +0300 Subject: [PATCH 2/6] [Reputation Oracle] fix: refresh token expiration (#3291) --- .../server/src/config/auth-config.service.ts | 6 +++--- .../server/src/modules/auth/auth.service.spec.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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(), From 217c6656658e21ae8fd66130b1ce5278aa4f0c15 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Tue, 22 Apr 2025 13:10:49 +0300 Subject: [PATCH 3/6] [Reputation Oracle] refactor: reputation module (#3285) --- .../server/src/common/constants/operator.ts | 3 +- .../src/modules/details/details.service.ts | 44 +- .../src/modules/details/details.spec.ts | 10 +- .../server/src/common/constants/index.ts | 1 - .../server/src/common/enums/index.ts | 1 - .../1744732021099-UniqueReputations.ts | 17 + .../server/src/modules/abuse/abuse.module.ts | 3 +- .../src/modules/abuse/abuse.service.spec.ts | 34 +- .../server/src/modules/abuse/abuse.service.ts | 9 +- .../src/modules/abuse/abuse.slack-bot.spec.ts | 6 +- .../escrow-completion.service.spec.ts | 14 +- .../escrow-completion.service.ts | 2 +- .../src/modules/payout/payout.service.ts | 6 +- .../reputation/constants.ts} | 20 +- .../server/src/modules/reputation/fixtures.ts | 23 + .../reputation/reputation.controller.ts | 116 ++- .../src/modules/reputation/reputation.dto.ts | 101 +-- .../modules/reputation/reputation.entity.ts | 18 +- .../reputation/reputation.error.filter.ts | 36 - .../modules/reputation/reputation.error.ts | 20 - .../reputation/reputation.interface.ts | 14 - .../modules/reputation/reputation.module.ts | 3 +- .../reputation/reputation.repository.ts | 99 ++- .../reputation/reputation.service.spec.ts | 692 +++++++----------- .../modules/reputation/reputation.service.ts | 547 ++++---------- .../server/src/utils/manifest.ts | 14 +- 26 files changed, 690 insertions(+), 1163 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/database/migrations/1744732021099-UniqueReputations.ts rename packages/apps/reputation-oracle/server/src/{common/enums/reputation.ts => modules/reputation/constants.ts} (67%) create mode 100644 packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.filter.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/reputation/reputation.error.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts 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/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/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/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..2122ec5c76 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts @@ -0,0 +1,23 @@ +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 generateReputationEntity(score?: number): ReputationEntity { + return { + id: faker.number.int(), + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + reputationPoints: score || faker.number.int(), + 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..79ad6541de 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,343 @@ +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 { + 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(); + +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, +}; - beforeEach(async () => { +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 }, - ]; - - jest - .spyOn(storageService, 'downloadJsonLikeData') - .mockResolvedValueOnce(manifest) - .mockResolvedValueOnce(finalResults); - - jest.spyOn(reputationService, 'increaseReputation').mockResolvedValue(); - jest.spyOn(reputationService, 'decreaseReputation').mockResolvedValue(); - - await reputationService.assessReputationScores(chainId, escrowAddress); - - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.JOB_LAUNCHER, - ); - - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - 'worker1', - ReputationEntityType.WORKER, - ); - - expect(reputationService.decreaseReputation).toHaveBeenCalledWith( - chainId, - 'worker2', - ReputationEntityType.WORKER, - ); - - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.EXCHANGE_ORACLE, - ); + afterEach(() => { + jest.resetAllMocks(); + }); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.RECORDING_ORACLE, - ); + 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', }); }); + }); - 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, - }, + describe('increaseReputation', () => { + it.each([faker.number.float(), faker.number.int() * -1])( + 'throws for invalid score points [%#]', + async (score) => { + await expect( + service.increaseReputation( { - id: 2, - job_id: 2, - annotator_wallet_address: 'worker2', - annotation_quality: 0.94, + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), }, - ], - }; - - jest - .spyOn(storageService, 'downloadJsonLikeData') - .mockResolvedValueOnce(manifest) - .mockResolvedValueOnce(annotationMeta); - - jest.spyOn(reputationService, 'increaseReputation').mockResolvedValue(); - jest.spyOn(reputationService, 'decreaseReputation').mockResolvedValue(); - - await reputationService.assessReputationScores(chainId, escrowAddress); - - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.JOB_LAUNCHER, + score, + ), + ).rejects.toThrow( + 'Adjustable reputation points must be positive integer', ); + }, + ); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - 'worker1', - ReputationEntityType.WORKER, - ); + it('creates entity if not exists and increases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - expect(reputationService.decreaseReputation).toHaveBeenCalledWith( - chainId, - 'worker2', - ReputationEntityType.WORKER, - ); + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }; + const score = faker.number.int({ min: 1 }); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.EXCHANGE_ORACLE, - ); + await service.increaseReputation(criteria, score); - expect(reputationService.increaseReputation).toHaveBeenCalledWith( - chainId, - MOCK_ADDRESS, - ReputationEntityType.RECORDING_ORACLE, - ); - }); + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), + ); + + expect(mockReputationRepository.increment).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.increment).toHaveBeenCalledWith( + criteria, + 'reputationPoints', + score, + ); }); - }); - describe('increaseReputation', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - const type = ReputationEntityType.WORKER; + it('creates entity if not exists and increases reputation for current reputation oracle', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - it('should create a new reputation entity if not found', async () => { - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(undefined as any); - jest.spyOn(reputationRepository, 'createUnique'); + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }; + const score = faker.number.int({ min: 1 }); - await reputationService.increaseReputation(chainId, address, type); + await service.increaseReputation(criteria, score); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: mockReputationConfigService.highLevel, + }), ); - expect(reputationRepository.createUnique).toHaveBeenCalledWith({ - chainId, - address, - reputationPoints: 1, - type, - }); - }); - - 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(mockReputationRepository.increment).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.increment).toHaveBeenCalledWith( + criteria, + 'reputationPoints', + score, ); + }); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, + it('increases reputation if entity already exists', async () => { + const reputationEntity = generateReputationEntity(); + mockReputationRepository.findExclusive.mockResolvedValueOnce( + reputationEntity, ); - 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, + const criteria = { + chainId: reputationEntity.chainId, + address: reputationEntity.address, + type: reputationEntity.type, }; + const score = faker.number.int({ min: 1 }); - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); + await service.increaseReputation(criteria, score); - await reputationService.increaseReputation(chainId, address, type); + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, + expect(mockReputationRepository.increment).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.increment).toHaveBeenCalledWith( + criteria, + 'reputationPoints', + score, ); - expect(reputationEntity.reputationPoints).toBe(2); - expect(reputationRepository.updateOne).toHaveBeenCalled(); }); }); describe('decreaseReputation', () => { - const chainId = ChainId.LOCALHOST; - const address = MOCK_ADDRESS; - const type = ReputationEntityType.WORKER; - - it('should create a new reputation entity if not found', async () => { - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(undefined as any); - jest.spyOn(reputationRepository, 'createUnique'); + 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', + ); + }, + ); - await reputationService.decreaseReputation(chainId, address, type); + it('creates entity if not exists and decreases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, - ); - expect(reputationRepository.createUnique).toHaveBeenCalledWith({ - chainId, - address, - reputationPoints: 0, - type, - }); - }); - - it('should decrease reputation points if entity found', async () => { - const reputationEntity: Partial = { - address, - reputationPoints: 1, - type: ReputationEntityType.RECORDING_ORACLE, + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), }; + const score = faker.number.int({ min: 1 }); - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); + await service.decreaseReputation(criteria, score); - await reputationService.decreaseReputation(chainId, address, type); + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), + ); - expect(reputationRepository.findOneByAddress).toHaveBeenCalledWith( - address, + expect(mockReputationRepository.decrement).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.decrement).toHaveBeenCalledWith( + criteria, + 'reputationPoints', + score, ); - expect(reputationEntity.reputationPoints).toBe(0); - expect(reputationRepository.updateOne).toHaveBeenCalled(); }); - it('should return if called for Reputation Oracle itself', async () => { - const reputationEntity: Partial = { - address, - reputationPoints: 701, - type: ReputationEntityType.RECORDING_ORACLE, + it('should not decrease reputation for current reputation oracle', async () => { + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, }; + const score = faker.number.int({ min: 1 }); - jest - .spyOn(reputationRepository, 'findOneByAddress') - .mockResolvedValueOnce(reputationEntity as ReputationEntity); + await service.decreaseReputation(criteria, score); - 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); + afterAll(() => { + spyOnIncreaseReputation.mockRestore(); }); - 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, + 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, ); - - 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); - }); - - 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), + 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..fe7f05887c 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 existingEntity = await this.reputationRepository.findExclusive({ + chainId, + address, + type, + }); + 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; - reputationEntity.reputationPoints += 1; + try { + await this.reputationRepository.createUnique(reputationEntity); + } catch (error) { + if (!isDuplicatedError(error)) { + throw error; + } + } + } - await this.reputationRepository.updateOne(reputationEntity); + await this.reputationRepository.increment( + { + chainId, + address, + type, + }, + 'reputationPoints', + points, + ); } /** - * 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 existingEntity = await this.reputationRepository.findExclusive({ + chainId, + address, + type, + }); - await this.reputationRepository.updateOne(reputationEntity); - } + if (!existingEntity) { + const reputationEntity = new ReputationEntity(); + reputationEntity.chainId = chainId; + reputationEntity.address = address; + reputationEntity.type = type; + reputationEntity.reputationPoints = INITIAL_REPUTATION; - /** - * 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, - }; + try { + await this.reputationRepository.createUnique(reputationEntity); + } catch (error) { + if (!isDuplicatedError(error)) { + throw error; + } + } } - const reputationEntity = - await this.reputationRepository.findOneByAddressAndChainId( - address, - chainId, - ); - - if (!reputationEntity) { - throw new ReputationError( - ReputationErrorMessage.NOT_FOUND, + await this.reputationRepository.decrement( + { chainId, address, - ); - } - - return { - chainId: reputationEntity.chainId, - address: reputationEntity.address, - reputation: this.getReputationLevel(reputationEntity.reputationPoints), - role: reputationEntity.type, - }; - } - - /** - * 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; - } - - if (reputationPoints >= this.reputationConfigService.highLevel) { - return ReputationLevel.HIGH; - } - - return ReputationLevel.MEDIUM; + type, + }, + 'reputationPoints', + points, + ); } /** - * 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; } From eb872fbf3db72154e80c67381b95e5a10b8f9e51 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Tue, 22 Apr 2025 16:44:14 +0300 Subject: [PATCH 4/6] refactor: replace increment operation for reputation (#3293) --- .../server/src/modules/reputation/fixtures.ts | 6 +- .../reputation/reputation.service.spec.ts | 333 ++++++++++-------- .../modules/reputation/reputation.service.ts | 68 ++-- 3 files changed, 232 insertions(+), 175 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts index 2122ec5c76..c5c6a40622 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/fixtures.ts @@ -10,13 +10,17 @@ 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 || faker.number.int(), + reputationPoints: score || generateRandomScorePoints(), createdAt: faker.date.recent(), updatedAt: new Date(), }; 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 79ad6541de..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 @@ -15,6 +15,7 @@ import { import { Web3Service } from '../web3/web3.service'; import { + generateRandomScorePoints, generateReputationEntity, generateReputationEntityType, } from './fixtures'; @@ -108,167 +109,215 @@ describe('ReputationService', () => { }); }); - 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', - ); - }, - ); - - it('creates entity if not exists and increases reputation', async () => { - mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - - const criteria = { - chainId: generateTestnetChainId(), - address: faker.finance.ethereumAddress(), - type: generateReputationEntityType(), - }; - const score = faker.number.int({ min: 1 }); - - await service.increaseReputation(criteria, score); - - expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( - expect.objectContaining({ - ...criteria, - reputationPoints: 0, - }), + 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 }), ); + }); - expect(mockReputationRepository.increment).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.increment).toHaveBeenCalledWith( - criteria, - 'reputationPoints', - score, + 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', + ); + }, ); - }); - it('creates entity if not exists and increases reputation for current reputation oracle', async () => { - mockReputationRepository.findExclusive.mockResolvedValueOnce(null); + it('creates entity if not exists and increases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - const criteria = { - chainId: generateTestnetChainId(), - address: mockWeb3ConfigService.operatorAddress, - type: ReputationEntityType.REPUTATION_ORACLE, - }; - const score = faker.number.int({ min: 1 }); + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }; + const score = generateRandomScorePoints(); - await service.increaseReputation(criteria, score); + await service.increaseReputation(criteria, score); - expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( - expect.objectContaining({ - ...criteria, - reputationPoints: mockReputationConfigService.highLevel, - }), - ); + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), + ); - expect(mockReputationRepository.increment).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.increment).toHaveBeenCalledWith( - criteria, - 'reputationPoints', - score, - ); - }); + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: score, + }), + ); + }); - it('increases reputation if entity already exists', async () => { - const reputationEntity = generateReputationEntity(); - mockReputationRepository.findExclusive.mockResolvedValueOnce( - reputationEntity, - ); + it('creates entity if not exists and increases reputation for current reputation oracle', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - const criteria = { - chainId: reputationEntity.chainId, - address: reputationEntity.address, - type: reputationEntity.type, - }; - const score = faker.number.int({ min: 1 }); + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }; + const score = generateRandomScorePoints(); - await service.increaseReputation(criteria, score); + await service.increaseReputation(criteria, score); - expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: mockReputationConfigService.highLevel, + }), + ); - expect(mockReputationRepository.increment).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.increment).toHaveBeenCalledWith( - criteria, - 'reputationPoints', - score, - ); - }); - }); + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: score + mockReputationConfigService.highLevel, + }), + ); + }); - 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', + it('increases reputation if entity already exists', async () => { + const reputationEntity = generateReputationEntity(); + mockReputationRepository.findExclusive.mockResolvedValueOnce( + reputationEntity, ); - }, - ); - - it('creates entity if not exists and decreases reputation', async () => { - mockReputationRepository.findExclusive.mockResolvedValueOnce(null); - - const criteria = { - chainId: generateTestnetChainId(), - address: faker.finance.ethereumAddress(), - type: generateReputationEntityType(), - }; - const score = faker.number.int({ min: 1 }); - - await service.decreaseReputation(criteria, score); - - expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( - expect.objectContaining({ - ...criteria, - reputationPoints: 0, - }), - ); - expect(mockReputationRepository.decrement).toHaveBeenCalledTimes(1); - expect(mockReputationRepository.decrement).toHaveBeenCalledWith( - criteria, - 'reputationPoints', - score, - ); + const criteria = { + chainId: reputationEntity.chainId, + address: reputationEntity.address, + type: reputationEntity.type, + }; + const score = generateRandomScorePoints(); + const initialEntityScore = reputationEntity.reputationPoints; + + await service.increaseReputation(criteria, score); + + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); + + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: initialEntityScore + score, + }), + ); + }); }); - it('should not decrease reputation for current reputation oracle', async () => { - const criteria = { - chainId: generateTestnetChainId(), - address: mockWeb3ConfigService.operatorAddress, - type: ReputationEntityType.REPUTATION_ORACLE, - }; - const score = faker.number.int({ min: 1 }); + 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', + ); + }, + ); + + it('creates entity if not exists and decreases reputation', async () => { + mockReputationRepository.findExclusive.mockResolvedValueOnce(null); + mockReputationRepository.createUnique.mockImplementationOnce( + async (entity) => ({ ...entity }), + ); + + const criteria = { + chainId: generateTestnetChainId(), + address: faker.finance.ethereumAddress(), + type: generateReputationEntityType(), + }; + const score = generateRandomScorePoints(); + + await service.decreaseReputation(criteria, score); + + expect(mockReputationRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: 0, + }), + ); + + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: -score, + }), + ); + }); + + it('decreases reputation if entity already exists', async () => { + const reputationEntity = generateReputationEntity(); + mockReputationRepository.findExclusive.mockResolvedValueOnce( + reputationEntity, + ); + + const criteria = { + chainId: reputationEntity.chainId, + address: reputationEntity.address, + type: reputationEntity.type, + }; + const score = generateRandomScorePoints(); + const initialEntityScore = reputationEntity.reputationPoints; - await service.decreaseReputation(criteria, score); + await service.decreaseReputation(criteria, score); - expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); - expect(mockReputationRepository.decrement).not.toHaveBeenCalled(); + expect(mockReputationRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockReputationRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...criteria, + reputationPoints: initialEntityScore - score, + }), + ); + }); + + it('should not decrease reputation for current reputation oracle', async () => { + const criteria = { + chainId: generateTestnetChainId(), + address: mockWeb3ConfigService.operatorAddress, + type: ReputationEntityType.REPUTATION_ORACLE, + }; + const score = generateRandomScorePoints(); + + await service.decreaseReputation(criteria, score); + + expect(mockReputationRepository.createUnique).not.toHaveBeenCalled(); + + expect(mockReputationRepository.decrement).not.toHaveBeenCalled(); + }); }); }); @@ -279,6 +328,10 @@ describe('ReputationService', () => { spyOnIncreaseReputation = jest.spyOn(service, 'increaseReputation'); }); + beforeEach(() => { + spyOnIncreaseReputation.mockImplementation(); + }); + afterAll(() => { spyOnIncreaseReputation.mockRestore(); }); 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 fe7f05887c..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 @@ -69,11 +69,10 @@ export class ReputationService { ): Promise { assertAdjustableReputationPoints(points); - const existingEntity = await this.reputationRepository.findExclusive({ - chainId, - address, - type, - }); + const searchCriteria = { chainId, address, type }; + + let existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); if (!existingEntity) { let initialReputation = INITIAL_REPUTATION; @@ -91,23 +90,24 @@ export class ReputationService { reputationEntity.reputationPoints = initialReputation; try { - await this.reputationRepository.createUnique(reputationEntity); + existingEntity = + await this.reputationRepository.createUnique(reputationEntity); } catch (error) { - if (!isDuplicatedError(error)) { - throw error; + /** + * Safety-belt for cases where operation is executed concurrently + * in absense of distributed lock + */ + if (isDuplicatedError(error)) { + existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); } + + throw error; } } - await this.reputationRepository.increment( - { - chainId, - address, - type, - }, - 'reputationPoints', - points, - ); + existingEntity.reputationPoints += points; + await this.reputationRepository.updateOne(existingEntity); } /** @@ -127,11 +127,10 @@ export class ReputationService { return; } - const existingEntity = await this.reputationRepository.findExclusive({ - chainId, - address, - type, - }); + const searchCriteria = { chainId, address, type }; + + let existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); if (!existingEntity) { const reputationEntity = new ReputationEntity(); @@ -141,23 +140,24 @@ export class ReputationService { reputationEntity.reputationPoints = INITIAL_REPUTATION; try { - await this.reputationRepository.createUnique(reputationEntity); + existingEntity = + await this.reputationRepository.createUnique(reputationEntity); } catch (error) { - if (!isDuplicatedError(error)) { - throw error; + /** + * Safety-belt for cases where operation is executed concurrently + * in absense of distributed lock + */ + if (isDuplicatedError(error)) { + existingEntity = + await this.reputationRepository.findExclusive(searchCriteria); } + + throw error; } } - await this.reputationRepository.decrement( - { - chainId, - address, - type, - }, - 'reputationPoints', - points, - ); + existingEntity.reputationPoints -= points; + await this.reputationRepository.updateOne(existingEntity); } /** From 7d8c3a3f9d8e236bcaa40019c2ff05df1613277c Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:40:28 +0200 Subject: [PATCH 5/6] [HUMAN App] refactor: modals (#3286) --- packages/apps/human-app/frontend/src/main.tsx | 23 ++- .../components/wallet-connect-modal.tsx | 19 +- .../auth-web3/context/web3-auth-context.tsx | 1 - .../hooks/use-wallet-connect-modal.tsx | 15 ++ .../src/modules/auth/context/auth-context.tsx | 1 - .../frontend/src/modules/auth/hooks/index.ts | 2 + .../frontend/src/modules/auth/index.ts | 1 + .../operator/views/connect-wallet.page.tsx | 14 +- ...ew.tsx => available-jobs-filter-modal.tsx} | 19 +- .../available-jobs/available-jobs-view.tsx | 10 +- .../mobile/available-jobs-table-mobile.tsx | 12 +- .../hooks/use-available-jobs-filter-modal.tsx | 21 ++ .../worker/jobs/available-jobs/index.ts | 2 +- .../src/modules/worker/jobs/jobs.page.tsx | 189 +++++++----------- .../jobs/my-jobs/components/mobile/index.ts | 2 +- ...er-mobile.tsx => my-jobs-filter-modal.tsx} | 15 +- .../components/mobile/my-jobs-list-mobile.tsx | 16 +- .../hooks/use-my-jobs-filter-modal.tsx | 21 ++ .../worker/jobs/my-jobs/my-jobs-view.tsx | 13 +- .../components/expiration-modal.tsx | 12 +- .../components/ui/modal/display-modal.tsx | 25 --- .../components/ui/modal/global-modal.tsx | 25 +++ .../components/ui/modal/modal-content.tsx | 18 -- .../components/ui/modal/modal-header.tsx | 14 +- .../shared/components/ui/modal/modal.store.ts | 51 ----- .../src/shared/components/ui/modal/modal.tsx | 27 --- .../shared/contexts/generic-auth-context.tsx | 14 +- .../src/shared/contexts/modal-context.tsx | 77 +++++++ .../frontend/src/shared/hooks/index.ts | 1 + .../src/shared/hooks/use-expiration-modal.tsx | 15 ++ .../hooks/use-handle-main-nav-icon-click.tsx | 13 +- 31 files changed, 333 insertions(+), 355 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/auth-web3/hooks/use-wallet-connect-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/auth/hooks/index.ts create mode 100644 packages/apps/human-app/frontend/src/modules/auth/index.ts rename packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/{available-jobs-drawer-mobile-view.tsx => available-jobs-filter-modal.tsx} (89%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx rename packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/{my-jobs-drawer-mobile.tsx => my-jobs-filter-modal.tsx} (91%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx rename packages/apps/human-app/frontend/src/{modules/auth => shared}/components/expiration-modal.tsx (82%) delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/display-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-content.tsx delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.tsx create mode 100644 packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx create mode 100644 packages/apps/human-app/frontend/src/shared/hooks/use-expiration-modal.tsx diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index 4d89fb3bc1..a8fa0e6415 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -5,7 +5,7 @@ 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'; import '@fontsource/inter/400.css'; @@ -18,7 +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 { AuthProvider } from './modules/auth/context/auth-context'; +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 8492460058..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,5 +1,4 @@ /* eslint-disable camelcase */ -// web3-auth.tsx import { z } from 'zod'; import { createAuthProvider } from '@/shared/contexts/generic-auth-context'; 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/context/auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx index 92754f633c..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,5 +1,4 @@ /* eslint-disable camelcase */ -// web2-auth.tsx import { z } from 'zod'; import { createAuthProvider } from '@/shared/contexts/generic-auth-context'; 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/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 index eb833ab87c..9e72fccfba 100644 --- 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 @@ -1,17 +1,13 @@ -// generic-auth-provider.tsx 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 { - ModalType, - useModalStore, -} from '@/shared/components/ui/modal/modal.store'; 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'; @@ -46,7 +42,6 @@ export function createAuthProvider(config: { function AuthProvider({ children }: Readonly<{ children: React.ReactNode }>) { const queryClient = useQueryClient(); - const { openModal } = useModalStore(); const [authState, setAuthState] = useState<{ user: T | null; status: AuthStatus; @@ -54,14 +49,11 @@ export function createAuthProvider(config: { user: null, status: 'loading', }); + const { openModal } = useExpirationModal(); const displayExpirationModal = () => { queryClient.setDefaultOptions({ queries: { enabled: false } }); - openModal({ - modalType: ModalType.EXPIRATION_MODAL, - displayCloseButton: false, - maxWidth: 'sm', - }); + openModal(); }; const handleSignIn = () => { 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); }; From 112021ddd812c1d0a3472c2f6c4cd54dc5c8d779 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 23 Apr 2025 10:25:51 +0300 Subject: [PATCH 6/6] [Staking] Apply new design (#3262) * wip: add new icons and remove needless ones; reworking layout * refactor: dashboard page, its components, approach to work with icons * refactor: initial version of kvstore page; layout and header * refactor: layout, header, footer, icons * refactor: header and icons; began working on kvstore page and its content * refactor: dashboard modals; some other fixes after review; * refactor: account popover * refactor: kvs modal; add delete confirmation modal * fix: address feedback on modals, header and some other stuff * fix: responsive header * fix: account button * fix: responsive design for dashboard * fix: remove needless stylesheets, footer elements and styles, theme globals * feat: add network switcher * fix: links and routes * refactor: withdrawal card and modal * fix: typography * refactor: drop network tooltip * fix: disabled logic on withdrawal modal * fix: simplify condition * refactor: confirmation modal * refactor: move render functions out of the component * fix: return integer if ends with zeros * minor fix --- packages/apps/staking/src/App.tsx | 15 +- .../apps/staking/src/assets/DiscordIcon.tsx | 12 - .../apps/staking/src/assets/KVStoreIcon.tsx | 186 ------------- packages/apps/staking/src/assets/bag.png | Bin 19688 -> 0 bytes .../apps/staking/src/assets/fund-crypto.png | Bin 78197 -> 0 bytes packages/apps/staking/src/assets/human.png | Bin 16156 -> 0 bytes packages/apps/staking/src/assets/logo.svg | 22 +- .../src/assets/styles/_breadcrumbs.scss | 11 - .../staking/src/assets/styles/_footer.scss | 47 +--- .../staking/src/assets/styles/_header.scss | 72 ----- .../staking/src/assets/styles/_home-page.scss | 188 ------------- .../src/assets/styles/_page-wrapper.scss | 55 ++-- .../src/assets/styles/_shadow-icon.scss | 29 --- .../src/assets/styles/color-palette.ts | 2 +- .../apps/staking/src/assets/styles/main.scss | 6 +- packages/apps/staking/src/assets/user.png | Bin 15558 -> 0 bytes .../staking/src/components/Account/index.tsx | 137 ++++++---- .../staking/src/components/Amount/index.tsx | 36 +++ .../src/components/BalanceCard/index.tsx | 54 ++-- .../src/components/CardWrapper/index.tsx | 35 +++ .../Footer/{Footer.tsx => index.tsx} | 102 ++++---- .../staking/src/components/Header/index.tsx | 212 +++++++++++++++ .../src/components/Headers/DefaultHeader.tsx | 133 ---------- .../src/components/LockedAmountCard/index.tsx | 55 ++-- .../src/components/ModalState/Error.tsx | 29 +++ .../src/components/ModalState/Loading.tsx | 8 + .../src/components/ModalState/Success.tsx | 27 ++ .../src/components/ModalState/index.tsx | 5 + .../NetworkStatus/NetworkIcon/index.tsx | 5 +- .../src/components/NetworkStatus/index.tsx | 58 ++--- .../src/components/NetworkSwitcher/index.tsx | 78 ++++++ .../src/components/PageWrapper/index.tsx | 23 +- .../src/components/ShadowIcon/index.tsx | 22 -- .../src/components/StakedAmountCard/index.tsx | 114 ++++---- .../staking/src/components/Tables/kvstore.tsx | 69 ++--- .../src/components/Wallet/ConnectWallet.tsx | 69 +---- .../WithdrawableAmountCard/index.tsx | 114 ++++---- .../src/components/modals/BaseModal.tsx | 47 +++- .../src/components/modals/KVStoreModal.tsx | 167 +++++++++--- .../modals/SaveConfirmationModal.tsx | 46 ++++ .../src/components/modals/StakeModal.tsx | 202 ++++++++++---- .../src/components/modals/UnstakeModal.tsx | 202 ++++++++++---- .../src/components/modals/WithdrawModal.tsx | 128 +++++++++ packages/apps/staking/src/constants/index.ts | 4 + packages/apps/staking/src/hooks/useKVStore.ts | 7 +- .../src/hooks/useModalRequestStatus.ts | 29 +++ packages/apps/staking/src/hooks/useStake.ts | 3 + packages/apps/staking/src/icons/index.tsx | 246 ++++++++++++++++++ .../staking/src/pages/Dashboard/index.tsx | 87 +++---- .../apps/staking/src/pages/Home/index.tsx | 60 ----- .../apps/staking/src/pages/KVStore/index.tsx | 37 +++ packages/apps/staking/src/theme.ts | 19 +- packages/apps/staking/src/utils/string.ts | 14 + 53 files changed, 1874 insertions(+), 1454 deletions(-) delete mode 100644 packages/apps/staking/src/assets/DiscordIcon.tsx delete mode 100644 packages/apps/staking/src/assets/KVStoreIcon.tsx delete mode 100644 packages/apps/staking/src/assets/bag.png delete mode 100644 packages/apps/staking/src/assets/fund-crypto.png delete mode 100644 packages/apps/staking/src/assets/human.png delete mode 100644 packages/apps/staking/src/assets/styles/_breadcrumbs.scss delete mode 100644 packages/apps/staking/src/assets/styles/_header.scss delete mode 100644 packages/apps/staking/src/assets/styles/_home-page.scss delete mode 100644 packages/apps/staking/src/assets/styles/_shadow-icon.scss delete mode 100644 packages/apps/staking/src/assets/user.png create mode 100644 packages/apps/staking/src/components/Amount/index.tsx create mode 100644 packages/apps/staking/src/components/CardWrapper/index.tsx rename packages/apps/staking/src/components/Footer/{Footer.tsx => index.tsx} (51%) create mode 100644 packages/apps/staking/src/components/Header/index.tsx delete mode 100644 packages/apps/staking/src/components/Headers/DefaultHeader.tsx create mode 100644 packages/apps/staking/src/components/ModalState/Error.tsx create mode 100644 packages/apps/staking/src/components/ModalState/Loading.tsx create mode 100644 packages/apps/staking/src/components/ModalState/Success.tsx create mode 100644 packages/apps/staking/src/components/ModalState/index.tsx create mode 100644 packages/apps/staking/src/components/NetworkSwitcher/index.tsx delete mode 100644 packages/apps/staking/src/components/ShadowIcon/index.tsx create mode 100644 packages/apps/staking/src/components/modals/SaveConfirmationModal.tsx create mode 100644 packages/apps/staking/src/components/modals/WithdrawModal.tsx create mode 100644 packages/apps/staking/src/constants/index.ts create mode 100644 packages/apps/staking/src/hooks/useModalRequestStatus.ts create mode 100644 packages/apps/staking/src/icons/index.tsx delete mode 100644 packages/apps/staking/src/pages/Home/index.tsx create mode 100644 packages/apps/staking/src/pages/KVStore/index.tsx 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 9145a460d870f3942515bfbe5f6b96993ef649fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19688 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

Kn09Z8d(vv(*0&EA4Y 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%0A-bPr9j5V5AEz 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 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 ca40b9c9066acfc23cd63c39722801bcb93defa7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15558 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 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 ( -