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/4] [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/4] [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/4] [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/4] 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); } /**