From 5aaee4630282b1f46e1eb32d1024d2c9311dd0a2 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Mon, 18 Aug 2025 11:40:30 +0300 Subject: [PATCH 1/7] feat: improve logs & services config (#3507) --- .../server/src/common/utils/environment.ts | 2 ++ .../apps/dashboard/server/src/logger/index.ts | 19 +++++++++-- .../server/src/modules/stats/stats.service.ts | 6 ++-- .../exchange-oracle/server/package.json | 13 +++---- .../server/src/common/utils/environment.ts | 2 ++ .../server/src/logger/index.ts | 18 ++++++++-- .../src/modules/cron-job/cron-job.service.ts | 4 +-- .../exchange-oracle/server/typeorm.config.ts | 9 ++--- .../src/common/utils/environment.ts | 2 ++ .../recording-oracle/src/logger/index.ts | 19 +++++++++-- packages/apps/human-app/server/jest.config.ts | 4 +-- .../config/environment-config.service.ts | 4 --- .../server/src/common/utils/environment.ts | 2 ++ .../apps/human-app/server/src/logger/index.ts | 19 +++++++++-- .../src/modules/cron-job/cron-job.service.ts | 4 +-- .../modules/health/dto/ping-response.dto.ts | 5 ++- .../modules/health/health.controller.spec.ts | 3 +- .../src/modules/health/health.controller.ts | 4 ++- .../apps/job-launcher/server/package.json | 14 ++++---- .../job-launcher/server/src/app.module.ts | 9 ----- .../server/src/common/utils/environment.ts | 2 ++ .../job-launcher/server/src/logger/index.ts | 19 +++++++++-- .../src/modules/cron-job/cron-job.service.ts | 30 ++++++++-------- .../job-launcher/server/typeorm.config.ts | 9 ++--- .../src/config/server-config.service.ts | 4 --- .../server/src/logger/index.ts | 19 +++++++++-- .../src/modules/cron-job/cron-job.service.ts | 34 +++++++++---------- .../modules/health/dto/ping-response.dto.ts | 4 +-- .../modules/health/health.controller.spec.ts | 12 +------ .../src/modules/health/health.controller.ts | 4 +-- .../server/src/utils/environment.ts | 2 ++ packages/libs/logger/package.json | 2 +- packages/libs/logger/src/index.ts | 24 +++++++++++-- packages/libs/logger/src/pino-logger.ts | 2 +- packages/libs/logger/src/types.ts | 5 +++ yarn.lock | 30 ---------------- 36 files changed, 219 insertions(+), 145 deletions(-) diff --git a/packages/apps/dashboard/server/src/common/utils/environment.ts b/packages/apps/dashboard/server/src/common/utils/environment.ts index 05d5c87255..b85af194f0 100644 --- a/packages/apps/dashboard/server/src/common/utils/environment.ts +++ b/packages/apps/dashboard/server/src/common/utils/environment.ts @@ -10,6 +10,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/apps/dashboard/server/src/logger/index.ts b/packages/apps/dashboard/server/src/logger/index.ts index e8057bf1e0..3f20186648 100644 --- a/packages/apps/dashboard/server/src/logger/index.ts +++ b/packages/apps/dashboard/server/src/logger/index.ts @@ -1,19 +1,34 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import Environment from '../common/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} + const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: 'human-protocol-dashboard', + version: Environment.version, }, ); diff --git a/packages/apps/dashboard/server/src/modules/stats/stats.service.ts b/packages/apps/dashboard/server/src/modules/stats/stats.service.ts index 91425d1a9e..10440c770f 100644 --- a/packages/apps/dashboard/server/src/modules/stats/stats.service.ts +++ b/packages/apps/dashboard/server/src/modules/stats/stats.service.ts @@ -85,7 +85,7 @@ export class StatsService implements OnModuleInit { private async fetchHistoricalHcaptchaStats(): Promise { let startDate = dayjs(HCAPTCHA_STATS_API_START_DATE); - this.logger.info('Fetching historical hCaptcha stats', { + this.logger.debug('Fetching historical hCaptcha stats', { startDate, }); @@ -169,7 +169,7 @@ export class StatsService implements OnModuleInit { const from = today; const to = today; - this.logger.info('Fetching hCaptcha stats for today', { from, to }); + this.logger.debug('Fetching hCaptcha stats for today', { from, to }); try { const { data } = await lastValueFrom( @@ -235,7 +235,7 @@ export class StatsService implements OnModuleInit { @Cron('*/15 * * * *') async fetchHmtGeneralStats() { - this.logger.info('Fetching HMT general stats across multiple networks'); + this.logger.debug('Fetching HMT general stats across multiple networks'); try { const aggregatedStats: HmtGeneralStatsDto = { diff --git a/packages/apps/fortune/exchange-oracle/server/package.json b/packages/apps/fortune/exchange-oracle/server/package.json index 0fefc7173a..093d3b942c 100644 --- a/packages/apps/fortune/exchange-oracle/server/package.json +++ b/packages/apps/fortune/exchange-oracle/server/package.json @@ -15,17 +15,18 @@ "start:prod": "node dist/src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", - "migration:create": "yarn build && typeorm-ts-node-commonjs migration:create", - "migration:generate": "yarn build && typeorm-ts-node-commonjs migration:generate -p -d typeorm.config.ts", - "migration:revert": "yarn build && typeorm-ts-node-commonjs migration:revert -d typeorm.config.ts", - "migration:run": "yarn build && typeorm-ts-node-commonjs migration:run -d typeorm.config.ts", - "migration:show": "yarn build && typeorm-ts-node-commonjs migration:show -d typeorm.config.ts", + "migration:create": "yarn typeorm migration:create", + "migration:generate": "yarn typeorm migration:generate -p -d typeorm.config.ts", + "migration:revert": "yarn typeorm migration:revert -d typeorm.config.ts", + "migration:run": "yarn typeorm migration:run -d typeorm.config.ts", + "migration:show": "yarn typeorm migration:show -d typeorm.config.ts", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "setup:local": "ts-node ./scripts/setup-staking.ts && LOCAL=true yarn setup:kvstore", "setup:kvstore": "ts-node ./scripts/setup-kv-store.ts", - "generate-env-doc": "ts-node scripts/generate-env-doc.ts" + "generate-env-doc": "ts-node scripts/generate-env-doc.ts", + "typeorm": "typeorm-ts-node-commonjs" }, "dependencies": { "@human-protocol/logger": "workspace:*", diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/utils/environment.ts b/packages/apps/fortune/exchange-oracle/server/src/common/utils/environment.ts index eea42cf768..ea3118df8d 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/utils/environment.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/utils/environment.ts @@ -9,6 +9,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/apps/fortune/exchange-oracle/server/src/logger/index.ts b/packages/apps/fortune/exchange-oracle/server/src/logger/index.ts index 1c026ef0e2..5abbfcc646 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/logger/index.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/logger/index.ts @@ -1,19 +1,33 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import Environment from '../common/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: 'fortune-exchange-oracle', + version: Environment.version, }, ); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts index 9bd649a3fe..343d565c5e 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -73,7 +73,7 @@ export class CronJobService { return; } - this.logger.info('Pending webhooks START'); + this.logger.debug('Pending webhooks START'); const cronJob = await this.startCronJob(CronJobType.ProcessPendingWebhook); try { @@ -99,7 +99,7 @@ export class CronJobService { this.logger.error('Error processing pending webhooks', error); } - this.logger.info('Pending webhooks STOP'); + this.logger.debug('Pending webhooks STOP'); await this.completeCronJob(cronJob); } diff --git a/packages/apps/fortune/exchange-oracle/server/typeorm.config.ts b/packages/apps/fortune/exchange-oracle/server/typeorm.config.ts index 4fe91e67f2..a06dfb3ca3 100644 --- a/packages/apps/fortune/exchange-oracle/server/typeorm.config.ts +++ b/packages/apps/fortune/exchange-oracle/server/typeorm.config.ts @@ -13,17 +13,18 @@ dotenv.config({ export default new DataSource({ type: 'postgres', + useUTC: true, url: process.env.POSTGRES_URL, host: process.env.POSTGRES_HOST, port: Number(process.env.POSTGRES_PORT), username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DATABASE || 'exchange-oracle', - entities: ['dist/src/**/*.entity{.ts,.js}'], + ssl: process.env.POSTGRES_SSL?.toLowerCase() === 'true', synchronize: false, - migrations: ['dist/src/database/migrations/*{.ts,.js}'], - migrationsTableName: 'migrations_typeorm', migrationsRun: true, + migrations: ['src/database/migrations/*.ts'], + migrationsTableName: 'migrations_typeorm', namingStrategy: new SnakeNamingStrategy(), - ssl: process.env.POSTGRES_SSL?.toLowerCase() === 'true', + entities: ['src/modules/**/*.entity.ts'], }); diff --git a/packages/apps/fortune/recording-oracle/src/common/utils/environment.ts b/packages/apps/fortune/recording-oracle/src/common/utils/environment.ts index eea42cf768..ea3118df8d 100644 --- a/packages/apps/fortune/recording-oracle/src/common/utils/environment.ts +++ b/packages/apps/fortune/recording-oracle/src/common/utils/environment.ts @@ -9,6 +9,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/apps/fortune/recording-oracle/src/logger/index.ts b/packages/apps/fortune/recording-oracle/src/logger/index.ts index 693fc3f195..c40311c0ea 100644 --- a/packages/apps/fortune/recording-oracle/src/logger/index.ts +++ b/packages/apps/fortune/recording-oracle/src/logger/index.ts @@ -1,19 +1,34 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import Environment from '../common/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} + const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: 'fortune-recording-oracle', + version: Environment.version, }, ); diff --git a/packages/apps/human-app/server/jest.config.ts b/packages/apps/human-app/server/jest.config.ts index b4808588b1..68d0c4ca7c 100644 --- a/packages/apps/human-app/server/jest.config.ts +++ b/packages/apps/human-app/server/jest.config.ts @@ -1,7 +1,7 @@ -process.env['GIT_HASH'] = 'test_value_hardcoded_in_jest_config'; - import { createDefaultPreset } from 'ts-jest'; +process.env['GIT_HASH'] = 'test_value_hardcoded_in_jest_config'; + const jestTsPreset = createDefaultPreset({}); module.exports = { diff --git a/packages/apps/human-app/server/src/common/config/environment-config.service.ts b/packages/apps/human-app/server/src/common/config/environment-config.service.ts index 54980729d8..26b30da510 100644 --- a/packages/apps/human-app/server/src/common/config/environment-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/environment-config.service.ts @@ -37,10 +37,6 @@ export class EnvironmentConfigService { return this.configService.getOrThrow('PORT'); } - get gitHash(): string { - return this.configService.get('GIT_HASH', ''); - } - /** * The URL of the reputation oracle service. * Required diff --git a/packages/apps/human-app/server/src/common/utils/environment.ts b/packages/apps/human-app/server/src/common/utils/environment.ts index e7a924059a..cce705afa6 100644 --- a/packages/apps/human-app/server/src/common/utils/environment.ts +++ b/packages/apps/human-app/server/src/common/utils/environment.ts @@ -10,6 +10,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/apps/human-app/server/src/logger/index.ts b/packages/apps/human-app/server/src/logger/index.ts index f28c991343..5a306142fe 100644 --- a/packages/apps/human-app/server/src/logger/index.ts +++ b/packages/apps/human-app/server/src/logger/index.ts @@ -1,19 +1,34 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import Environment from '../common/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} + const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: 'human-app', + version: Environment.version, }, ); diff --git a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts index 05cea18173..eec931c76f 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts @@ -69,7 +69,7 @@ export class CronJobService { } async updateJobsListCron() { - this.logger.info('Update jobs list START'); + this.logger.debug('Update jobs list START'); const oracles = await this.oracleDiscoveryService.discoverOracles(); @@ -110,7 +110,7 @@ export class CronJobService { this.logger.error('Error in update jobs list job', formattedError); } - this.logger.info('Update jobs list END'); + this.logger.debug('Update jobs list END'); } async updateJobsListCache(oracle: DiscoveredOracle, token: string) { diff --git a/packages/apps/human-app/server/src/modules/health/dto/ping-response.dto.ts b/packages/apps/human-app/server/src/modules/health/dto/ping-response.dto.ts index e8a351f47d..e70bcae96f 100644 --- a/packages/apps/human-app/server/src/modules/health/dto/ping-response.dto.ts +++ b/packages/apps/human-app/server/src/modules/health/dto/ping-response.dto.ts @@ -2,5 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; export class PingResponseDto { @ApiProperty() - public gitHash: string; + node_env: string; + + @ApiProperty() + version: string; } diff --git a/packages/apps/human-app/server/src/modules/health/health.controller.spec.ts b/packages/apps/human-app/server/src/modules/health/health.controller.spec.ts index 87738f6be7..5ba6e63e76 100644 --- a/packages/apps/human-app/server/src/modules/health/health.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/health/health.controller.spec.ts @@ -36,7 +36,8 @@ describe('HealthController', () => { it('/ping should return proper info', async () => { await expect(healthController.ping()).resolves.toEqual({ - gitHash: 'test_value_hardcoded_in_jest_config', + node_env: 'test', + version: 'test_value_hardcoded_in_jest_config', }); }); diff --git a/packages/apps/human-app/server/src/modules/health/health.controller.ts b/packages/apps/human-app/server/src/modules/health/health.controller.ts index 48d8dab2f7..d3b32c3f78 100644 --- a/packages/apps/human-app/server/src/modules/health/health.controller.ts +++ b/packages/apps/human-app/server/src/modules/health/health.controller.ts @@ -11,6 +11,7 @@ import { import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { Public } from '../../common/decorators'; +import Environment from '../../common/utils/environment'; import { PingResponseDto } from './dto/ping-response.dto'; import { CacheManagerHealthIndicator } from './indicators/cache-manager.health'; @@ -42,7 +43,8 @@ export class HealthController { @Get('/ping') async ping(): Promise { return { - gitHash: this.environmentConfigService.gitHash, + node_env: Environment.name, + version: Environment.version, }; } diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index 59e7c88ed6..6b5e607c5b 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -13,11 +13,11 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main", - "migration:create": "yarn build && typeorm-ts-node-commonjs migration:create", - "migration:generate": "yarn build && typeorm-ts-node-commonjs migration:generate -p -d typeorm.config.ts", - "migration:revert": "yarn build && typeorm-ts-node-commonjs migration:revert -d typeorm.config.ts", - "migration:run": "yarn build && typeorm-ts-node-commonjs migration:run -d typeorm.config.ts", - "migration:show": "yarn build && typeorm-ts-node-commonjs migration:show -d typeorm.config.ts", + "migration:create": "yarn typeorm migration:create", + "migration:generate": "yarn typeorm migration:generate -p -d typeorm.config.ts", + "migration:revert": "yarn typeorm migration:revert -d typeorm.config.ts", + "migration:run": "yarn typeorm migration:run -d typeorm.config.ts", + "migration:show": "yarn typeorm migration:show -d typeorm.config.ts", "setup:local": "ts-node ./scripts/setup-staking.ts && LOCAL=true yarn setup:kvstore", "setup:kvstore": "ts-node ./scripts/setup-kv-store.ts", "lint": "eslint \"{src,test}/**/*.ts\" --fix", @@ -25,7 +25,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "generate-env-doc": "ts-node scripts/generate-env-doc.ts" + "generate-env-doc": "ts-node scripts/generate-env-doc.ts", + "typeorm": "typeorm-ts-node-commonjs" }, "dependencies": { "@google-cloud/storage": "^7.15.0", @@ -40,7 +41,6 @@ "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.3.10", "@nestjs/schedule": "^4.0.1", - "@nestjs/serve-static": "^4.0.1", "@nestjs/swagger": "^7.4.2", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.4.0", diff --git a/packages/apps/job-launcher/server/src/app.module.ts b/packages/apps/job-launcher/server/src/app.module.ts index c15f1acdbf..8bc53ae973 100644 --- a/packages/apps/job-launcher/server/src/app.module.ts +++ b/packages/apps/job-launcher/server/src/app.module.ts @@ -2,9 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; -import { ServeStaticModule } from '@nestjs/serve-static'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; -import { join } from 'path'; import { AppController } from './app.controller'; import { EnvConfigModule } from './common/config/config.module'; import { envValidator } from './common/config/env-schema'; @@ -82,13 +80,6 @@ import { WebhookModule } from './modules/webhook/webhook.module'; WebhookModule, StatisticModule, QualificationModule, - ServeStaticModule.forRoot({ - rootPath: join( - __dirname, - '../../../../../../', - 'node_modules/swagger-ui-dist', - ), - }), CronJobModule, EnvConfigModule, ], diff --git a/packages/apps/job-launcher/server/src/common/utils/environment.ts b/packages/apps/job-launcher/server/src/common/utils/environment.ts index 05d5c87255..b85af194f0 100644 --- a/packages/apps/job-launcher/server/src/common/utils/environment.ts +++ b/packages/apps/job-launcher/server/src/common/utils/environment.ts @@ -10,6 +10,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/apps/job-launcher/server/src/logger/index.ts b/packages/apps/job-launcher/server/src/logger/index.ts index 50ec6a69cc..ee20d85215 100644 --- a/packages/apps/job-launcher/server/src/logger/index.ts +++ b/packages/apps/job-launcher/server/src/logger/index.ts @@ -1,19 +1,34 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import Environment from '../common/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} + const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: 'job-launcher', + version: Environment.version, }, ); diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index 8e41a57729..d5ecc22ac4 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -67,7 +67,7 @@ export class CronJobService { return false; } - this.logger.info('Previous cron job is not completed yet'); + this.logger.warn('Previous cron job is not completed yet'); return true; } @@ -131,7 +131,7 @@ export class CronJobService { return; } - this.logger.info('Create escrow START'); + this.logger.debug('Create escrow START'); const cronJob = await this.startCronJob(CronJobType.CreateEscrow); try { @@ -156,7 +156,7 @@ export class CronJobService { this.logger.error('Error in createEscrow cron job', error); } - this.logger.info('Create escrow STOP'); + this.logger.debug('Create escrow STOP'); await this.completeCronJob(cronJob); } @@ -170,7 +170,7 @@ export class CronJobService { return; } - this.logger.info('Setup escrow START'); + this.logger.debug('Setup escrow START'); const cronJob = await this.startCronJob(CronJobType.SetupEscrow); try { @@ -196,7 +196,7 @@ export class CronJobService { this.logger.error('Error in setupEscrow cron job', error); } - this.logger.info('Setup escrow STOP'); + this.logger.debug('Setup escrow STOP'); await this.completeCronJob(cronJob); } @@ -210,7 +210,7 @@ export class CronJobService { return; } - this.logger.info('Fund escrow START'); + this.logger.debug('Fund escrow START'); const cronJob = await this.startCronJob(CronJobType.FundEscrow); try { @@ -236,7 +236,7 @@ export class CronJobService { this.logger.error('Error in fundEscrow cron job', error); } - this.logger.info('Fund escrow STOP'); + this.logger.debug('Fund escrow STOP'); await this.completeCronJob(cronJob); } @@ -250,7 +250,7 @@ export class CronJobService { return; } - this.logger.info('Cancel jobs START'); + this.logger.debug('Cancel jobs START'); const cronJob = await this.startCronJob(CronJobType.CancelEscrow); try { @@ -315,7 +315,7 @@ export class CronJobService { this.logger.error('Error in cancelEscrow cron job', error); } await this.completeCronJob(cronJob); - this.logger.info('Cancel jobs STOP'); + this.logger.debug('Cancel jobs STOP'); return true; } @@ -333,7 +333,7 @@ export class CronJobService { return; } - this.logger.info('Pending webhooks START'); + this.logger.debug('Pending webhooks START'); const cronJob = await this.startCronJob(CronJobType.ProcessPendingWebhook); try { @@ -360,7 +360,7 @@ export class CronJobService { this.logger.error('Error in processPendingWebhooks cron job', error); } - this.logger.info('Pending webhooks STOP'); + this.logger.debug('Pending webhooks STOP'); await this.completeCronJob(cronJob); } @@ -376,7 +376,7 @@ export class CronJobService { return; } - this.logger.info('Abuse START'); + this.logger.debug('Abuse START'); const cronJob = await this.startCronJob(CronJobType.Abuse); try { @@ -426,7 +426,7 @@ export class CronJobService { this.logger.error('Error in processAbuse cron job', error); } - this.logger.info('Abuse STOP'); + this.logger.debug('Abuse STOP'); await this.completeCronJob(cronJob); } @@ -444,7 +444,7 @@ export class CronJobService { return; } - this.logger.info('Update jobs START'); + this.logger.debug('Update jobs START'); const cronJob = await this.startCronJob(CronJobType.SyncJobStatuses); try { @@ -541,7 +541,7 @@ export class CronJobService { this.logger.error('Error in syncJobStatuses cron job', error); } - this.logger.info('Update jobs STOP'); + this.logger.debug('Update jobs STOP'); await this.completeCronJob(cronJob); } } diff --git a/packages/apps/job-launcher/server/typeorm.config.ts b/packages/apps/job-launcher/server/typeorm.config.ts index d58d85e3e8..aaaf8b816d 100644 --- a/packages/apps/job-launcher/server/typeorm.config.ts +++ b/packages/apps/job-launcher/server/typeorm.config.ts @@ -13,17 +13,18 @@ dotenv.config({ export default new DataSource({ type: 'postgres', + useUTC: true, url: process.env.POSTGRES_URL, host: process.env.POSTGRES_HOST, port: Number(process.env.POSTGRES_PORT), username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DATABASE, - entities: ['dist/src/**/*.entity{ .ts,.js}'], + ssl: process.env.POSTGRES_SSL?.toLowerCase() === 'true', synchronize: false, - migrations: ['dist/src/database/migrations/*{.ts,.js}'], - migrationsTableName: 'migrations_typeorm', migrationsRun: true, + migrations: ['src/database/migrations/*.ts'], + migrationsTableName: 'migrations_typeorm', namingStrategy: new SnakeNamingStrategy(), - ssl: process.env.POSTGRES_SSL?.toLowerCase() === 'true', + entities: ['src/modules/**/*.entity.ts'], }); diff --git a/packages/apps/reputation-oracle/server/src/config/server-config.service.ts b/packages/apps/reputation-oracle/server/src/config/server-config.service.ts index 362789f6a3..99ee579661 100644 --- a/packages/apps/reputation-oracle/server/src/config/server-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/server-config.service.ts @@ -5,10 +5,6 @@ import { ConfigService } from '@nestjs/config'; export class ServerConfigService { constructor(private configService: ConfigService) {} - get gitHash(): string { - return this.configService.get('GIT_HASH', ''); - } - /** * The hostname or IP address on which the server will run. * Default: 'localhost' diff --git a/packages/apps/reputation-oracle/server/src/logger/index.ts b/packages/apps/reputation-oracle/server/src/logger/index.ts index fec3e67176..1515ea9346 100644 --- a/packages/apps/reputation-oracle/server/src/logger/index.ts +++ b/packages/apps/reputation-oracle/server/src/logger/index.ts @@ -1,20 +1,35 @@ -import { createLogger, NestLogger, LogLevel } from '@human-protocol/logger'; +import { + createLogger, + NestLogger, + LogLevel, + isLogLevel, +} from '@human-protocol/logger'; import { SERVICE_NAME } from '@/common/constants'; import Environment from '@/utils/environment'; const isDevelopment = Environment.isDevelopment(); +const LOG_LEVEL_OVERRIDE = process.env.LOG_LEVEL; + +let logLevel = LogLevel.INFO; +if (isLogLevel(LOG_LEVEL_OVERRIDE)) { + logLevel = LOG_LEVEL_OVERRIDE; +} else if (isDevelopment) { + logLevel = LogLevel.DEBUG; +} + const defaultLogger = createLogger( { name: 'DefaultLogger', - level: isDevelopment ? LogLevel.DEBUG : LogLevel.INFO, + level: logLevel, pretty: isDevelopment, disabled: Environment.isTest(), }, { environment: Environment.name, service: SERVICE_NAME, + version: Environment.version, }, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts index 660f0b9d11..d4f94ed5d4 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -55,7 +55,7 @@ export class CronJobService { return false; } - this.logger.info('Previous cron job is not completed yet', { cronJobType }); + this.logger.warn('Previous cron job is not completed yet', { cronJobType }); return true; } @@ -75,7 +75,7 @@ export class CronJobService { return; } - this.logger.info('Pending incoming webhooks START'); + this.logger.debug('Pending incoming webhooks START'); const cronJob = await this.startCronJob( CronJobType.ProcessPendingIncomingWebhook, ); @@ -86,7 +86,7 @@ export class CronJobService { this.logger.error('Error processing pending incoming webhooks', error); } - this.logger.info('Pending incoming webhooks STOP'); + this.logger.debug('Pending incoming webhooks STOP'); await this.completeCronJob(cronJob); } @@ -100,7 +100,7 @@ export class CronJobService { return; } - this.logger.info('Pending escrow completion tracking START'); + this.logger.debug('Pending escrow completion tracking START'); const cronJob = await this.startCronJob( CronJobType.ProcessPendingEscrowCompletionTracking, ); @@ -111,7 +111,7 @@ export class CronJobService { this.logger.error('Error processing pending escrow completion', error); } - this.logger.info('Pending escrow completion tracking STOP'); + this.logger.debug('Pending escrow completion tracking STOP'); await this.completeCronJob(cronJob); } @@ -125,7 +125,7 @@ export class CronJobService { return; } - this.logger.info('Paid escrow completion tracking START'); + this.logger.debug('Paid escrow completion tracking START'); const cronJob = await this.startCronJob( CronJobType.ProcessPaidEscrowCompletionTracking, ); @@ -136,7 +136,7 @@ export class CronJobService { this.logger.error('Error processing paid escrow completion', error); } - this.logger.info('Paid escrow completion tracking STOP'); + this.logger.debug('Paid escrow completion tracking STOP'); await this.completeCronJob(cronJob); } @@ -149,7 +149,7 @@ export class CronJobService { return; } - this.logger.info('Pending outgoing webhooks START'); + this.logger.debug('Pending outgoing webhooks START'); const cronJob = await this.startCronJob( CronJobType.ProcessPendingOutgoingWebhook, ); @@ -160,7 +160,7 @@ export class CronJobService { this.logger.error('Error processing pending outgoing webhooks', error); } - this.logger.info('Pending outgoing webhooks STOP'); + this.logger.debug('Pending outgoing webhooks STOP'); await this.completeCronJob(cronJob); } @@ -174,7 +174,7 @@ export class CronJobService { return; } - this.logger.info('Awaiting payouts processing START'); + this.logger.debug('Awaiting payouts processing START'); const cronJob = await this.startCronJob( CronJobType.ProcessAwaitingEscrowPayouts, ); @@ -185,7 +185,7 @@ export class CronJobService { this.logger.error('Error processing awaiting payouts', error); } - this.logger.info('Awaiting payouts processing STOP'); + this.logger.debug('Awaiting payouts processing STOP'); await this.completeCronJob(cronJob); } @@ -199,7 +199,7 @@ export class CronJobService { return; } - this.logger.info('Process Abuse START'); + this.logger.debug('Process Abuse START'); const cronJob = await this.startCronJob(CronJobType.ProcessRequestedAbuse); try { @@ -208,7 +208,7 @@ export class CronJobService { this.logger.error('Error processing abuse requests', e); } - this.logger.info('Process Abuse STOP'); + this.logger.debug('Process Abuse STOP'); await this.completeCronJob(cronJob); } @@ -222,7 +222,7 @@ export class CronJobService { return; } - this.logger.info('Process classified abuses START'); + this.logger.debug('Process classified abuses START'); const cronJob = await this.startCronJob(CronJobType.ProcessClassifiedAbuse); try { @@ -231,7 +231,7 @@ export class CronJobService { this.logger.error('Error processing classified abuse requests', e); } - this.logger.info('Process classified abuses STOP'); + this.logger.debug('Process classified abuses STOP'); await this.completeCronJob(cronJob); } @@ -245,7 +245,7 @@ export class CronJobService { return; } - this.logger.info('Delete expired DB records START'); + this.logger.debug('Delete expired DB records START'); const cronJob = await this.startCronJob(CronJobType.DeleteExpiredDbRecords); try { @@ -254,7 +254,7 @@ export class CronJobService { this.logger.error('Error deleting expired DB records', e); } - this.logger.info('Delete expired DB records STOP'); + this.logger.debug('Delete expired DB records STOP'); await this.completeCronJob(cronJob); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts b/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts index 154e7452f6..35b351134c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts @@ -4,6 +4,6 @@ export class PingResponseDto { @ApiProperty({ name: 'node_env' }) nodeEnv: string; - @ApiProperty({ name: 'git_hash' }) - gitHash: string; + @ApiProperty() + version: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/health/health.controller.spec.ts b/packages/apps/reputation-oracle/server/src/modules/health/health.controller.spec.ts index 7ed0d7279f..48507709d8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/health/health.controller.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/health/health.controller.spec.ts @@ -1,4 +1,3 @@ -import { faker } from '@faker-js/faker'; import { ServiceUnavailableException } from '@nestjs/common'; import { HealthIndicatorResult, @@ -8,15 +7,10 @@ import { } from '@nestjs/terminus'; import { Test } from '@nestjs/testing'; -import { ServerConfigService } from '@/config'; import { nestLoggerOverride } from '@/logger'; import { HealthController } from './health.controller'; -const mockServerConfigService = { - gitHash: faker.git.commitSha(), -}; - const mockTypeOrmPingCheck = jest.fn(); function generateMockHealthIndicatorResult( @@ -38,10 +32,6 @@ describe('HealthController', () => { imports: [TerminusModule], controllers: [HealthController], providers: [ - { - provide: ServerConfigService, - useValue: mockServerConfigService, - }, { provide: TypeOrmHealthIndicator, useValue: { @@ -64,8 +54,8 @@ describe('HealthController', () => { it('/ping should return proper info', async () => { await expect(healthController.ping()).resolves.toEqual({ - gitHash: mockServerConfigService.gitHash, nodeEnv: 'test', + version: 'test_value_hardcoded_in_jest_config', }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/health/health.controller.ts b/packages/apps/reputation-oracle/server/src/modules/health/health.controller.ts index ed00d5f44a..cc292bf797 100644 --- a/packages/apps/reputation-oracle/server/src/modules/health/health.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/health/health.controller.ts @@ -9,7 +9,6 @@ import { } from '@nestjs/terminus'; import { Public } from '@/common/decorators'; -import { ServerConfigService } from '@/config'; import Environment from '@/utils/environment'; import { PingResponseDto } from './dto/ping-response.dto'; @@ -19,7 +18,6 @@ import { PingResponseDto } from './dto/ping-response.dto'; @Controller('health') export class HealthController { constructor( - private readonly serverConfigService: ServerConfigService, private readonly health: HealthCheckService, private readonly db: TypeOrmHealthIndicator, ) {} @@ -42,7 +40,7 @@ export class HealthController { async ping(): Promise { return { nodeEnv: Environment.name, - gitHash: this.serverConfigService.gitHash, + version: Environment.version, }; } diff --git a/packages/apps/reputation-oracle/server/src/utils/environment.ts b/packages/apps/reputation-oracle/server/src/utils/environment.ts index 05d5c87255..b85af194f0 100644 --- a/packages/apps/reputation-oracle/server/src/utils/environment.ts +++ b/packages/apps/reputation-oracle/server/src/utils/environment.ts @@ -10,6 +10,8 @@ class Environment { static readonly name: string = process.env.NODE_ENV || EnvironmentName.DEVELOPMENT; + static readonly version: string = process.env.GIT_HASH || 'n/a'; + static isDevelopment(): boolean { return [ EnvironmentName.DEVELOPMENT, diff --git a/packages/libs/logger/package.json b/packages/libs/logger/package.json index a78ada3f4d..26fb9e0945 100644 --- a/packages/libs/logger/package.json +++ b/packages/libs/logger/package.json @@ -1,6 +1,6 @@ { "name": "@human-protocol/logger", - "version": "1.0.0", + "version": "1.1.0", "description": "Unified logging package for HUMAN Protocol", "type": "commonjs", "main": "dist/index.js", diff --git a/packages/libs/logger/src/index.ts b/packages/libs/logger/src/index.ts index ab3a81d8ae..8100e15c4f 100644 --- a/packages/libs/logger/src/index.ts +++ b/packages/libs/logger/src/index.ts @@ -1,9 +1,27 @@ -import { createPinoLogger as createLogger } from './pino-logger'; +import { createPinoLogger } from './pino-logger'; +import { isLogLevel, LoggerFactory, LogLevel } from './types'; -export { default as NestLogger } from './nest-logger'; +const FALLBACK_LEVEL = LogLevel.INFO; + +const createLogger: LoggerFactory = (options, bindings) => { + if (options.level) { + if (!isLogLevel(options.level)) { + console.warn( + `Unknown log level '${options.level}'. Fallback to '${FALLBACK_LEVEL}'`, + ); + options.level = FALLBACK_LEVEL; + } + } else { + options.level = LogLevel.DEBUG; + } + + return createPinoLogger(options, bindings); +}; -export { LogLevel } from './types'; +export { LogLevel, isLogLevel } from './types'; export type { Logger, LoggerFactory, LoggerFactoryOptions } from './types'; +export { default as NestLogger } from './nest-logger'; export { createLogger }; + export default createLogger({ name: 'human-protocol-logger', pretty: true }); diff --git a/packages/libs/logger/src/pino-logger.ts b/packages/libs/logger/src/pino-logger.ts index c7da8d5def..db78b39b00 100644 --- a/packages/libs/logger/src/pino-logger.ts +++ b/packages/libs/logger/src/pino-logger.ts @@ -42,7 +42,7 @@ export const createPinoLogger: LoggerFactory = ( ) => { const pinoLogger = pino({ base: null, - level: level || LogLevel.DEBUG, + level, enabled: disabled !== true, timestamp: false, formatters: { diff --git a/packages/libs/logger/src/types.ts b/packages/libs/logger/src/types.ts index 127d96eef4..106be0c921 100644 --- a/packages/libs/logger/src/types.ts +++ b/packages/libs/logger/src/types.ts @@ -41,3 +41,8 @@ export type LoggerFactory = ( optins: LoggerFactoryOptions, bindings?: LogMeta, ) => Logger; + +const LOG_LEVELS = Object.values(LogLevel); +export function isLogLevel(value: unknown): value is LogLevel { + return LOG_LEVELS.includes(value as LogLevel); +} diff --git a/yarn.lock b/yarn.lock index ccda1e4bb4..a25552445a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4461,7 +4461,6 @@ __metadata: "@nestjs/platform-express": "npm:^10.3.10" "@nestjs/schedule": "npm:^4.0.1" "@nestjs/schematics": "npm:^11.0.2" - "@nestjs/serve-static": "npm:^4.0.1" "@nestjs/swagger": "npm:^7.4.2" "@nestjs/terminus": "npm:^11.0.0" "@nestjs/testing": "npm:^10.4.6" @@ -6611,28 +6610,6 @@ __metadata: languageName: node linkType: hard -"@nestjs/serve-static@npm:^4.0.1": - version: 4.0.2 - resolution: "@nestjs/serve-static@npm:4.0.2" - dependencies: - path-to-regexp: "npm:0.2.5" - peerDependencies: - "@fastify/static": ^6.5.0 || ^7.0.0 - "@nestjs/common": ^9.0.0 || ^10.0.0 - "@nestjs/core": ^9.0.0 || ^10.0.0 - express: ^4.18.1 - fastify: ^4.7.0 - peerDependenciesMeta: - "@fastify/static": - optional: true - express: - optional: true - fastify: - optional: true - checksum: 10c0/e8dcf277a35a9ac3a82379ff14b8db0df9bd033f95f254d87d0a4cad9ed5bff6c6b8d9366295e38465d1f06679f5c3cca5efbf94ff52b936d26bde238199ab89 - languageName: node - linkType: hard - "@nestjs/swagger@npm:^7.4.2": version: 7.4.2 resolution: "@nestjs/swagger@npm:7.4.2" @@ -25121,13 +25098,6 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:0.2.5": - version: 0.2.5 - resolution: "path-to-regexp@npm:0.2.5" - checksum: 10c0/947ffdd583390408a4814dcb921226fba363110a8245d22bd11c2bb1db323ad76b2e879f6dadc02bcf8c9c925b1556d5405c01a466dd28a93d84af5f62c51b79 - languageName: node - linkType: hard - "path-to-regexp@npm:3.3.0": version: 3.3.0 resolution: "path-to-regexp@npm:3.3.0" From 765af4d72971dffadf1a009310ccb1f10fd405d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:00:38 +0200 Subject: [PATCH 2/7] Refactor Governance contracts (#3457) --- packages/core/GOVERNANCE.md | 205 ++++++++++++++++ packages/core/README.md | 4 + .../contracts/governance/DAOSpokeContract.sol | 15 +- .../governance/MetaHumanGovernor.sol | 231 +++++++----------- packages/core/scripts/create-proposal.ts | 4 +- packages/core/scripts/deploy-hub.ts | 18 +- packages/core/scripts/proposal.ts | 31 ++- packages/core/scripts/queue-proposal.ts | 7 +- packages/core/scripts/update-spokes.ts | 10 +- packages/core/test/DAOSpokeContract.ts | 49 ---- packages/core/test/MetaHumanGovernor.ts | 15 -- .../core/test/MetaHumanGovernorHubOnly.ts | 15 -- 12 files changed, 337 insertions(+), 267 deletions(-) create mode 100644 packages/core/GOVERNANCE.md diff --git a/packages/core/GOVERNANCE.md b/packages/core/GOVERNANCE.md new file mode 100644 index 0000000000..adb0d0fab3 --- /dev/null +++ b/packages/core/GOVERNANCE.md @@ -0,0 +1,205 @@ +

+ Human Protocol +

+ +

Human Protocol — Governance

+

Cross-chain governance contracts (Hub + Spokes) and tooling.

+ +## Overview + +The Governance system is composed of a Hub Governor (MetaHumanGovernor) and one or more Spoke contracts (DAOSpokeContract) on other chains. Proposals are created on the Hub and broadcast via Wormhole Relayer to Spokes. Voting occurs on Hub and Spokes; results are collected back to the Hub before queueing/execution through a Timelock. + +ABIs for these contracts are exported under `packages/core/abis/governance` on compile. + +## Contracts + +### MetaHumanGovernor (Hub) + +Based on OpenZeppelin Governor with extensions: + +- GovernorSettings: voting delay/period, proposal threshold (in seconds, timestamp mode) +- GovernorVotes & GovernorVotesQuorumFraction: ERC20Votes-based voting and quorum percent +- GovernorTimelockControl: queue/execute through TimelockController +- CrossChainGovernorCountingSimple: sums Hub + Spoke votes, maintains Spoke snapshots per proposal +- Magistrate: privileged role that can create cross-chain proposals and trigger certain actions + +Key functions: + +- `crossChainPropose(targets, values, calldatas, description)` (onlyMagistrate, payable): + Creates a proposal on the Hub, snapshots current Spokes, and broadcasts to all Spokes via Wormhole. +- `requestCollections(proposalId)` (payable): + After the vote period ends, requests each Spoke to send back its tallies. +- `queue(targets, values, calldatas, descriptionHash)`: Queues a successful proposal in the Timelock; requires collection phase to be finished if Spokes are involved. +- `crossChainCancel(...)` (payable): Cancels and broadcasts cancel to Spokes. +- `updateSpokeContracts(spokes)`: Owner-only (Ownable) to set active Spokes. Typically owned by Timelock. + +Constructor (simplified): + +`MetaHumanGovernor(IVotes token, TimelockController timelock, CrossChainAddress[] spokes, uint16 hubWormholeChainId, address wormholeRelayer, address magistrate, uint256 secondsPerBlock, uint48 votingDelaySeconds, uint32 votingPeriodSeconds, uint256 proposalThreshold, uint256 quorumFraction)` + +### DAOSpokeContract (Spoke) + +Receives proposal metadata from Hub and opens a local voting window based on timestamps supplied by Hub. + +- `castVote(proposalId, support)`: Vote with ERC20Votes weight at the snapshot time +- `receiveWormholeMessages(...)`: Handles incoming broadcasts from Hub +- `sendVoteResultToHub(proposalId)` (onlyMagistrate, payable): Sends tallies to Hub (also triggered when Hub calls `requestCollections`) + +Constructor (simplified): + +`DAOSpokeContract(bytes32 hubAddress, uint16 hubChainId, IVotes voteToken, uint256 targetSecondsPerBlock, uint16 spokeChainId, address wormholeRelayer, address magistrate)` + +### VHMToken (Voting token) + +Wrapper token (ERC20Votes + ERC20Wrapper) for HMT used for voting. Timestamps are used for clock mode. + +- Deploy with `VHMToken(HMT_ADDRESS)` and self-delegate to activate voting power. + +### Magistrate + +Minimal Ownable-like role with no renounce. Controls proposal creation on Hub and result sending on Spokes. Can be transferred via `transferMagistrate(newMagistrate)`. + +### Wormhole Interfaces + +`IWormholeRelayer` and `IWormholeReceiver` are used to send/receive cross-chain messages via Wormhole Automatic Relayer. + +## Environment + +Create a `.env` in `packages/core` with at least the following variables (see `.env.example` for more) + +You also need the relevant explorer API keys if you plan to verify contracts. + +## Build + +```bash +yarn install +yarn compile +``` + +## Deployment + +All commands are run from `packages/core` unless noted. Use `--network ` for the target network (see `hardhat.config.ts`). + +### 1) Deploy vHMT (voting token) + +Prereq: `HMT_TOKEN_ADDRESS` set and funded deployer key. + +```bash +npx hardhat run scripts/deploy-vhmt.ts --network +``` + +### 2) Deploy Hub (Governor + Timelock) + +Ensure Hub env vars are filled: `HUB_WORMHOLE_CHAIN_ID`, `HUB_AUTOMATIC_RELAYER_ADDRESS`, `MAGISTRATE_ADDRESS`, `HUB_SECONDS_PER_BLOCK`, voting params, and `HUB_VOTE_TOKEN_ADDRESS`. + +```bash +yarn deploy:hub --network +``` + +Optionally, transfer ownership (Ownable on Governor for spoke updates) to the Timelock: + +```bash +npx hardhat run scripts/dao-ownership.ts --network +``` + +### 3) Deploy Spokes (per Spoke chain) + +For each Spoke network, set: + +- `SPOKE_WORMHOLE_CHAIN_IDS` = Wormhole chainId of the Spoke +- `SPOKE_AUTOMATIC_RELAYER_ADDRESS` = Wormhole Automatic Relayer on the Spoke +- `SPOKE_VOTE_TOKEN_ADDRESS` = vHMT (or other IVotes) on the Spoke + +Then deploy: + +```bash +yarn deploy:spokes --network +``` + +Collect all Spoke addresses and their Wormhole chain IDs for the update step. + +### 4) Register Spokes on the Hub + +Set `SPOKE_ADDRESSES` and `SPOKE_WORMHOLE_CHAIN_IDS` as comma-separated lists, then run on the Hub network: + +```bash +yarn update:spokes --network +``` + +### 5) Self-delegate voting power (optional, for testing/quorum) + +Provide `SECOND_PRIVATE_KEY` and `THIRD_PRIVATE_KEY` and run: + +```bash +yarn hub:selfdelegate:vote --network +yarn spoke:selfdelegate:vote --network +``` + +## Proposal lifecycle + +1. Create (Hub): set `DESCRIPTION` and run: + + ```bash + yarn create:proposal --network + ``` + +2. Vote (Hub + Spokes): + + - On Hub, use standard OZ Governor voting flows (e.g., cast votes via a UI or script if enabled). + - On each Spoke, call `castVote(proposalId, support)` where support = 0 (Against), 1 (For), 2 (Abstain). Window is enforced by timestamps provided by Hub. + +3. Collect Spoke tallies (Hub): after the main voting period ends, anyone can call on Hub: + + - `requestCollections(proposalId)` (payable): triggers Spokes to send results back via Wormhole. This repository does not include a ready-made script; use Hardhat console or a block explorer to call it. Ensure enough ETH to cover relayer quotes. + +4. Queue (Hub): once `state(proposalId)` is `Succeeded`, queue in Timelock: + + - Queue is used to schedule an approved proposal in the Timelock after voting succeeds. It sets an ETA (after the timelock delay) when the proposal can be executed. + + ```bash + npx hardhat run scripts/queue-proposal.ts --network + ``` + +5. Execute (Hub): after the Timelock delay, execute with `execute(targets, values, calldatas, descriptionHash)` using the same params used for queue. You can use a block explorer or a small script. + +Notes: + +- `propose(...)` on the Hub is intentionally disabled; use `crossChainPropose(...)`. +- The Hub will return `Pending` while waiting for Spoke collection after the voting period if collection hasn’t finished. +- Fees: cross-chain messaging uses Wormhole Relayer quotes; ensure the sender funds cover costs on create and collection. + +## Verification + +Verify contracts per network using Hardhat’s verify task (examples): + +```bash +# Hub governor +npx hardhat verify --network \ + "[]" \ + \ + + +# Timelock (if needed) +npx hardhat verify --network 1 [] [] + +# Spoke (use bytes32-padded governor address) +npx hardhat verify --network \ + \ + + +# vHMT +npx hardhat verify --network +``` + +Adjust arguments/order if constructors change; consult the contract sources if verification fails. + +## Troubleshooting + +- Wrong Wormhole chain IDs or relayer addresses will cause messages to be dropped. Double-check per network. +- Insufficient ETH for relayer fees: increase the value sent on `crossChainPropose` / `requestCollections` or fund the signer. +- Not enough voting power: ensure holders self-delegate on Hub/Spokes. +- Updating Spokes requires Governor ownership; if owned by Timelock, execute an ownership-protected transaction via Timelock. + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/humanprotocol/human-protocol/blob/main/LICENSE) file for details. diff --git a/packages/core/README.md b/packages/core/README.md index 1a1d6cef77..180b21ca8d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -218,6 +218,10 @@ npx hardhat verify --network [NETWORK_NAME] [CONTRACT_ADDRESS] For detailed information about core, please refer to the [Human Protocol Tech Docs](https://human-protocol.gitbook.io/hub/human-tech-docs/architecture/components/smart-contracts). +### Governance + +For the cross-chain Governance system (Hub + Spokes), deployment, and proposal lifecycle, see the root-level [GOVERNANCE.md](./GOVERNANCE.md). The in-source, detailed reference lives at [`contracts/governance/README.md`](contracts/governance/README.md). + ## License This project is licensed under the MIT License. See the [LICENSE](https://github.com/humanprotocol/human-protocol/blob/main/LICENSE) file for details. diff --git a/packages/core/contracts/governance/DAOSpokeContract.sol b/packages/core/contracts/governance/DAOSpokeContract.sol index b30ba32141..de9f6601cb 100644 --- a/packages/core/contracts/governance/DAOSpokeContract.sol +++ b/packages/core/contracts/governance/DAOSpokeContract.sol @@ -85,13 +85,6 @@ contract DAOSpokeContract is IWormholeReceiver, Magistrate { hubContractChainId = _hubContractChainId; } - /** - * @dev Allows the magistrate address to withdraw all funds from the contract - */ - function withdrawFunds() public onlyMagistrate { - payable(msg.sender).sendValue(address(this).balance); - } - function hasVoted( uint256 proposalId, address account @@ -199,14 +192,14 @@ contract DAOSpokeContract is IWormholeReceiver, Magistrate { * @dev Receives messages from the Wormhole protocol's relay mechanism and processes them accordingly. * This function is intended to be called only by the designated Wormhole relayer. * @param payload The payload of the received message. - * @param sourceAddress The address that initiated the message transmission (HelloWormhole contract address). + * @param sourceAddress The address that initiated the message transmission (Hub contract address). * @param sourceChain The chain ID of the source contract. * @param deliveryHash A unique hash representing the delivery of the message to prevent duplicate processing. */ function receiveWormholeMessages( bytes memory payload, bytes[] memory, // additionalVaas - bytes32 sourceAddress, // address that called 'sendPayloadToEvm' (HelloWormhole contract address) + bytes32 sourceAddress, // address that called 'sendPayloadToEvm' (Hub contract address) uint16 sourceChain, bytes32 deliveryHash // this can be stored in a mapping deliveryHash => bool to prevent duplicate deliveries ) public payable override { @@ -296,7 +289,7 @@ contract DAOSpokeContract is IWormholeReceiver, Magistrate { 0, // no receiver value needed GAS_LIMIT, hubContractChainId, - address(uint160(uint256(hubContractAddress))) + magistrate() ); } } @@ -336,7 +329,7 @@ contract DAOSpokeContract is IWormholeReceiver, Magistrate { 0, // no receiver value needed GAS_LIMIT, hubContractChainId, - address(uint160(uint256(hubContractAddress))) + magistrate() ); } diff --git a/packages/core/contracts/governance/MetaHumanGovernor.sol b/packages/core/contracts/governance/MetaHumanGovernor.sol index 9d4c57e637..57d4d9fb0c 100644 --- a/packages/core/contracts/governance/MetaHumanGovernor.sol +++ b/packages/core/contracts/governance/MetaHumanGovernor.sol @@ -89,77 +89,47 @@ contract MetaHumanGovernor is secondsPerBlock = _secondsPerBlock; } - /** - * @dev Allows the magistrate address to withdraw all funds from the contract - */ - function withdrawFunds() public onlyMagistrate { - payable(msg.sender).sendValue(address(this).balance); - } - function cancel( address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash - ) public virtual override(Governor) returns (uint256) { - // First, perform the original cancellation logic. - uint256 proposalId = super.cancel( + ) public override(Governor) returns (uint256) { + uint256 proposalId = hashProposal( targets, values, calldatas, descriptionHash ); - //Notify all spoke chains about the cancellation - _notifySpokeChainsOfCancellation(proposalId); + if (spokeContractsSnapshots[proposalId].length > 0) { + revert('Please use crossChainCancel for proposals with spokes.'); + } - return proposalId; + return super.cancel(targets, values, calldatas, descriptionHash); } - function _notifySpokeChainsOfCancellation(uint256 proposalId) internal { - uint256 spokeContractsLength = spokeContractsSnapshots[proposalId] - .length; - for (uint16 i = 0; i < spokeContractsLength; i++) { - bytes memory message = abi.encode( - 2, // "2" is an inused function selector indicating cancellation - proposalId - ); - - bytes memory payload = abi.encode( - spokeContractsSnapshots[proposalId][i].contractAddress, - spokeContractsSnapshots[proposalId][i].chainId, - bytes32(uint256(uint160(address(this)))), - message - ); - - uint256 cost = quoteCrossChainMessage( - spokeContractsSnapshots[proposalId][i].chainId, - 0 - ); - - // Send cancellation message - wormholeRelayer.sendPayloadToEvm{value: cost}( - spokeContractsSnapshots[proposalId][i].chainId, - address( - uint160( - uint256( - spokeContractsSnapshots[proposalId][i] - .contractAddress - ) - ) - ), - payload, - 0, - GAS_LIMIT - ); - } + function crossChainCancel( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) external payable returns (uint256) { + uint256 proposalId = super.cancel( + targets, + values, + calldatas, + descriptionHash + ); + bytes memory message = abi.encode(uint16(2), proposalId); // selector 2 = cancel + _sendCrossChainMessageToSpokes(proposalId, message, address(this), 2); + return proposalId; } /** * @dev Receives messages from the Wormhole protocol's relay mechanism and processes them accordingly. * This function is intended to be called only by the designated Wormhole relayer. * @param payload The payload of the received message. - * @param additionalVaas An array of additional data (not used in this function). * @param sourceAddress The address that initiated the message transmission (HelloWormhole contract address). * @param sourceChain The chain ID of the source contract. * @param deliveryHash A unique hash representing the delivery of the message to prevent duplicate processing. @@ -183,7 +153,6 @@ contract MetaHumanGovernor is address intendedRecipient, //chainId , , - //sender bytes memory decodedMessage ) = abi.decode(payload, (address, uint16, address, bytes)); @@ -191,11 +160,6 @@ contract MetaHumanGovernor is revert InvalidIntendedRecipient(); } - // require( - // intendedRecipient == address(this), - // 'Message is not addressed for this contract' - // ); - processedMessages[deliveryHash] = true; // Gets a function selector option uint16 option; @@ -305,48 +269,8 @@ contract MetaHumanGovernor is _finishCollectionPhase(proposalId); } - // Get a price of sending the message back to hub - uint256 sendMessageToHubCost = quoteCrossChainMessage(chainId, 0); - - // Sends an empty message to each of the aggregators. - // If they receive a message, it is their cue to send data back - for (uint16 i = 1; i <= spokeContractsLength; ++i) { - // Using "1" as the function selector - bytes memory message = abi.encode(1, proposalId); - - uint16 spokeChainId = spokeContractsSnapshots[proposalId][i - 1] - .chainId; - address spokeAddress = address( - uint160( - uint256( - spokeContractsSnapshots[proposalId][i - 1] - .contractAddress - ) - ) - ); - - bytes memory payload = abi.encode( - spokeAddress, - spokeChainId, - msg.sender, - message - ); - - uint256 cost = quoteCrossChainMessage( - spokeChainId, - sendMessageToHubCost - ); - - wormholeRelayer.sendPayloadToEvm{value: cost}( - spokeChainId, - spokeAddress, - payload, - sendMessageToHubCost, // send value to enable the spoke to send back vote result - GAS_LIMIT, - spokeChainId, - spokeAddress - ); - } + bytes memory message = abi.encode(uint16(1), proposalId); // selector 1 = requestCollections + _sendCrossChainMessageToSpokes(proposalId, message, msg.sender, 1); } /** @@ -377,59 +301,72 @@ contract MetaHumanGovernor is uint256 voteStartTimestamp = proposalSnapshot(proposalId); uint256 voteEndTimestamp = proposalDeadline(proposalId); - // Sends the proposal to all of the other spoke contracts - if (spokeContractsSnapshots[proposalId].length > 0) { - // Iterate over every spoke contract and send a message - uint256 spokeContractsLength = spokeContractsSnapshots[proposalId] - .length; - for (uint16 i = 1; i <= spokeContractsLength; ++i) { - bytes memory message = abi.encode( - 0, // Function selector "0" for destination contract - proposalId, - block.timestamp, // proposal creation timestamp - voteStartTimestamp, //vote start timestamp - voteEndTimestamp //vote end timestamp - ); - - uint16 spokeChainId = spokeContractsSnapshots[proposalId][i - 1] - .chainId; - address spokeAddress = address( - uint160( - uint256( - spokeContractsSnapshots[proposalId][i - 1] - .contractAddress - ) + bytes memory message = abi.encode( + uint16(0), // selector 0 = propose + proposalId, + block.timestamp, + voteStartTimestamp, + voteEndTimestamp + ); + _sendCrossChainMessageToSpokes(proposalId, message, address(this), 0); + return proposalId; + } + + /** + * @dev Internal function to send a cross-chain message to all spoke contracts for a given proposal. + * @param proposalId The ID of the proposal. + * @param message The encoded message to send. + * @param sender The address to set as msg.sender in the payload (for requestCollections, otherwise address(this)). + * @param selector The function selector (0: propose, 1: requestCollections, 2: cancel). + */ + function _sendCrossChainMessageToSpokes( + uint256 proposalId, + bytes memory message, + address sender, + uint16 selector + ) internal { + uint256 spokeContractsLength = spokeContractsSnapshots[proposalId] + .length; + uint256 sendMessageToHubCost = _quoteCrossChainMessage(chainId, 0); + bool isRequestCollectionsMessage = (selector == 1); + for (uint16 i = 1; i <= spokeContractsLength; ++i) { + uint16 spokeChainId = spokeContractsSnapshots[proposalId][i - 1] + .chainId; + address spokeAddress = address( + uint160( + uint256( + spokeContractsSnapshots[proposalId][i - 1] + .contractAddress ) - ); - - bytes memory payload = abi.encode( - spokeAddress, - spokeChainId, - bytes32(uint256(uint160(address(this)))), - message - ); - - uint256 cost = quoteCrossChainMessage(spokeChainId, 0); - - wormholeRelayer.sendPayloadToEvm{value: cost}( - spokeChainId, - spokeAddress, - payload, - 0, // no receiver value needed - GAS_LIMIT, - spokeChainId, - spokeAddress - ); - } + ) + ); + bytes memory payload = abi.encode( + spokeAddress, + spokeChainId, + sender, + message + ); + uint256 cost = _quoteCrossChainMessage( + spokeChainId, + isRequestCollectionsMessage ? sendMessageToHubCost : 0 + ); + wormholeRelayer.sendPayloadToEvm{value: cost}( + spokeChainId, + spokeAddress, + payload, + isRequestCollectionsMessage ? sendMessageToHubCost : 0, + GAS_LIMIT, + spokeChainId, + magistrate() + ); } - return proposalId; } /** * @dev Retrieves the quote for cross chain message delivery. * @return cost Price, in units of current chain currency, that the delivery provider charges to perform the relay */ - function quoteCrossChainMessage( + function _quoteCrossChainMessage( uint16 targetChain, uint256 valueToSend ) internal view returns (uint256 cost) { @@ -470,18 +407,18 @@ contract MetaHumanGovernor is /** * @dev Retrieves the quorum required for voting. - * @param blockNumber The block number to calculate the quorum for. + * @param snapshotTime The timestamp (snapshot) at which to calculate the quorum * @return The required quorum percentage. */ function quorum( - uint256 blockNumber + uint256 snapshotTime ) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) { - return super.quorum(blockNumber); + return super.quorum(snapshotTime); } /** diff --git a/packages/core/scripts/create-proposal.ts b/packages/core/scripts/create-proposal.ts index ce40586a0b..3c26dd815b 100644 --- a/packages/core/scripts/create-proposal.ts +++ b/packages/core/scripts/create-proposal.ts @@ -26,11 +26,11 @@ async function main() { proposal.values, proposal.calldatas, proposal.description, - { value: ethers.parseEther('0.01') } + { value: ethers.parseEther('0.015') } ); await transactionResponse.wait(); - console.log('Proposal created:'); + console.log('Proposal created:', transactionResponse.hash); } main() diff --git a/packages/core/scripts/deploy-hub.ts b/packages/core/scripts/deploy-hub.ts index 40588c5cd3..68020a2563 100644 --- a/packages/core/scripts/deploy-hub.ts +++ b/packages/core/scripts/deploy-hub.ts @@ -24,12 +24,16 @@ async function main() { } // vHMT Deployment - const VHMToken = await ethers.getContractFactory( - 'contracts/governance/vhm-token/VHMToken.sol:VHMToken' - ); - const VHMTokenContract = await VHMToken.deploy(hmtTokenAddress); - await VHMTokenContract.waitForDeployment(); - console.log('VHMToken deployed to:', await VHMTokenContract.getAddress()); + // const VHMToken = await ethers.getContractFactory( + // 'contracts/governance/vhm-token/VHMToken.sol:VHMToken' + // ); + // const VHMTokenContract = await VHMToken.deploy(hmtTokenAddress); + // await VHMTokenContract.waitForDeployment(); + // console.log('VHMToken deployed to:', await VHMTokenContract.getAddress()); + const vhmTokenAddress = process.env.VHM_TOKEN_ADDRESS || ''; + if (!vhmTokenAddress) { + throw new Error('VHM Token Address is missing'); + } //DeployHUB const chainId = process.env.HUB_WORMHOLE_CHAIN_ID; @@ -68,7 +72,7 @@ async function main() { 'contracts/governance/MetaHumanGovernor.sol:MetaHumanGovernor' ); const metaHumanGovernorContract = await MetaHumanGovernor.deploy( - VHMTokenContract.getAddress(), + vhmTokenAddress, TimelockControllerContract.getAddress(), [], chainId, diff --git a/packages/core/scripts/proposal.ts b/packages/core/scripts/proposal.ts index 372fc770a0..609285b15d 100644 --- a/packages/core/scripts/proposal.ts +++ b/packages/core/scripts/proposal.ts @@ -14,22 +14,27 @@ export const getProposal = async () => { throw new Error('One or more required environment variables are missing.'); } - const deployerSigner = new ethers.Wallet(deployerPrivateKey, ethers.provider); - const governanceContract = await ethers.getContractAt( - 'MetaHumanGovernor', - governorAddress, - deployerSigner - ); - - const encodedCall = governanceContract.interface.encodeFunctionData( - 'setVotingPeriod', - [86400] - ); + // Keep the following commented code as a reference for how to encode a contract + // function call within a governance proposal, useful for future proposals that + // need to call contract methods through governance + + // const deployerSigner = new ethers.Wallet(deployerPrivateKey, ethers.provider); + // const governanceContract = await ethers.getContractAt( + // 'MetaHumanGovernor', + // governorAddress, + // deployerSigner + // ); + + // const encodedCall = governanceContract.interface.encodeFunctionData( + // 'setVotingPeriod', + // [3600] + // ); // Proposal data - const targets = [governorAddress]; + const targets = [ethers.ZeroAddress]; const values = [0]; - const calldatas = [encodedCall]; + // const calldatas = [encodedCall]; + const calldatas = ['0x']; // Example inputs (replace with actual values) const descriptionHash = ethers.id(description); diff --git a/packages/core/scripts/queue-proposal.ts b/packages/core/scripts/queue-proposal.ts index bfcd100902..d90ccdb09e 100644 --- a/packages/core/scripts/queue-proposal.ts +++ b/packages/core/scripts/queue-proposal.ts @@ -20,18 +20,15 @@ async function main() { ); const proposal = await getProposal(); - - const transactionResponse = await governanceContract.cancel( + const transactionResponse = await governanceContract.queue( proposal.targets, proposal.values, proposal.calldatas, proposal.descriptionHash ); - console.log(transactionResponse); - await transactionResponse.wait(); - console.log('Proposal queued:'); + console.log('Proposal queued:', transactionResponse.hash); } main() diff --git a/packages/core/scripts/update-spokes.ts b/packages/core/scripts/update-spokes.ts index 86b419e0a5..bd206a4ed7 100644 --- a/packages/core/scripts/update-spokes.ts +++ b/packages/core/scripts/update-spokes.ts @@ -24,14 +24,18 @@ async function main() { ); const spokeContracts = spokeAddresses.map((address, index) => ({ - contractAddress: ethers.zeroPadBytes(address, 32), + contractAddress: ethers.zeroPadValue(address, 32), chainId: spokeChainIds[index], })); console.log('Updating spoke contracts...'); // can only be called by the governor - await governanceContract.updateSpokeContracts(spokeContracts); - console.log('Spoke contracts updated successfully.'); + const transaction = + await governanceContract.updateSpokeContracts(spokeContracts); + console.log( + 'Spoke contracts updated successfully. TxHash:', + transaction.hash + ); } main().catch((error) => { diff --git a/packages/core/test/DAOSpokeContract.ts b/packages/core/test/DAOSpokeContract.ts index b0ea22a6d3..663e3e2c97 100644 --- a/packages/core/test/DAOSpokeContract.ts +++ b/packages/core/test/DAOSpokeContract.ts @@ -526,55 +526,6 @@ describe('DAOSpokeContract', function () { }); }); - describe('withdraw', () => { - it('should withdraw as magistrate', async () => { - await createProposalOnSpoke( - daoSpoke, - wormholeMockForDaoSpoke, - 1, - await governor.getAddress() - ); - - const contractBalanceBefore = await ethers.provider.getBalance( - await daoSpoke.getAddress() - ); - const ownerBalanceBefore = await ethers.provider.getBalance( - await owner.getAddress() - ); - - const txReceipt = await daoSpoke.connect(owner).withdrawFunds(); - const tx = await txReceipt.wait(); - - if (!tx) { - throw new Error('Failed to fetch the transaction receipt'); - } - - const contractBalanceAfter = await ethers.provider.getBalance( - await daoSpoke.getAddress() - ); - const ownerBalanceAfter = await ethers.provider.getBalance( - await owner.getAddress() - ); - - expect(contractBalanceAfter).to.equal(0); - expect(ownerBalanceAfter - ownerBalanceBefore).to.equal( - contractBalanceBefore - tx.gasUsed * tx.gasPrice - ); - }); - - it('should revert when not magistrate', async () => { - await createProposalOnSpoke( - daoSpoke, - wormholeMockForDaoSpoke, - 1, - await governor.getAddress() - ); - - await expect(daoSpoke.connect(user1).withdrawFunds()).to.be.revertedWith( - 'Magistrate: caller is not the magistrate' - ); - }); - }); describe('sendVoteResultToHub', async () => { it('should revert when not finished', async () => { const proposalId = await createProposalOnSpoke( diff --git a/packages/core/test/MetaHumanGovernor.ts b/packages/core/test/MetaHumanGovernor.ts index 02c685132f..3aa35ccaca 100644 --- a/packages/core/test/MetaHumanGovernor.ts +++ b/packages/core/test/MetaHumanGovernor.ts @@ -1359,19 +1359,4 @@ describe('MetaHumanGovernor', function () { governor.transferMagistrate(ethers.ZeroAddress) ).to.be.revertedWith('Magistrate: new magistrate is the zero address'); }); - - it('Should withdraw as Magistrate', async function () { - const contractBalance = await token.balanceOf(await governor.getAddress()); - const beforeWithdraw = await token.balanceOf(await owner.getAddress()); - await governor.connect(owner).withdrawFunds(); - const afterWithdraw = await token.balanceOf(await owner.getAddress()); - const diffference = afterWithdraw - beforeWithdraw; - expect(diffference).to.equal(contractBalance); - }); - - it('Should reverts if not magistrate tries to withdraw', async function () { - await expect(governor.connect(user1).withdrawFunds()).to.be.revertedWith( - 'Magistrate: caller is not the magistrate' - ); - }); }); diff --git a/packages/core/test/MetaHumanGovernorHubOnly.ts b/packages/core/test/MetaHumanGovernorHubOnly.ts index 189482f6cd..09e0353316 100644 --- a/packages/core/test/MetaHumanGovernorHubOnly.ts +++ b/packages/core/test/MetaHumanGovernorHubOnly.ts @@ -807,19 +807,4 @@ describe('MetaHumanGovernorHubOnly', function () { governor.transferMagistrate(ethers.ZeroAddress) ).to.be.revertedWith('Magistrate: new magistrate is the zero address'); }); - - it('Should withdraw as Magistrate', async function () { - const contractBalance = await token.balanceOf(await governor.getAddress()); - const beforeWithdraw = await token.balanceOf(await owner.getAddress()); - await governor.connect(owner).withdrawFunds(); - const afterWithdraw = await token.balanceOf(await owner.getAddress()); - const diffference = afterWithdraw - beforeWithdraw; - expect(diffference).to.equal(contractBalance); - }); - - it('Should reverts if not magistrate tries to withdraw', async function () { - await expect(governor.connect(user1).withdrawFunds()).to.be.revertedWith( - 'Magistrate: caller is not the magistrate' - ); - }); }); From c0a634ca121beee75bff06db4b13e28e5fdcb192 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:47:42 +0200 Subject: [PATCH 3/7] chore(deps): bump decimal.js from 10.5.0 to 10.6.0 (#3500) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/apps/job-launcher/client/package.json | 2 +- packages/apps/job-launcher/server/package.json | 2 +- yarn.lock | 13 ++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/apps/job-launcher/client/package.json b/packages/apps/job-launcher/client/package.json index 86a3bd68ac..77bd257b64 100644 --- a/packages/apps/job-launcher/client/package.json +++ b/packages/apps/job-launcher/client/package.json @@ -22,7 +22,7 @@ "axios": "^1.1.3", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.12", - "decimal.js": "^10.5.0", + "decimal.js": "^10.6.0", "ethers": "^6.13.5", "file-saver": "^2.0.5", "formik": "^2.4.2", diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index 6b5e607c5b..7097b9ff8e 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -54,7 +54,7 @@ "body-parser": "^1.20.3", "class-transformer": "^0.5.1", "class-validator": "0.14.1", - "decimal.js": "^10.4.3", + "decimal.js": "^10.6.0", "dotenv": "^17.0.0", "helmet": "^7.1.0", "joi": "^17.13.3", diff --git a/yarn.lock b/yarn.lock index a25552445a..9f7806b90a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4411,7 +4411,7 @@ __metadata: axios: "npm:^1.1.3" copy-to-clipboard: "npm:^3.3.3" dayjs: "npm:^1.11.12" - decimal.js: "npm:^10.5.0" + decimal.js: "npm:^10.6.0" eslint: "npm:^8.55.0" eslint-config-react-app: "npm:^7.0.1" eslint-import-resolver-typescript: "npm:^3.7.0" @@ -4483,7 +4483,7 @@ __metadata: body-parser: "npm:^1.20.3" class-transformer: "npm:^0.5.1" class-validator: "npm:0.14.1" - decimal.js: "npm:^10.4.3" + decimal.js: "npm:^10.6.0" dotenv: "npm:^17.0.0" eslint: "npm:^8.55.0" eslint-config-prettier: "npm:^9.1.0" @@ -16295,13 +16295,20 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.4.3, decimal.js@npm:^10.5.0": +"decimal.js@npm:^10.4.3": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" checksum: 10c0/785c35279df32762143914668df35948920b6c1c259b933e0519a69b7003fc0a5ed2a766b1e1dda02574450c566b21738a45f15e274b47c2ac02072c0d1f3ac3 languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "decode-uri-component@npm:^0.2.2": version: 0.2.2 resolution: "decode-uri-component@npm:0.2.2" From cf71981e4b1f79648516154ce906dab0b1cfa593 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Thu, 21 Aug 2025 11:01:09 +0300 Subject: [PATCH 4/7] feat: improve unhandled exceptions logging (#3511) --- .../server/src/common/exceptions/exception.filter.ts | 5 ++++- .../src/common/exceptions/exception.filter.ts | 5 ++++- .../server/src/common/filter/exceptions.filter.ts | 8 +++++++- .../server/src/common/exceptions/exception.filter.ts | 5 ++++- .../server/src/common/filters/exception.filter.ts | 5 ++++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts b/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts index bfc2d72a83..cc6d435f03 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts @@ -52,7 +52,10 @@ export class ExceptionFilter implements IExceptionFilter { const message = exception.message || 'Internal server error'; if (status === HttpStatus.INTERNAL_SERVER_ERROR) { - this.logger.error('Unhandled exception', exception); + this.logger.error('Unhandled exception', { + error: exception, + path: request.url, + }); } response.removeHeader('Cache-Control'); diff --git a/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts b/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts index 807da5fbec..88bbbf7a50 100644 --- a/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts +++ b/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts @@ -49,7 +49,10 @@ export class ExceptionFilter implements IExceptionFilter { const message = exception.message || 'Internal server error'; if (status === HttpStatus.INTERNAL_SERVER_ERROR) { - this.logger.error('Unhandled exception', exception); + this.logger.error('Unhandled exception', { + error: exception, + path: request.url, + }); } response.removeHeader('Cache-Control'); diff --git a/packages/apps/human-app/server/src/common/filter/exceptions.filter.ts b/packages/apps/human-app/server/src/common/filter/exceptions.filter.ts index 68f48dc675..47f17a8953 100644 --- a/packages/apps/human-app/server/src/common/filter/exceptions.filter.ts +++ b/packages/apps/human-app/server/src/common/filter/exceptions.filter.ts @@ -16,6 +16,7 @@ export class ExceptionFilter implements IExceptionFilter { catch(exception: any, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); + const request = ctx.getRequest(); let status = HttpStatus.INTERNAL_SERVER_ERROR; let message: any = 'Internal Server Error'; @@ -31,12 +32,17 @@ export class ExceptionFilter implements IExceptionFilter { let formattedError = exception; if (exception instanceof AxiosError) { formattedError = errorUtils.formatError(exception); + formattedError.outgoingRequestUrl = exception.config?.url; } - this.logger.error('Unhandled exception', formattedError); + this.logger.error('Unhandled exception', { + error: formattedError, + path: request.url, + }); } if (typeof status !== 'number' || status < 100 || status >= 600) { this.logger.error('Invalid status code in exception filter', { + path: request.url, status, }); status = HttpStatus.INTERNAL_SERVER_ERROR; diff --git a/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts b/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts index c6e34256b9..fd52737363 100644 --- a/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts +++ b/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts @@ -52,7 +52,10 @@ export class ExceptionFilter implements IExceptionFilter { const message = exception.message || 'Internal server error'; if (status === HttpStatus.INTERNAL_SERVER_ERROR) { - this.logger.error('Unhandled exception', exception); + this.logger.error('Unhandled exception', { + error: exception, + path: request.url, + }); } response.removeHeader('Cache-Control'); diff --git a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts index 6f7006a235..7d67fca996 100644 --- a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts +++ b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts @@ -56,7 +56,10 @@ export class ExceptionFilter implements IExceptionFilter { ); } } else { - this.logger.error('Unhandled exception', exception); + this.logger.error('Unhandled exception', { + error: exception, + path: request.url, + }); } response.removeHeader('Cache-Control'); From ac22e7e8d2deb572e796a61d7cadb6766b78b9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:50:02 +0200 Subject: [PATCH 5/7] [Human app] Add proposals endpoint and update frontend (#3509) --- packages/apps/human-app/frontend/.env.example | 1 - .../components/governance-banner.tsx | 76 ++++---- .../hooks/use-active-proposal-query.ts | 74 -------- .../hooks/use-proposal-query.ts | 9 + .../services/governance.service.ts | 34 ++++ .../apps/human-app/frontend/src/shared/env.ts | 1 - .../frontend/src/shared/utils/time.ts | 13 ++ packages/apps/human-app/server/.env.example | 3 + packages/apps/human-app/server/package.json | 3 +- .../apps/human-app/server/src/app.module.ts | 6 + .../config/environment-config.service.ts | 15 ++ .../server/src/common/enums/proposal.ts | 10 + .../governance/governance.controller.ts | 25 +++ .../modules/governance/governance.module.ts | 8 + .../modules/governance/governance.service.ts | 175 ++++++++++++++++++ .../governance/model/governance.model.ts | 21 +++ yarn.lock | 3 +- 17 files changed, 363 insertions(+), 114 deletions(-) delete mode 100644 packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-active-proposal-query.ts create mode 100644 packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-proposal-query.ts create mode 100644 packages/apps/human-app/frontend/src/modules/governance-banner/services/governance.service.ts create mode 100644 packages/apps/human-app/frontend/src/shared/utils/time.ts create mode 100644 packages/apps/human-app/server/src/common/enums/proposal.ts create mode 100644 packages/apps/human-app/server/src/modules/governance/governance.controller.ts create mode 100644 packages/apps/human-app/server/src/modules/governance/governance.module.ts create mode 100644 packages/apps/human-app/server/src/modules/governance/governance.service.ts create mode 100644 packages/apps/human-app/server/src/modules/governance/model/governance.model.ts diff --git a/packages/apps/human-app/frontend/.env.example b/packages/apps/human-app/frontend/.env.example index 66bd2c755b..4c0b161ec5 100644 --- a/packages/apps/human-app/frontend/.env.example +++ b/packages/apps/human-app/frontend/.env.example @@ -31,7 +31,6 @@ VITE_NAVBAR__LINK__PROTOCOL_URL=https://humanprotocol.org/ VITE_PRIVACY_POLICY_URL=http://local.app/privacy-policy/ VITE_TERMS_OF_SERVICE_URL=http://local.app/terms-and-conditions/ -VITE_GOVERNOR_ADDRESS= VITE_GOVERNANCE_URL= # Feature flags diff --git a/packages/apps/human-app/frontend/src/modules/governance-banner/components/governance-banner.tsx b/packages/apps/human-app/frontend/src/modules/governance-banner/components/governance-banner.tsx index 02617421d2..a1096e1d3d 100644 --- a/packages/apps/human-app/frontend/src/modules/governance-banner/components/governance-banner.tsx +++ b/packages/apps/human-app/frontend/src/modules/governance-banner/components/governance-banner.tsx @@ -5,51 +5,50 @@ import { useTranslation } from 'react-i18next'; import { env } from '@/shared/env'; import { useColorMode } from '@/shared/contexts/color-mode'; import { useWorkerIdentityVerificationStatus } from '@/modules/worker/profile/hooks'; -import { useActiveProposalQuery } from '../hooks/use-active-proposal-query'; +import { useProposalQuery } from '../hooks/use-proposal-query'; +import { formatCountdown } from '../../../shared/utils/time'; +import { type ProposalResponse } from '../services/governance.service'; +export type ProposalStatus = 'pending' | 'active'; + +function getProposalStatus(proposal: ProposalResponse): ProposalStatus { + const now = Date.now(); + const { voteStart, voteEnd } = proposal; + if (voteStart <= now && now < voteEnd) return 'active'; + return 'pending'; +} export function GovernanceBanner() { const { t } = useTranslation(); - const { data, isLoading, isError } = useActiveProposalQuery(); + const { data: proposal, isLoading, isError } = useProposalQuery(); const { isVerificationCompleted } = useWorkerIdentityVerificationStatus(); const { colorPalette } = useColorMode(); const { text, background } = colorPalette.banner; const [timeRemaining, setTimeRemaining] = useState('00:00:00'); useEffect(() => { - if (!data?.deadline) return; + if (!proposal) return; + const { voteStart, voteEnd } = proposal; const timer = setInterval(() => { - const now = Math.floor(Date.now() / 1000); - const diff = data.deadline - now; - - if (diff <= 0) { - setTimeRemaining('00:00:00'); - } else { - const hours = Math.floor(diff / 3600); - const minutes = Math.floor((diff % 3600) / 60); - const seconds = diff % 60; - - const hh = hours.toString().padStart(2, '0'); - const mm = minutes.toString().padStart(2, '0'); - const ss = seconds.toString().padStart(2, '0'); - - setTimeRemaining(`${hh}:${mm}:${ss}`); - } + const currentStatus = getProposalStatus(proposal); + setTimeRemaining( + formatCountdown(currentStatus === 'pending' ? voteStart : voteEnd) + ); }, 1000); return () => { clearInterval(timer); }; - }, [data?.deadline]); + }, [proposal]); - if (!isVerificationCompleted || isLoading || isError || !data) { + if (!isVerificationCompleted || isLoading || isError || !proposal) { return null; } - const forVotes = parseFloat(data.forVotes) || 0; - const againstVotes = parseFloat(data.againstVotes) || 0; - const abstainVotes = parseFloat(data.abstainVotes) || 0; - const totalVotes = forVotes + againstVotes + abstainVotes; + const status = getProposalStatus(proposal); + + const totalVotes = + proposal.forVotes + proposal.againstVotes + proposal.abstainVotes; return ( - {t('governance.timeToReveal', 'Time to reveal vote')}: + {status === 'pending' + ? t('governance.timeToStart', 'Voting starts in') + : t('governance.timeToReveal', 'Time to reveal vote')} + : {timeRemaining} - - {totalVotes} {t('governance.votes', 'votes')} - + {status === 'active' && ( + + {totalVotes} {t('governance.votes', 'votes')} + + )} {/* Right side: "More details" link */} diff --git a/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-active-proposal-query.ts b/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-active-proposal-query.ts deleted file mode 100644 index dc781ff0f1..0000000000 --- a/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-active-proposal-query.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { ethers } from 'ethers'; -import * as wagmiChains from 'wagmi/chains'; -import GovernorABI from '@/modules/smart-contracts/abi/MetaHumanGovernor.json'; -import { env } from '@/shared/env'; - -enum ProposalState { - PENDING, - ACTIVE, - CANCELED, - DEFEATED, - SUCCEEDED, - QUEUED, - EXPIRED, - EXECUTED, -} - -async function fetchActiveProposalFn() { - const provider = new ethers.JsonRpcProvider( - env.VITE_NETWORK === 'mainnet' - ? wagmiChains.polygon.rpcUrls.default.http[0] - : wagmiChains.sepolia.rpcUrls.default.http[0] - ); - const contract = new ethers.Contract( - env.VITE_GOVERNOR_ADDRESS, - GovernorABI, - provider - ); - const filter = contract.filters.ProposalCreated(); - const logs = await contract.queryFilter( - filter, - env.VITE_NETWORK === 'mainnet' - ? (await provider.getBlockNumber()) - 100000 - : (await provider.getBlockNumber()) - 10000, - 'latest' - ); - - for (const log of logs) { - const parsed = contract.interface.parseLog(log); - const proposalId = parsed?.args.proposalId as ethers.BigNumberish; - const state = Number(await contract.state(proposalId)) as ProposalState; - - if (state === ProposalState.ACTIVE) { - const votesResult = (await contract.proposalVotes(proposalId)) as [ - ethers.BigNumberish, - ethers.BigNumberish, - ethers.BigNumberish, - ]; - const [againstBn, forBn, abstainBn] = votesResult; - const forVotes = ethers.formatEther(forBn); - const againstVotes = ethers.formatEther(againstBn); - const abstainVotes = ethers.formatEther(abstainBn); - - const deadline = Number(await contract.proposalDeadline(proposalId)); - - return { - proposalId: proposalId.toString(), - forVotes, - againstVotes, - abstainVotes, - deadline, - }; - } - } - - return null; -} - -export function useActiveProposalQuery() { - return useQuery({ - queryKey: ['governanceActiveProposal'], - queryFn: fetchActiveProposalFn, - }); -} diff --git a/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-proposal-query.ts b/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-proposal-query.ts new file mode 100644 index 0000000000..4417fd2324 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/governance-banner/hooks/use-proposal-query.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchProposal } from '../services/governance.service'; + +export function useProposalQuery() { + return useQuery({ + queryKey: ['governanceProposal'], + queryFn: fetchProposal, + }); +} diff --git a/packages/apps/human-app/frontend/src/modules/governance-banner/services/governance.service.ts b/packages/apps/human-app/frontend/src/modules/governance-banner/services/governance.service.ts new file mode 100644 index 0000000000..ce04d543f8 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/governance-banner/services/governance.service.ts @@ -0,0 +1,34 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +const apiPaths = { + getProposals: '/governance/proposals', +}; + +export interface ProposalResponse { + proposalId: string; + forVotes: number; + againstVotes: number; + abstainVotes: number; + voteStart: number; + voteEnd: number; +} + +export async function fetchProposal(): Promise { + const list = await authorizedHumanAppApiClient.get( + apiPaths.getProposals + ); + if (!Array.isArray(list) || list.length === 0) return null; + + const now = Date.now(); + const activeProposals = list.filter( + (p) => p.voteStart <= now && now < p.voteEnd + ); + if (activeProposals.length > 0) + return activeProposals.sort((a, b) => a.voteEnd - b.voteEnd)[0]; + + const pendingProposals = list.filter((p) => now < p.voteStart); + if (pendingProposals.length > 0) + return pendingProposals.sort((a, b) => a.voteStart - b.voteStart)[0]; + + return null; +} diff --git a/packages/apps/human-app/frontend/src/shared/env.ts b/packages/apps/human-app/frontend/src/shared/env.ts index 6875df8211..28285fa085 100644 --- a/packages/apps/human-app/frontend/src/shared/env.ts +++ b/packages/apps/human-app/frontend/src/shared/env.ts @@ -30,7 +30,6 @@ const envSchema = z.object({ return iconsArray; }), VITE_NETWORK: z.enum(['mainnet', 'testnet']), - VITE_GOVERNOR_ADDRESS: z.string(), VITE_GOVERNANCE_URL: z.string(), VITE_H_CAPTCHA_ORACLE_ANNOTATION_TOOL: z.string(), VITE_H_CAPTCHA_ORACLE_ROLE: z.string(), diff --git a/packages/apps/human-app/frontend/src/shared/utils/time.ts b/packages/apps/human-app/frontend/src/shared/utils/time.ts new file mode 100644 index 0000000000..5aefd1f435 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/utils/time.ts @@ -0,0 +1,13 @@ +export function formatCountdown(targetMs: number): string { + const now = Date.now(); + const diffMs = targetMs - now; + if (diffMs <= 0) return '00:00:00'; + const totalSeconds = Math.floor(diffMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const hh = String(hours).padStart(2, '0'); + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} diff --git a/packages/apps/human-app/server/.env.example b/packages/apps/human-app/server/.env.example index 86af25dbfe..d009192d3c 100644 --- a/packages/apps/human-app/server/.env.example +++ b/packages/apps/human-app/server/.env.example @@ -34,3 +34,6 @@ HCAPTCHA_LABELING_API_KEY=disabled # Feature flags FEATURE_FLAG_JOBS_DISCOVERY=true + +# Governance +GOVERNOR_ADDRESS=replace_me diff --git a/packages/apps/human-app/server/package.json b/packages/apps/human-app/server/package.json index a99b3fa8ed..a747aefe53 100644 --- a/packages/apps/human-app/server/package.json +++ b/packages/apps/human-app/server/package.json @@ -24,6 +24,7 @@ "@automapper/classes": "^8.8.1", "@automapper/core": "^8.8.1", "@automapper/nestjs": "^8.8.1", + "@human-protocol/core": "workspace:*", "@human-protocol/logger": "workspace:*", "@human-protocol/sdk": "workspace:*", "@nestjs/axios": "^3.1.2", @@ -42,7 +43,7 @@ "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "0.14.1", - "ethers": "^6.13.5", + "ethers": "~6.13.5", "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 202e7d1ec1..078f1101bf 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -42,6 +42,8 @@ import { KycProcedureModule } from './modules/kyc-procedure/kyc-procedure.module import { NDAController } from './modules/nda/nda.controller'; import { NDAModule } from './modules/nda/nda.module'; import { OracleDiscoveryController } from './modules/oracle-discovery/oracle-discovery.controller'; +import { GovernanceModule } from './modules/governance/governance.module'; +import { GovernanceController } from './modules/governance/governance.controller'; import { OracleDiscoveryModule } from './modules/oracle-discovery/oracle-discovery.module'; import { PasswordResetModule } from './modules/password-reset/password-reset.module'; import { PrepareSignatureModule } from './modules/prepare-signature/prepare-signature.module'; @@ -76,6 +78,8 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); REDIS_HOST: Joi.string().required(), REDIS_DB: Joi.number(), RPC_URL: Joi.string().required(), + GOVERNANCE_RPC_URL: Joi.string(), + GOVERNOR_ADDRESS: Joi.string().required(), HCAPTCHA_LABELING_STATS_API_URL: Joi.string().required(), HCAPTCHA_LABELING_VERIFY_API_URL: Joi.string().required(), HCAPTCHA_LABELING_API_KEY: Joi.string().required(), @@ -141,6 +145,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); UiConfigurationModule, NDAModule, AbuseModule, + GovernanceModule, ], controllers: [ AppController, @@ -155,6 +160,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); TokenRefreshController, NDAController, AbuseController, + GovernanceController, ], exports: [HttpModule], providers: [ diff --git a/packages/apps/human-app/server/src/common/config/environment-config.service.ts b/packages/apps/human-app/server/src/common/config/environment-config.service.ts index 26b30da510..447e21e7e3 100644 --- a/packages/apps/human-app/server/src/common/config/environment-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/environment-config.service.ts @@ -193,6 +193,21 @@ export class EnvironmentConfigService { return this.configService.getOrThrow('RPC_URL'); } + /** + * RPC URL for the governance hub (optional). If not provided, falls back to RPC_URL. + */ + get governanceRpcUrl(): string { + return this.configService.get('GOVERNANCE_RPC_URL') || this.rpcUrl; + } + + /** + * Governor contract address used for governance queries. + * Required + */ + get governorAddress(): string { + return this.configService.getOrThrow('GOVERNOR_ADDRESS'); + } + /** * Flag indicating if CORS is enabled. * Default: false diff --git a/packages/apps/human-app/server/src/common/enums/proposal.ts b/packages/apps/human-app/server/src/common/enums/proposal.ts new file mode 100644 index 0000000000..b422a710a7 --- /dev/null +++ b/packages/apps/human-app/server/src/common/enums/proposal.ts @@ -0,0 +1,10 @@ +export enum ProposalState { + PENDING, + ACTIVE, + CANCELED, + DEFEATED, + SUCCEEDED, + QUEUED, + EXPIRED, + EXECUTED, +} diff --git a/packages/apps/human-app/server/src/modules/governance/governance.controller.ts b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts new file mode 100644 index 0000000000..13f93948e0 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, HttpCode, Header } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { GovernanceService } from './governance.service'; +import { ProposalResponse } from './model/governance.model'; + +@ApiTags('Governance') +@ApiBearerAuth() +@Controller('/governance') +export class GovernanceController { + constructor(private readonly governanceService: GovernanceService) {} + + @ApiOperation({ summary: 'Get pending and active governance proposals' }) + @ApiOkResponse({ type: ProposalResponse, isArray: true }) + @HttpCode(200) + @Header('Cache-Control', 'private, max-age=60') + @Get('/proposals') + public async getProposals(): Promise { + return this.governanceService.getProposals(); + } +} diff --git a/packages/apps/human-app/server/src/modules/governance/governance.module.ts b/packages/apps/human-app/server/src/modules/governance/governance.module.ts new file mode 100644 index 0000000000..f609898ced --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GovernanceService } from './governance.service'; + +@Module({ + providers: [GovernanceService], + exports: [GovernanceService], +}) +export class GovernanceModule {} diff --git a/packages/apps/human-app/server/src/modules/governance/governance.service.ts b/packages/apps/human-app/server/src/modules/governance/governance.service.ts new file mode 100644 index 0000000000..5eb8b0c64b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -0,0 +1,175 @@ +import { MetaHumanGovernor__factory } from '@human-protocol/core/typechain-types'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cache } from 'cache-manager'; +import { ethers } from 'ethers'; +import _ from 'lodash'; +import { EnvironmentConfigService } from '../../common/config/environment-config.service'; +import { ProposalState } from '../../common/enums/proposal'; +import { ProposalResponse } from './model/governance.model'; + +const N_BLOCKS_LOOKBACK = 100000; + +type Proposal = { + proposalId: string; + voteStart: number; + voteEnd: number; +}; + +@Injectable() +export class GovernanceService { + private readonly logger = new Logger(GovernanceService.name); + + constructor( + private readonly configService: EnvironmentConfigService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} + + public async getProposals(): Promise { + const provider = new ethers.JsonRpcProvider( + this.configService.governanceRpcUrl, + ); + const contract = MetaHumanGovernor__factory.connect( + this.configService.governorAddress, + provider, + ); + + const currentBlock = await provider.getBlockNumber(); + const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); + const proposalListKey = this.generateCacheKey('proposal', 'list'); + + const cachedLastScannedBlock = + (await this.cacheManager.get(lastScannedBlockKey)) ?? 0; + + const fromBlock = + cachedLastScannedBlock > 0 + ? cachedLastScannedBlock + 1 + : Math.max(0, currentBlock - N_BLOCKS_LOOKBACK); + + let proposalList: Proposal[] = []; + try { + const newProposals = await this.getProposalCreatedEvents( + contract, + fromBlock, + currentBlock, + ); + + const cachedList = + (await this.cacheManager.get(proposalListKey)) || []; + + proposalList = _.uniqBy([...cachedList, ...newProposals], 'proposalId'); + } catch (err) { + this.logger.warn( + 'getProposalCreatedEvents failed, falling back to cached list', + { + error: err, + }, + ); + proposalList = + (await this.cacheManager.get(proposalListKey)) || []; + } + + const proposals: ProposalResponse[] = []; + const keptProposalList: typeof proposalList = []; + + for (const proposal of proposalList) { + const voteStartMs = (proposal.voteStart ?? 0) * 1000; + const voteEndMs = (proposal.voteEnd ?? 0) * 1000; + + let state: ProposalState; + try { + state = Number( + await contract.state(proposal.proposalId), + ) as ProposalState; + } catch (err) { + this.logger.warn('Failed to fetch state for proposal', { + error: err, + proposalId: proposal.proposalId, + }); + continue; + } + + if (state !== ProposalState.PENDING && state !== ProposalState.ACTIVE) { + continue; + } + + keptProposalList.push(proposal); + + let forVotes = 0; + let againstVotes = 0; + let abstainVotes = 0; + if (state === ProposalState.ACTIVE) { + try { + const votes = (await contract.proposalVotes(proposal.proposalId)) as [ + ethers.BigNumberish, + ethers.BigNumberish, + ethers.BigNumberish, + ]; + const [againstBn, forBn, abstainBn] = votes; + forVotes = Number(ethers.formatEther(forBn)); + againstVotes = Number(ethers.formatEther(againstBn)); + abstainVotes = Number(ethers.formatEther(abstainBn)); + } catch (err) { + this.logger.warn('Failed to fetch votes for proposal', { + error: err, + proposalId: proposal.proposalId, + }); + continue; + } + } + + proposals.push({ + proposalId: proposal.proposalId, + forVotes, + againstVotes, + abstainVotes, + voteStart: voteStartMs, + voteEnd: voteEndMs, + }); + } + + await this.cacheManager.set(proposalListKey, keptProposalList); + await this.cacheManager.set(lastScannedBlockKey, currentBlock); + + return proposals; + } + + private async getProposalCreatedEvents( + contract: ReturnType, + fromBlock: number, + toBlock: number, + ): Promise { + const filter = contract.filters.ProposalCreated(); + const logs = await contract.queryFilter(filter, fromBlock, toBlock); + + const proposals: Proposal[] = []; + for (const log of logs) { + try { + const parsed = contract.interface.parseLog(log); + const proposalId = ( + parsed?.args.proposalId as ethers.BigNumberish + ).toString(); + + const proposal: Proposal = { + proposalId, + voteStart: Number(parsed?.args?.voteStart), + voteEnd: Number(parsed?.args?.voteEnd), + }; + proposals.push(proposal); + } catch (err) { + this.logger.warn('Failed to parse ProposalCreated log', { + error: err, + log, + }); + } + } + + return proposals; + } + + private generateCacheKey(...parts: (string | number)[]): string { + return ['governance', this.configService.governorAddress, ...parts] + .map(String) + .join(':'); + } +} diff --git a/packages/apps/human-app/server/src/modules/governance/model/governance.model.ts b/packages/apps/human-app/server/src/modules/governance/model/governance.model.ts new file mode 100644 index 0000000000..3774ed68c3 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/model/governance.model.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProposalResponse { + @ApiProperty() + proposalId: string; + + @ApiProperty() + forVotes: number; + + @ApiProperty() + againstVotes: number; + + @ApiProperty() + abstainVotes: number; + + @ApiProperty({ description: 'Voting start timestamp' }) + voteStart: number; + + @ApiProperty({ description: 'Voting end timestamp' }) + voteEnd: number; +} diff --git a/yarn.lock b/yarn.lock index 9f7806b90a..03a1bc3c5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4332,6 +4332,7 @@ __metadata: "@automapper/classes": "npm:^8.8.1" "@automapper/core": "npm:^8.8.1" "@automapper/nestjs": "npm:^8.8.1" + "@human-protocol/core": "workspace:*" "@human-protocol/logger": "workspace:*" "@human-protocol/sdk": "workspace:*" "@nestjs/axios": "npm:^3.1.2" @@ -4364,7 +4365,7 @@ __metadata: eslint: "npm:^8.55.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1" - ethers: "npm:^6.13.5" + ethers: "npm:~6.13.5" jest: "npm:29.7.0" joi: "npm:^17.13.3" jsonwebtoken: "npm:^9.0.2" From 21209ccfadc51db1a9e9d71c5dad8f0e94554094 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Fri, 22 Aug 2025 18:36:09 +0300 Subject: [PATCH 6/7] feat: improved error logging & exception response in Dashboard and RepO (#3514) --- packages/apps/dashboard/server/.env.example | 5 +- .../apps/dashboard/server/src/app.module.ts | 8 +++ .../src/common/filters/exception.filter.ts | 60 +++++++++++++++++++ .../escrow/escrow-utils-gateway.service.ts | 7 ++- .../escrow/spec/escrow-utils-gateway.spec.ts | 3 +- .../src/common/filters/exception.filter.ts | 16 +---- 6 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 packages/apps/dashboard/server/src/common/filters/exception.filter.ts diff --git a/packages/apps/dashboard/server/.env.example b/packages/apps/dashboard/server/.env.example index 8edb62a3e6..30998286a8 100644 --- a/packages/apps/dashboard/server/.env.example +++ b/packages/apps/dashboard/server/.env.example @@ -21,7 +21,7 @@ S3_SECRET_KEY=human-oracle-s3-secret S3_BUCKET=dashboard-hcaptcha-historical-stats S3_USE_SSL=false -#Web3 +# Web3 WEB3_ENV=testnet RPC_URL_POLYGON=https://rpc-amoy.polygon.technology RPC_URL_BSC_TESTNET=https://bsc-testnet.drpc.org @@ -29,3 +29,6 @@ RPC_URL_SEPOLIA=https://rpc.sepolia.org # Reputation Oracle URL REPUTATION_SOURCE_URL=http://0.0.0.0:5001 + +# hCaptcha stats +HCAPTCHA_STATS_ENABLED=false diff --git a/packages/apps/dashboard/server/src/app.module.ts b/packages/apps/dashboard/server/src/app.module.ts index 1b8770ffff..8726c5d6db 100644 --- a/packages/apps/dashboard/server/src/app.module.ts +++ b/packages/apps/dashboard/server/src/app.module.ts @@ -1,6 +1,7 @@ import { CacheModule } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import * as Joi from 'joi'; @@ -8,12 +9,19 @@ import * as Joi from 'joi'; import { AppController } from './app.controller'; import { CacheFactoryConfig } from './common/config/cache-factory.config'; import { CommonConfigModule } from './common/config/config.module'; +import { ExceptionFilter } from './common/filters/exception.filter'; import Environment from './common/utils/environment'; import { DetailsModule } from './modules/details/details.module'; import { NetworksModule } from './modules/networks/networks.module'; import { StatsModule } from './modules/stats/stats.module'; @Module({ + providers: [ + { + provide: APP_FILTER, + useClass: ExceptionFilter, + }, + ], imports: [ ConfigModule.forRoot({ /** diff --git a/packages/apps/dashboard/server/src/common/filters/exception.filter.ts b/packages/apps/dashboard/server/src/common/filters/exception.filter.ts new file mode 100644 index 0000000000..fe958fdd00 --- /dev/null +++ b/packages/apps/dashboard/server/src/common/filters/exception.filter.ts @@ -0,0 +1,60 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter as IExceptionFilter, + HttpStatus, + HttpException, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '../../logger'; + +@Catch() +export class ExceptionFilter implements IExceptionFilter { + private readonly logger = logger.child({ context: ExceptionFilter.name }); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + const responseBody: { message: string; [x: string]: unknown } = { + message: 'Internal server error', + }; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + if (typeof exceptionResponse === 'string') { + responseBody.message = exceptionResponse; + } else { + Object.assign( + responseBody, + { + message: exception.message, + }, + exceptionResponse, + ); + } + } else { + this.logger.error('Unhandled exception', { + error: exception, + path: request.url, + }); + } + + response.removeHeader('Cache-Control'); + + response.status(status).json( + Object.assign( + { + statusCode: status, + path: request.url, + timestamp: new Date().toISOString(), + }, + responseBody, + ), + ); + } +} diff --git a/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts index 9c9e7eb183..1ba3f8ba2a 100644 --- a/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts +++ b/packages/apps/human-app/server/src/integrations/escrow/escrow-utils-gateway.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ChainId, EscrowUtils } from '@human-protocol/sdk'; @Injectable() @@ -8,8 +8,11 @@ export class EscrowUtilsGateway { address: string, ): Promise { const escrowsData = await EscrowUtils.getEscrow(chainId, address); + if (!escrowsData) { + throw new Error('Escrow not found'); + } if (!escrowsData.exchangeOracle) { - throw new NotFoundException('Exchange Oracle not found'); + throw new Error('Escrow is missing exchange oracle address'); } return escrowsData.exchangeOracle; } diff --git a/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts b/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts index e8a521709e..2dcba36823 100644 --- a/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts +++ b/packages/apps/human-app/server/src/integrations/escrow/spec/escrow-utils-gateway.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EscrowUtilsGateway } from '../escrow-utils-gateway.service'; import { EscrowUtils, ChainId } from '@human-protocol/sdk'; -import { NotFoundException } from '@nestjs/common'; jest.mock('@human-protocol/sdk', () => { return { @@ -61,7 +60,7 @@ describe('EscrowUtilsGateway', () => { ChainId.POLYGON_AMOY, escrowAddress, ), - ).rejects.toThrow(new NotFoundException('Exchange Oracle not found')); + ).rejects.toThrow(new Error('Escrow is missing exchange oracle address')); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts index 7d67fca996..f15ee7315c 100644 --- a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts +++ b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts @@ -36,22 +36,12 @@ export class ExceptionFilter implements IExceptionFilter { const exceptionResponse = exception.getResponse(); if (typeof exceptionResponse === 'string') { responseBody.message = exceptionResponse; - } else if ( - 'error' in exceptionResponse && - exceptionResponse.error === exception.message - ) { - /** - * This is the case for "sugar" exception classes - * (e.g. UnauthorizedException) that have custom message - */ - responseBody.message = exceptionResponse.error; } else { - /** - * Exception filters called after interceptors, - * so it's just a safety belt - */ Object.assign( responseBody, + { + message: exception.message, + }, transformKeysFromCamelToSnake(exceptionResponse), ); } From ef2e5090178b04541d9fe4c803966449d0df2550 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Mon, 25 Aug 2025 15:36:13 +0300 Subject: [PATCH 7/7] [HUMAN App] fix: refetch hCaptcha stats once user solved captcha (#3515) --- .../hooks/use-solve-hcaptcha-mutation.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts index 0ce6074a3d..1662af772e 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ResponseError } from '@/shared/types/global.type'; import * as hCaptchaLabelingService from '../services/hcaptcha-labeling.service'; import { type VerifyHCaptchaLabelingBody } from '../types'; @@ -7,14 +7,22 @@ export function useSolveHCaptchaMutation(callbacks: { onSuccess: () => void | Promise; onError: (error: ResponseError) => void | Promise; }) { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: async (data: VerifyHCaptchaLabelingBody) => hCaptchaLabelingService.verifyHCaptchaLabeling(data), - onSuccess: async () => { - await callbacks.onSuccess(); + onSuccess: () => { + void callbacks.onSuccess(); + void queryClient.invalidateQueries({ + queryKey: ['getHCaptchaUsersStats'], + }); + void queryClient.invalidateQueries({ + queryKey: ['dailyHmtSpent'], + }); }, - onError: async (error) => { - await callbacks.onError(error); + onError: (error) => { + void callbacks.onError(error); }, }); }