From 3c730fe55ee4906ac4f0b70512b1dc1804a32a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 14 Aug 2025 14:02:06 +0200 Subject: [PATCH 1/9] Add governance module with active proposals functionality --- packages/apps/human-app/server/package.json | 1 + .../apps/human-app/server/src/app.module.ts | 7 ++ .../config/environment-config.service.ts | 29 +++++ .../server/src/common/constants/cache.ts | 4 + .../server/src/common/enums/proposal.ts | 10 ++ .../governance/governance.controller.ts | 19 ++++ .../modules/governance/governance.module.ts | 12 ++ .../modules/governance/governance.service.ts | 103 ++++++++++++++++++ .../governance/model/governance.model.ts | 18 +++ yarn.lock | 1 + 10 files changed, 204 insertions(+) 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/server/package.json b/packages/apps/human-app/server/package.json index a99b3fa8ed..2810485ee5 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", diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 202e7d1ec1..4a303456e8 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(), @@ -108,6 +112,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); CACHE_TTL_EXCHANGE_ORACLE_URL: Joi.number(), CACHE_TTL_EXCHANGE_ORACLE_REGISTRATION_NEEDED: Joi.number(), MAX_EXECUTIONS_TO_SKIP: Joi.number(), + CACHE_TTL_GOVERNANCE_SNAPSHOT: Joi.number(), FEATURE_FLAG_JOBS_DISCOVERY: JOI_BOOLEAN_STRING_SCHEMA, }), }), @@ -141,6 +146,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); UiConfigurationModule, NDAModule, AbuseModule, + GovernanceModule, ], controllers: [ AppController, @@ -155,6 +161,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 54980729d8..43392b4a27 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 @@ -16,6 +16,7 @@ const DEFAULT_CACHE_TTL_EXCHANGE_ORACLE_URL = 24 * 60 * 60; const DEFAULT_MAX_EXECUTIONS_TO_SKIP = 32; const DEFAULT_CACHE_TTL_JOB_TYPES = 24 * 60 * 60; const DEFAULT_CACHE_TTL_EXCHANGE_ORACLE_REGISTRATION_NEEDED = 24 * 60 * 60; +const DEFAULT_CACHE_TTL_GOVERNANCE_SNAPSHOT = 60; @Injectable() export class EnvironmentConfigService { @@ -197,6 +198,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 @@ -265,6 +281,19 @@ export class EnvironmentConfigService { ); } + /** + * The cache time-to-live (TTL) for governance vote/deadline snapshot per proposal. + * Default: 30 seconds + */ + get cacheTtlGovernanceSnapshot(): number { + return ( + this.configService.get( + 'CACHE_TTL_GOVERNANCE_SNAPSHOT', + DEFAULT_CACHE_TTL_GOVERNANCE_SNAPSHOT, + ) * 1000 + ); + } + /** * The API URL for hCaptcha labeling statistics. * Required diff --git a/packages/apps/human-app/server/src/common/constants/cache.ts b/packages/apps/human-app/server/src/common/constants/cache.ts index 004f9bf37b..458e290267 100644 --- a/packages/apps/human-app/server/src/common/constants/cache.ts +++ b/packages/apps/human-app/server/src/common/constants/cache.ts @@ -5,3 +5,7 @@ export const DAILY_HMT_SPENT_CACHE_KEY = 'daily:hmt-spent'; export const ORACLE_STATISTICS_CACHE_KEY = 'statistics:oracle'; export const WORKER_STATISTICS_CACHE_KEY = 'statistics:worker'; export const REPUTATION_ORACLE_PUBLIC_KEY = 'reputation:pubkey'; +export const GOVERNANCE_LAST_SCANNED_BLOCK = 'governance:last-block'; +export const GOVERNANCE_PROPOSAL_IDS = 'governance:proposal-ids'; +export const GOVERNANCE_PROPOSAL_SNAPSHOT = 'governance:proposal'; +export const GOVERNANCE_PROPOSAL_DEADLINE = 'governance:proposal-deadline'; 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..2ac7c08f35 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { GovernanceService } from './governance.service'; +import { ActiveProposalResponse } from './model/governance.model'; +import { Public } from 'src/common/decorators'; + +@ApiTags('Governance') +@Controller() +export class GovernanceController { + constructor(private readonly governanceService: GovernanceService) {} + + @Get('/governance/active-proposals') + @ApiOperation({ summary: 'Get active governance proposals' }) + @ApiOkResponse({ type: ActiveProposalResponse, isArray: true }) + @Public() + public async getActiveProposals(): Promise { + return this.governanceService.getActiveProposals(); + } +} 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..345db762c0 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CommonConfigModule } from '../../common/config/common-config.module'; +import { GovernanceService } from './governance.service'; +import { GovernanceController } from './governance.controller'; + +@Module({ + imports: [CommonConfigModule], + controllers: [GovernanceController], + 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..fe90abb21d --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -0,0 +1,103 @@ +import MetaHumanGovernorABI from '@human-protocol/core/abis/governance/MetaHumanGovernor.json'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; +import { Cache } from 'cache-manager'; +import { ethers } from 'ethers'; +import { EnvironmentConfigService } from '../../common/config/environment-config.service'; +import { + GOVERNANCE_LAST_SCANNED_BLOCK, + GOVERNANCE_PROPOSAL_IDS, + GOVERNANCE_PROPOSAL_SNAPSHOT, + GOVERNANCE_PROPOSAL_DEADLINE, +} from '../../common/constants/cache'; +import { ProposalState } from '../../common/enums/proposal'; +import { ActiveProposalResponse } from './model/governance.model'; + +@Injectable() +export class GovernanceService { + constructor( + private readonly configService: EnvironmentConfigService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} + + public async getActiveProposals(): Promise { + const provider = new ethers.JsonRpcProvider( + this.configService.governanceRpcUrl, + ); + const contract = new ethers.Contract( + this.configService.governorAddress, + MetaHumanGovernorABI, + provider, + ); + + const currentBlock = await provider.getBlockNumber(); + const lastBlockKey = `${GOVERNANCE_LAST_SCANNED_BLOCK}:${this.configService.governorAddress}`; + const idsKey = `${GOVERNANCE_PROPOSAL_IDS}:${this.configService.governorAddress}`; + + const defaultLookback = 100000; + const cachedLastBlock = + (await this.cacheManager.get(lastBlockKey)) ?? 0; + const fromBlock = + cachedLastBlock > 0 ? cachedLastBlock : currentBlock - defaultLookback; + const filter = contract.filters.ProposalCreated(); + const logs = await contract.queryFilter(filter, fromBlock, 'latest'); + + let cachedIds = + (await this.cacheManager.get(idsKey)) || []; + const idSet = new Set(cachedIds); + for (const log of logs) { + const parsed = contract.interface.parseLog(log); + const proposalId = ( + parsed?.args.proposalId as ethers.BigNumberish + ).toString(); + idSet.add(proposalId); + } + cachedIds = Array.from(idSet); + await this.cacheManager.set(idsKey, cachedIds, 0); + await this.cacheManager.set(lastBlockKey, currentBlock, 0); + + const active: ActiveProposalResponse[] = []; + for (const proposalId of cachedIds) { + const state = Number(await contract.state(proposalId)) as ProposalState; + if (state !== ProposalState.ACTIVE) continue; + + const snapshotKey = `${GOVERNANCE_PROPOSAL_SNAPSHOT}:${this.configService.governorAddress}:${proposalId}`; + const cachedSnapshot = + await this.cacheManager.get(snapshotKey); + if (cachedSnapshot) { + active.push(cachedSnapshot); + continue; + } + + const deadlineKey = `${GOVERNANCE_PROPOSAL_DEADLINE}:${this.configService.governorAddress}:${proposalId}`; + let deadline = await this.cacheManager.get(deadlineKey); + if (!deadline) { + deadline = Number(await contract.proposalDeadline(proposalId)); + await this.cacheManager.set(deadlineKey, deadline, 0); + } + + const votes = (await contract.proposalVotes(proposalId)) as [ + ethers.BigNumberish, + ethers.BigNumberish, + ethers.BigNumberish, + ]; + const [againstBn, forBn, abstainBn] = votes; + + const snapshot: ActiveProposalResponse = { + proposalId: proposalId.toString(), + forVotes: Number(ethers.formatEther(forBn)), + againstVotes: Number(ethers.formatEther(againstBn)), + abstainVotes: Number(ethers.formatEther(abstainBn)), + deadline, + }; + active.push(snapshot); + await this.cacheManager.set( + snapshotKey, + snapshot, + this.configService.cacheTtlGovernanceSnapshot, + ); + } + + return active; + } +} 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..1cc17f7d4f --- /dev/null +++ b/packages/apps/human-app/server/src/modules/governance/model/governance.model.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ActiveProposalResponse { + @ApiProperty() + proposalId: string; + + @ApiProperty() + forVotes: number; + + @ApiProperty() + againstVotes: number; + + @ApiProperty() + abstainVotes: number; + + @ApiProperty({ description: 'Deadline timestamp (seconds)' }) + deadline: number; +} diff --git a/yarn.lock b/yarn.lock index ccda1e4bb4..9a25a82ee2 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" From f5de50c11055374e736c0841e43206d69c217183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 14 Aug 2025 16:20:41 +0200 Subject: [PATCH 2/9] Require auth for getting active proposals --- .../server/src/modules/governance/governance.controller.ts | 1 - 1 file changed, 1 deletion(-) 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 index 2ac7c08f35..f115acf0cc 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.controller.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -12,7 +12,6 @@ export class GovernanceController { @Get('/governance/active-proposals') @ApiOperation({ summary: 'Get active governance proposals' }) @ApiOkResponse({ type: ActiveProposalResponse, isArray: true }) - @Public() public async getActiveProposals(): Promise { return this.governanceService.getActiveProposals(); } From c7633ec36fefcc06b228d8a0bca60d19a099f58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 14 Aug 2025 16:21:57 +0200 Subject: [PATCH 3/9] remove unused import from governance controller --- .../server/src/modules/governance/governance.controller.ts | 1 - 1 file changed, 1 deletion(-) 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 index f115acf0cc..72359dfc2a 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.controller.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -2,7 +2,6 @@ import { Controller, Get } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { GovernanceService } from './governance.service'; import { ActiveProposalResponse } from './model/governance.model'; -import { Public } from 'src/common/decorators'; @ApiTags('Governance') @Controller() From 6499c31a42ac1dcd9c0125ca103bc884cfad3454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 15 Aug 2025 13:31:20 +0200 Subject: [PATCH 4/9] update governance proposal handling and API integration --- packages/apps/human-app/frontend/.env.example | 1 - .../components/governance-banner.tsx | 50 +++++--- .../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 - .../server/src/common/constants/cache.ts | 2 +- .../governance/governance.controller.ts | 25 ++-- .../modules/governance/governance.service.ts | 108 +++++++++++++++--- .../governance/model/governance.model.ts | 9 +- 10 files changed, 187 insertions(+), 126 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 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..9ef62adf24 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,22 +5,25 @@ 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'; export function GovernanceBanner() { const { t } = useTranslation(); - const { data, isLoading, isError } = useActiveProposalQuery(); + const { data, 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 (!data) return; + const { voteStart, voteEnd } = data; + if (!voteStart || !voteEnd) return; const timer = setInterval(() => { const now = Math.floor(Date.now() / 1000); - const diff = data.deadline - now; + const target = now < voteStart ? voteStart : voteEnd; + const diff = target - now; if (diff <= 0) { setTimeRemaining('00:00:00'); @@ -40,15 +43,19 @@ export function GovernanceBanner() { return () => { clearInterval(timer); }; - }, [data?.deadline]); + }, [data]); if (!isVerificationCompleted || isLoading || isError || !data) { return null; } - const forVotes = parseFloat(data.forVotes) || 0; - const againstVotes = parseFloat(data.againstVotes) || 0; - const abstainVotes = parseFloat(data.abstainVotes) || 0; + const now = Math.floor(Date.now() / 1000); + const isPending = now < data.voteStart; + const isActive = data.voteStart <= now && now < data.voteEnd; + + const forVotes = isActive ? data.forVotes : 0; + const againstVotes = isActive ? data.againstVotes : 0; + const abstainVotes = isActive ? data.abstainVotes : 0; const totalVotes = forVotes + againstVotes + abstainVotes; return ( @@ -76,22 +83,27 @@ export function GovernanceBanner() { - {t('governance.timeToReveal', 'Time to reveal vote')}: + {isPending + ? t('governance.timeToStart', 'Voting starts in') + : t('governance.timeToReveal', 'Time to reveal vote')} + : {timeRemaining} - - {totalVotes} {t('governance.votes', 'votes')} - + {isActive && ( + + {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..d1b42d7fbb --- /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 = Math.floor(Date.now() / 1000); + const active = list + .filter((p) => p.voteStart <= now && now < p.voteEnd) + .sort((a, b) => a.voteEnd - b.voteEnd); + if (active.length > 0) return active[0]; + + const pending = list + .filter((p) => now < p.voteStart) + .sort((a, b) => a.voteStart - b.voteStart); + if (pending.length > 0) return pending[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/server/src/common/constants/cache.ts b/packages/apps/human-app/server/src/common/constants/cache.ts index 458e290267..8f8fe323d5 100644 --- a/packages/apps/human-app/server/src/common/constants/cache.ts +++ b/packages/apps/human-app/server/src/common/constants/cache.ts @@ -8,4 +8,4 @@ export const REPUTATION_ORACLE_PUBLIC_KEY = 'reputation:pubkey'; export const GOVERNANCE_LAST_SCANNED_BLOCK = 'governance:last-block'; export const GOVERNANCE_PROPOSAL_IDS = 'governance:proposal-ids'; export const GOVERNANCE_PROPOSAL_SNAPSHOT = 'governance:proposal'; -export const GOVERNANCE_PROPOSAL_DEADLINE = 'governance:proposal-deadline'; +export const GOVERNANCE_PROPOSAL_META = 'governance:proposal-meta'; 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 index 72359dfc2a..b9583790ac 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.controller.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -1,17 +1,24 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, HttpCode } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { GovernanceService } from './governance.service'; -import { ActiveProposalResponse } from './model/governance.model'; +import { ProposalResponse } from './model/governance.model'; @ApiTags('Governance') -@Controller() +@ApiBearerAuth() +@Controller('/governance') export class GovernanceController { constructor(private readonly governanceService: GovernanceService) {} - @Get('/governance/active-proposals') - @ApiOperation({ summary: 'Get active governance proposals' }) - @ApiOkResponse({ type: ActiveProposalResponse, isArray: true }) - public async getActiveProposals(): Promise { - return this.governanceService.getActiveProposals(); + @ApiOperation({ summary: 'Get pending and active governance proposals' }) + @ApiOkResponse({ type: ProposalResponse, isArray: true }) + @HttpCode(200) + @Get('/proposals') + public async getProposals(): Promise { + return this.governanceService.getProposals(); } } 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 index fe90abb21d..1669129284 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.service.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -8,10 +8,10 @@ import { GOVERNANCE_LAST_SCANNED_BLOCK, GOVERNANCE_PROPOSAL_IDS, GOVERNANCE_PROPOSAL_SNAPSHOT, - GOVERNANCE_PROPOSAL_DEADLINE, + GOVERNANCE_PROPOSAL_META, } from '../../common/constants/cache'; import { ProposalState } from '../../common/enums/proposal'; -import { ActiveProposalResponse } from './model/governance.model'; +import { ProposalResponse } from './model/governance.model'; @Injectable() export class GovernanceService { @@ -20,7 +20,7 @@ export class GovernanceService { @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, ) {} - public async getActiveProposals(): Promise { + public async getProposals(): Promise { const provider = new ethers.JsonRpcProvider( this.configService.governanceRpcUrl, ); @@ -37,8 +37,11 @@ export class GovernanceService { const defaultLookback = 100000; const cachedLastBlock = (await this.cacheManager.get(lastBlockKey)) ?? 0; - const fromBlock = - cachedLastBlock > 0 ? cachedLastBlock : currentBlock - defaultLookback; + const fromBlockBase = + cachedLastBlock > 0 + ? cachedLastBlock + 1 + : Math.max(0, currentBlock - defaultLookback); + const fromBlock = Math.min(fromBlockBase, currentBlock); const filter = contract.filters.ProposalCreated(); const logs = await contract.queryFilter(filter, fromBlock, 'latest'); @@ -51,31 +54,97 @@ export class GovernanceService { parsed?.args.proposalId as ethers.BigNumberish ).toString(); idSet.add(proposalId); + + try { + const voteStartBn = parsed?.args?.voteStart as + | ethers.BigNumberish + | undefined; + const voteEndBn = parsed?.args?.voteEnd as + | ethers.BigNumberish + | undefined; + + if (voteStartBn !== undefined || voteEndBn !== undefined) { + const metaKey = `${GOVERNANCE_PROPOSAL_META}:${this.configService.governorAddress}:${proposalId}`; + const meta = (await this.cacheManager.get<{ + proposalId: string; + voteStart?: number; + voteEnd?: number; + }>(metaKey)) || { proposalId }; + + if (voteStartBn !== undefined) { + meta.voteStart = Number( + ethers.toNumber?.(voteStartBn as any) ?? voteStartBn.toString(), + ); + } + if (voteEndBn !== undefined) { + meta.voteEnd = Number( + ethers.toNumber?.(voteEndBn as any) ?? voteEndBn.toString(), + ); + } + + await this.cacheManager.set(metaKey, meta, 0); + } + } catch {} } cachedIds = Array.from(idSet); await this.cacheManager.set(idsKey, cachedIds, 0); - await this.cacheManager.set(lastBlockKey, currentBlock, 0); + const lastScannedBlock = logs.length + ? Number(logs[logs.length - 1].blockNumber) + : currentBlock; + await this.cacheManager.set(lastBlockKey, lastScannedBlock, 0); - const active: ActiveProposalResponse[] = []; + const active: ProposalResponse[] = []; + const keptIds: string[] = []; for (const proposalId of cachedIds) { const state = Number(await contract.state(proposalId)) as ProposalState; - if (state !== ProposalState.ACTIVE) continue; + + const metaKey = `${GOVERNANCE_PROPOSAL_META}:${this.configService.governorAddress}:${proposalId}`; + const meta = (await this.cacheManager.get<{ + proposalId: string; + voteStart?: number; + voteEnd?: number; + }>(metaKey)) || { proposalId }; + + if (meta.voteEnd === undefined) { + meta.voteEnd = Number(await contract.proposalDeadline(proposalId)); + } + if (meta.voteStart === undefined) { + const snapshotBlock = Number( + await contract.proposalSnapshot(proposalId), + ); + const block = await provider.getBlock(snapshotBlock); + meta.voteStart = block?.timestamp ?? 0; + } + await this.cacheManager.set(metaKey, meta, 0); + + if (state === ProposalState.PENDING) { + keptIds.push(proposalId); + + active.push({ + proposalId: proposalId.toString(), + forVotes: 0, + againstVotes: 0, + abstainVotes: 0, + voteStart: meta.voteStart ?? 0, + voteEnd: meta.voteEnd ?? 0, + }); + continue; + } + + if (state !== ProposalState.ACTIVE) { + continue; + } + + keptIds.push(proposalId); const snapshotKey = `${GOVERNANCE_PROPOSAL_SNAPSHOT}:${this.configService.governorAddress}:${proposalId}`; const cachedSnapshot = - await this.cacheManager.get(snapshotKey); + await this.cacheManager.get(snapshotKey); if (cachedSnapshot) { active.push(cachedSnapshot); continue; } - const deadlineKey = `${GOVERNANCE_PROPOSAL_DEADLINE}:${this.configService.governorAddress}:${proposalId}`; - let deadline = await this.cacheManager.get(deadlineKey); - if (!deadline) { - deadline = Number(await contract.proposalDeadline(proposalId)); - await this.cacheManager.set(deadlineKey, deadline, 0); - } - const votes = (await contract.proposalVotes(proposalId)) as [ ethers.BigNumberish, ethers.BigNumberish, @@ -83,12 +152,13 @@ export class GovernanceService { ]; const [againstBn, forBn, abstainBn] = votes; - const snapshot: ActiveProposalResponse = { + const snapshot: ProposalResponse = { proposalId: proposalId.toString(), forVotes: Number(ethers.formatEther(forBn)), againstVotes: Number(ethers.formatEther(againstBn)), abstainVotes: Number(ethers.formatEther(abstainBn)), - deadline, + voteStart: meta.voteStart ?? 0, + voteEnd: meta.voteEnd ?? 0, }; active.push(snapshot); await this.cacheManager.set( @@ -98,6 +168,8 @@ export class GovernanceService { ); } + await this.cacheManager.set(idsKey, keptIds, 0); + return active; } } 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 index 1cc17f7d4f..a01f2a1488 100644 --- 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 @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -export class ActiveProposalResponse { +export class ProposalResponse { @ApiProperty() proposalId: string; @@ -13,6 +13,9 @@ export class ActiveProposalResponse { @ApiProperty() abstainVotes: number; - @ApiProperty({ description: 'Deadline timestamp (seconds)' }) - deadline: number; + @ApiProperty({ description: 'Voting start timestamp (seconds)' }) + voteStart: number; + + @ApiProperty({ description: 'Voting end timestamp (seconds)' }) + voteEnd: number; } From 5ad605c8bac37da5fdbc8068e7c03ef081406b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 19 Aug 2025 11:21:43 +0200 Subject: [PATCH 5/9] improve proposal handling and update timestamps to milliseconds --- .../components/governance-banner.tsx | 11 +- .../services/governance.service.ts | 16 +- packages/apps/human-app/server/.env.example | 3 + packages/apps/human-app/server/package.json | 2 +- .../config/environment-config.service.ts | 14 - .../server/src/common/constants/cache.ts | 1 - .../governance/governance.controller.ts | 3 +- .../modules/governance/governance.module.ts | 4 - .../modules/governance/governance.service.ts | 241 +++++++++--------- .../governance/model/governance.model.ts | 4 +- yarn.lock | 2 +- 11 files changed, 144 insertions(+), 157 deletions(-) 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 9ef62adf24..356fbd5073 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 @@ -18,19 +18,18 @@ export function GovernanceBanner() { useEffect(() => { if (!data) return; const { voteStart, voteEnd } = data; - if (!voteStart || !voteEnd) return; const timer = setInterval(() => { - const now = Math.floor(Date.now() / 1000); + const now = Date.now(); const target = now < voteStart ? voteStart : voteEnd; const diff = target - 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 hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); const hh = hours.toString().padStart(2, '0'); const mm = minutes.toString().padStart(2, '0'); @@ -49,7 +48,7 @@ export function GovernanceBanner() { return null; } - const now = Math.floor(Date.now() / 1000); + const now = Date.now(); const isPending = now < data.voteStart; const isActive = data.voteStart <= now && now < data.voteEnd; 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 index d1b42d7fbb..b450202f56 100644 --- 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 @@ -20,15 +20,15 @@ export async function fetchProposal(): Promise { if (!Array.isArray(list) || list.length === 0) return null; const now = Math.floor(Date.now() / 1000); - const active = list - .filter((p) => p.voteStart <= now && now < p.voteEnd) - .sort((a, b) => a.voteEnd - b.voteEnd); - if (active.length > 0) return active[0]; + 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 pending = list - .filter((p) => now < p.voteStart) - .sort((a, b) => a.voteStart - b.voteStart); - if (pending.length > 0) return pending[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/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 2810485ee5..a747aefe53 100644 --- a/packages/apps/human-app/server/package.json +++ b/packages/apps/human-app/server/package.json @@ -43,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/common/config/environment-config.service.ts b/packages/apps/human-app/server/src/common/config/environment-config.service.ts index 43392b4a27..8890993f6c 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 @@ -16,7 +16,6 @@ const DEFAULT_CACHE_TTL_EXCHANGE_ORACLE_URL = 24 * 60 * 60; const DEFAULT_MAX_EXECUTIONS_TO_SKIP = 32; const DEFAULT_CACHE_TTL_JOB_TYPES = 24 * 60 * 60; const DEFAULT_CACHE_TTL_EXCHANGE_ORACLE_REGISTRATION_NEEDED = 24 * 60 * 60; -const DEFAULT_CACHE_TTL_GOVERNANCE_SNAPSHOT = 60; @Injectable() export class EnvironmentConfigService { @@ -281,19 +280,6 @@ export class EnvironmentConfigService { ); } - /** - * The cache time-to-live (TTL) for governance vote/deadline snapshot per proposal. - * Default: 30 seconds - */ - get cacheTtlGovernanceSnapshot(): number { - return ( - this.configService.get( - 'CACHE_TTL_GOVERNANCE_SNAPSHOT', - DEFAULT_CACHE_TTL_GOVERNANCE_SNAPSHOT, - ) * 1000 - ); - } - /** * The API URL for hCaptcha labeling statistics. * Required diff --git a/packages/apps/human-app/server/src/common/constants/cache.ts b/packages/apps/human-app/server/src/common/constants/cache.ts index 8f8fe323d5..3d2f8d27c5 100644 --- a/packages/apps/human-app/server/src/common/constants/cache.ts +++ b/packages/apps/human-app/server/src/common/constants/cache.ts @@ -7,5 +7,4 @@ export const WORKER_STATISTICS_CACHE_KEY = 'statistics:worker'; export const REPUTATION_ORACLE_PUBLIC_KEY = 'reputation:pubkey'; export const GOVERNANCE_LAST_SCANNED_BLOCK = 'governance:last-block'; export const GOVERNANCE_PROPOSAL_IDS = 'governance:proposal-ids'; -export const GOVERNANCE_PROPOSAL_SNAPSHOT = 'governance:proposal'; export const GOVERNANCE_PROPOSAL_META = 'governance:proposal-meta'; 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 index b9583790ac..13f93948e0 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.controller.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode } from '@nestjs/common'; +import { Controller, Get, HttpCode, Header } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, @@ -17,6 +17,7 @@ export class GovernanceController { @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 index 345db762c0..f609898ced 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.module.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.module.ts @@ -1,11 +1,7 @@ import { Module } from '@nestjs/common'; -import { CommonConfigModule } from '../../common/config/common-config.module'; import { GovernanceService } from './governance.service'; -import { GovernanceController } from './governance.controller'; @Module({ - imports: [CommonConfigModule], - controllers: [GovernanceController], providers: [GovernanceService], exports: [GovernanceService], }) 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 index 1669129284..f1dda34907 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.service.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -1,20 +1,24 @@ -import MetaHumanGovernorABI from '@human-protocol/core/abis/governance/MetaHumanGovernor.json'; +import { MetaHumanGovernor__factory } from '@human-protocol/core/typechain-types'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Cache } from 'cache-manager'; import { ethers } from 'ethers'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; -import { - GOVERNANCE_LAST_SCANNED_BLOCK, - GOVERNANCE_PROPOSAL_IDS, - GOVERNANCE_PROPOSAL_SNAPSHOT, - GOVERNANCE_PROPOSAL_META, -} from '../../common/constants/cache'; 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, @@ -24,109 +28,62 @@ export class GovernanceService { const provider = new ethers.JsonRpcProvider( this.configService.governanceRpcUrl, ); - const contract = new ethers.Contract( + const contract = MetaHumanGovernor__factory.connect( this.configService.governorAddress, - MetaHumanGovernorABI, provider, ); const currentBlock = await provider.getBlockNumber(); - const lastBlockKey = `${GOVERNANCE_LAST_SCANNED_BLOCK}:${this.configService.governorAddress}`; - const idsKey = `${GOVERNANCE_PROPOSAL_IDS}:${this.configService.governorAddress}`; + const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); + const proposalListKey = this.generateCacheKey('proposal', 'list'); + + const cachedLastScannedBlock = + (await this.cacheManager.get(lastScannedBlockKey)) ?? 0; - const defaultLookback = 100000; - const cachedLastBlock = - (await this.cacheManager.get(lastBlockKey)) ?? 0; const fromBlockBase = - cachedLastBlock > 0 - ? cachedLastBlock + 1 - : Math.max(0, currentBlock - defaultLookback); + cachedLastScannedBlock > 0 + ? cachedLastScannedBlock + 1 + : Math.max(0, currentBlock - N_BLOCKS_LOOKBACK); const fromBlock = Math.min(fromBlockBase, currentBlock); - const filter = contract.filters.ProposalCreated(); - const logs = await contract.queryFilter(filter, fromBlock, 'latest'); - let cachedIds = - (await this.cacheManager.get(idsKey)) || []; - const idSet = new Set(cachedIds); - for (const log of logs) { - const parsed = contract.interface.parseLog(log); - const proposalId = ( - parsed?.args.proposalId as ethers.BigNumberish - ).toString(); - idSet.add(proposalId); + let proposalList: Proposal[] = []; + try { + proposalList = await this.getProposalCreatedEvents(contract, fromBlock); + } catch (err) { + this.logger.warn( + `getProposalCreatedEvents failed, falling back to cached list: ${ + (err as Error)?.message || err + }`, + ); + proposalList = + (await this.cacheManager.get(proposalListKey)) || []; + } + + const proposals: ProposalResponse[] = []; + const keptProposalList: typeof proposalList = []; + for (const proposal of proposalList) { + let state: ProposalState; try { - const voteStartBn = parsed?.args?.voteStart as - | ethers.BigNumberish - | undefined; - const voteEndBn = parsed?.args?.voteEnd as - | ethers.BigNumberish - | undefined; - - if (voteStartBn !== undefined || voteEndBn !== undefined) { - const metaKey = `${GOVERNANCE_PROPOSAL_META}:${this.configService.governorAddress}:${proposalId}`; - const meta = (await this.cacheManager.get<{ - proposalId: string; - voteStart?: number; - voteEnd?: number; - }>(metaKey)) || { proposalId }; - - if (voteStartBn !== undefined) { - meta.voteStart = Number( - ethers.toNumber?.(voteStartBn as any) ?? voteStartBn.toString(), - ); - } - if (voteEndBn !== undefined) { - meta.voteEnd = Number( - ethers.toNumber?.(voteEndBn as any) ?? voteEndBn.toString(), - ); - } - - await this.cacheManager.set(metaKey, meta, 0); - } - } catch {} - } - cachedIds = Array.from(idSet); - await this.cacheManager.set(idsKey, cachedIds, 0); - const lastScannedBlock = logs.length - ? Number(logs[logs.length - 1].blockNumber) - : currentBlock; - await this.cacheManager.set(lastBlockKey, lastScannedBlock, 0); - - const active: ProposalResponse[] = []; - const keptIds: string[] = []; - for (const proposalId of cachedIds) { - const state = Number(await contract.state(proposalId)) as ProposalState; - - const metaKey = `${GOVERNANCE_PROPOSAL_META}:${this.configService.governorAddress}:${proposalId}`; - const meta = (await this.cacheManager.get<{ - proposalId: string; - voteStart?: number; - voteEnd?: number; - }>(metaKey)) || { proposalId }; - - if (meta.voteEnd === undefined) { - meta.voteEnd = Number(await contract.proposalDeadline(proposalId)); - } - if (meta.voteStart === undefined) { - const snapshotBlock = Number( - await contract.proposalSnapshot(proposalId), + state = Number( + await contract.state(proposal.proposalId), + ) as ProposalState; + } catch (err) { + this.logger.warn( + `Failed to fetch state for proposal ${proposal.proposalId}: ${(err as Error)?.message || err}`, ); - const block = await provider.getBlock(snapshotBlock); - meta.voteStart = block?.timestamp ?? 0; + continue; } - await this.cacheManager.set(metaKey, meta, 0); if (state === ProposalState.PENDING) { - keptIds.push(proposalId); - - active.push({ - proposalId: proposalId.toString(), + keptProposalList.push(proposal); + proposals.push({ + proposalId: proposal.proposalId, forVotes: 0, againstVotes: 0, abstainVotes: 0, - voteStart: meta.voteStart ?? 0, - voteEnd: meta.voteEnd ?? 0, + voteStart: (proposal.voteStart ?? 0) * 1000, + voteEnd: (proposal.voteEnd ?? 0) * 1000, }); continue; } @@ -135,41 +92,87 @@ export class GovernanceService { continue; } - keptIds.push(proposalId); + keptProposalList.push(proposal); - const snapshotKey = `${GOVERNANCE_PROPOSAL_SNAPSHOT}:${this.configService.governorAddress}:${proposalId}`; - const cachedSnapshot = - await this.cacheManager.get(snapshotKey); - if (cachedSnapshot) { - active.push(cachedSnapshot); + let againstBn: ethers.BigNumberish, + forBn: ethers.BigNumberish, + abstainBn: ethers.BigNumberish; + try { + const votes = (await contract.proposalVotes(proposal.proposalId)) as [ + ethers.BigNumberish, + ethers.BigNumberish, + ethers.BigNumberish, + ]; + [againstBn, forBn, abstainBn] = votes; + } catch (err) { + this.logger.warn( + `Failed to fetch votes for proposal ${proposal.proposalId}: ${(err as Error)?.message || err}`, + ); continue; } - const votes = (await contract.proposalVotes(proposalId)) as [ - ethers.BigNumberish, - ethers.BigNumberish, - ethers.BigNumberish, - ]; - const [againstBn, forBn, abstainBn] = votes; - - const snapshot: ProposalResponse = { - proposalId: proposalId.toString(), + proposals.push({ + proposalId: proposal.proposalId, forVotes: Number(ethers.formatEther(forBn)), againstVotes: Number(ethers.formatEther(againstBn)), abstainVotes: Number(ethers.formatEther(abstainBn)), - voteStart: meta.voteStart ?? 0, - voteEnd: meta.voteEnd ?? 0, - }; - active.push(snapshot); - await this.cacheManager.set( - snapshotKey, - snapshot, - this.configService.cacheTtlGovernanceSnapshot, - ); + voteStart: (proposal.voteStart ?? 0) * 1000, + voteEnd: (proposal.voteEnd ?? 0) * 1000, + }); } - await this.cacheManager.set(idsKey, keptIds, 0); + await this.cacheManager.set(proposalListKey, keptProposalList, 0); + + return proposals; + } + + private generateCacheKey(...parts: (string | number)[]): string { + return ['governance', this.configService.governorAddress, ...parts] + .map(String) + .join(':'); + } + + private async getProposalCreatedEvents( + contract: ReturnType, + fromBlock: number, + ): Promise { + const proposalListKey = this.generateCacheKey('proposal', 'list'); + const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); + + const filter = contract.filters.ProposalCreated(); + const logs = await contract.queryFilter(filter, fromBlock, 'latest'); + + const cachedList: Proposal[] = + (await this.cacheManager.get(proposalListKey)) || []; + const nextList: Proposal[] = [...cachedList]; + + 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), + }; + nextList.push(proposal); + } catch (err) { + this.logger.warn( + `Failed to parse/cache ProposalCreated log: ${(err as Error)?.message || err}`, + ); + } + } + + await this.cacheManager.set(proposalListKey, nextList, 0); + + const lastScannedBlock = logs.length + ? Number(logs[logs.length - 1].blockNumber) + : fromBlock; + await this.cacheManager.set(lastScannedBlockKey, lastScannedBlock, 0); - return active; + return nextList; } } 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 index a01f2a1488..3774ed68c3 100644 --- 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 @@ -13,9 +13,9 @@ export class ProposalResponse { @ApiProperty() abstainVotes: number; - @ApiProperty({ description: 'Voting start timestamp (seconds)' }) + @ApiProperty({ description: 'Voting start timestamp' }) voteStart: number; - @ApiProperty({ description: 'Voting end timestamp (seconds)' }) + @ApiProperty({ description: 'Voting end timestamp' }) voteEnd: number; } diff --git a/yarn.lock b/yarn.lock index 9a25a82ee2..d94b8e2800 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4365,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 d3f627cf491e16dacd3103517f5b0f9088f64fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 19 Aug 2025 11:29:52 +0200 Subject: [PATCH 6/9] remove unused governance cache constants --- packages/apps/human-app/server/src/common/constants/cache.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/apps/human-app/server/src/common/constants/cache.ts b/packages/apps/human-app/server/src/common/constants/cache.ts index 3d2f8d27c5..004f9bf37b 100644 --- a/packages/apps/human-app/server/src/common/constants/cache.ts +++ b/packages/apps/human-app/server/src/common/constants/cache.ts @@ -5,6 +5,3 @@ export const DAILY_HMT_SPENT_CACHE_KEY = 'daily:hmt-spent'; export const ORACLE_STATISTICS_CACHE_KEY = 'statistics:oracle'; export const WORKER_STATISTICS_CACHE_KEY = 'statistics:worker'; export const REPUTATION_ORACLE_PUBLIC_KEY = 'reputation:pubkey'; -export const GOVERNANCE_LAST_SCANNED_BLOCK = 'governance:last-block'; -export const GOVERNANCE_PROPOSAL_IDS = 'governance:proposal-ids'; -export const GOVERNANCE_PROPOSAL_META = 'governance:proposal-meta'; From 0c483dbf11d996a51ecb7ff17bbe32c844ddad1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 19 Aug 2025 11:31:04 +0200 Subject: [PATCH 7/9] remove unused CACHE_TTL_GOVERNANCE_SNAPSHOT constant --- packages/apps/human-app/server/src/app.module.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 4a303456e8..078f1101bf 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -112,7 +112,6 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); CACHE_TTL_EXCHANGE_ORACLE_URL: Joi.number(), CACHE_TTL_EXCHANGE_ORACLE_REGISTRATION_NEEDED: Joi.number(), MAX_EXECUTIONS_TO_SKIP: Joi.number(), - CACHE_TTL_GOVERNANCE_SNAPSHOT: Joi.number(), FEATURE_FLAG_JOBS_DISCOVERY: JOI_BOOLEAN_STRING_SCHEMA, }), }), From 9b145d59279a271e9f3842d1a965b866ba7215e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 19 Aug 2025 18:09:51 +0200 Subject: [PATCH 8/9] enhance proposal handling and countdown formatting --- .../components/governance-banner.tsx | 32 +--- .../services/governance.service.ts | 2 +- .../frontend/src/shared/utils/time.ts | 22 +++ .../server/src/common/utils/cache.ts | 3 + .../modules/governance/governance.service.ts | 143 +++++++++--------- 5 files changed, 107 insertions(+), 95 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/shared/utils/time.ts create mode 100644 packages/apps/human-app/server/src/common/utils/cache.ts 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 356fbd5073..97936749b3 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 @@ -6,6 +6,7 @@ import { env } from '@/shared/env'; import { useColorMode } from '@/shared/contexts/color-mode'; import { useWorkerIdentityVerificationStatus } from '@/modules/worker/profile/hooks'; import { useProposalQuery } from '../hooks/use-proposal-query'; +import { formatCountdown, getProposalStatus } from '../../../shared/utils/time'; export function GovernanceBanner() { const { t } = useTranslation(); @@ -21,22 +22,10 @@ export function GovernanceBanner() { const timer = setInterval(() => { const now = Date.now(); - const target = now < voteStart ? voteStart : voteEnd; + const currentStatus = getProposalStatus(voteStart, voteEnd, now); + const target = currentStatus === 'pending' ? voteStart : voteEnd; const diff = target - now; - - if (diff <= 0) { - setTimeRemaining('00:00:00'); - } else { - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - 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}`); - } + setTimeRemaining(formatCountdown(diff)); }, 1000); return () => { @@ -48,14 +37,9 @@ export function GovernanceBanner() { return null; } - const now = Date.now(); - const isPending = now < data.voteStart; - const isActive = data.voteStart <= now && now < data.voteEnd; + const status = getProposalStatus(data.voteStart, data.voteEnd); - const forVotes = isActive ? data.forVotes : 0; - const againstVotes = isActive ? data.againstVotes : 0; - const abstainVotes = isActive ? data.abstainVotes : 0; - const totalVotes = forVotes + againstVotes + abstainVotes; + const totalVotes = data.forVotes + data.againstVotes + data.abstainVotes; return ( - {isPending + {status === 'pending' ? t('governance.timeToStart', 'Voting starts in') : t('governance.timeToReveal', 'Time to reveal vote')} : @@ -91,7 +75,7 @@ export function GovernanceBanner() { {timeRemaining} - {isActive && ( + {status === 'active' && ( { ); if (!Array.isArray(list) || list.length === 0) return null; - const now = Math.floor(Date.now() / 1000); + const now = Date.now(); const activeProposals = list.filter( (p) => p.voteStart <= now && now < p.voteEnd ); 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..4608bf0bd8 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/utils/time.ts @@ -0,0 +1,22 @@ +export type ProposalStatus = 'pending' | 'active'; + +export function getProposalStatus( + voteStartMs: number, + voteEndMs: number, + now: number = Date.now() +): ProposalStatus { + if (voteStartMs <= now && now < voteEndMs) return 'active'; + return 'pending'; +} + +export function formatCountdown(diffMs: number): string { + 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/src/common/utils/cache.ts b/packages/apps/human-app/server/src/common/utils/cache.ts new file mode 100644 index 0000000000..31447bc0b1 --- /dev/null +++ b/packages/apps/human-app/server/src/common/utils/cache.ts @@ -0,0 +1,3 @@ +export const generateCacheKey = (...parts: (string | number)[]): string => { + return ['governance', ...parts].map(String).join(':'); +}; 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 index f1dda34907..c0cc4f0988 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.service.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -5,6 +5,7 @@ import { Cache } from 'cache-manager'; import { ethers } from 'ethers'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { ProposalState } from '../../common/enums/proposal'; +import { generateCacheKey } from '../../common/utils/cache'; import { ProposalResponse } from './model/governance.model'; const N_BLOCKS_LOOKBACK = 100000; @@ -34,26 +35,50 @@ export class GovernanceService { ); const currentBlock = await provider.getBlockNumber(); - const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); - const proposalListKey = this.generateCacheKey('proposal', 'list'); + const lastScannedBlockKey = generateCacheKey( + this.configService.governorAddress, + 'last-scanned-block', + ); + const proposalListKey = generateCacheKey( + this.configService.governorAddress, + 'proposal', + 'list', + ); const cachedLastScannedBlock = (await this.cacheManager.get(lastScannedBlockKey)) ?? 0; - const fromBlockBase = + const fromBlock = cachedLastScannedBlock > 0 ? cachedLastScannedBlock + 1 : Math.max(0, currentBlock - N_BLOCKS_LOOKBACK); - const fromBlock = Math.min(fromBlockBase, currentBlock); let proposalList: Proposal[] = []; try { - proposalList = await this.getProposalCreatedEvents(contract, fromBlock); + const newProposals = await this.getProposalCreatedEvents( + contract, + fromBlock, + currentBlock, + ); + + const cachedList = + (await this.cacheManager.get(proposalListKey)) || []; + + proposalList = [...cachedList, ...newProposals].reduce( + (acc, p) => { + if (!acc.some((x) => x.proposalId === p.proposalId)) { + acc.push(p); + } + return acc; + }, + [], + ); } catch (err) { this.logger.warn( - `getProposalCreatedEvents failed, falling back to cached list: ${ - (err as Error)?.message || err - }`, + 'getProposalCreatedEvents failed, falling back to cached list', + { + error: (err as Error)?.message || err, + }, ); proposalList = (await this.cacheManager.get(proposalListKey)) || []; @@ -63,89 +88,76 @@ export class GovernanceService { 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 ${proposal.proposalId}: ${(err as Error)?.message || err}`, - ); - continue; - } - - if (state === ProposalState.PENDING) { - keptProposalList.push(proposal); - proposals.push({ + this.logger.warn('Failed to fetch state for proposal', { + error: err, proposalId: proposal.proposalId, - forVotes: 0, - againstVotes: 0, - abstainVotes: 0, - voteStart: (proposal.voteStart ?? 0) * 1000, - voteEnd: (proposal.voteEnd ?? 0) * 1000, }); continue; } - if (state !== ProposalState.ACTIVE) { + if (state !== ProposalState.PENDING && state !== ProposalState.ACTIVE) { continue; } keptProposalList.push(proposal); - let againstBn: ethers.BigNumberish, - forBn: ethers.BigNumberish, - abstainBn: ethers.BigNumberish; - try { - const votes = (await contract.proposalVotes(proposal.proposalId)) as [ - ethers.BigNumberish, - ethers.BigNumberish, - ethers.BigNumberish, - ]; - [againstBn, forBn, abstainBn] = votes; - } catch (err) { - this.logger.warn( - `Failed to fetch votes for proposal ${proposal.proposalId}: ${(err as Error)?.message || err}`, - ); - continue; + 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: Number(ethers.formatEther(forBn)), - againstVotes: Number(ethers.formatEther(againstBn)), - abstainVotes: Number(ethers.formatEther(abstainBn)), - voteStart: (proposal.voteStart ?? 0) * 1000, - voteEnd: (proposal.voteEnd ?? 0) * 1000, + forVotes, + againstVotes, + abstainVotes, + voteStart: voteStartMs, + voteEnd: voteEndMs, }); } - await this.cacheManager.set(proposalListKey, keptProposalList, 0); + await this.cacheManager.set(proposalListKey, keptProposalList); + await this.cacheManager.set(lastScannedBlockKey, currentBlock); return proposals; } - private generateCacheKey(...parts: (string | number)[]): string { - return ['governance', this.configService.governorAddress, ...parts] - .map(String) - .join(':'); - } - private async getProposalCreatedEvents( contract: ReturnType, fromBlock: number, + toBlock: number, ): Promise { - const proposalListKey = this.generateCacheKey('proposal', 'list'); - const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); - const filter = contract.filters.ProposalCreated(); - const logs = await contract.queryFilter(filter, fromBlock, 'latest'); - - const cachedList: Proposal[] = - (await this.cacheManager.get(proposalListKey)) || []; - const nextList: Proposal[] = [...cachedList]; + const logs = await contract.queryFilter(filter, fromBlock, toBlock); + const proposals: Proposal[] = []; for (const log of logs) { try { const parsed = contract.interface.parseLog(log); @@ -158,21 +170,12 @@ export class GovernanceService { voteStart: Number(parsed?.args?.voteStart), voteEnd: Number(parsed?.args?.voteEnd), }; - nextList.push(proposal); + proposals.push(proposal); } catch (err) { - this.logger.warn( - `Failed to parse/cache ProposalCreated log: ${(err as Error)?.message || err}`, - ); + this.logger.warn('Failed to parse ProposalCreated log', err); } } - await this.cacheManager.set(proposalListKey, nextList, 0); - - const lastScannedBlock = logs.length - ? Number(logs[logs.length - 1].blockNumber) - : fromBlock; - await this.cacheManager.set(lastScannedBlockKey, lastScannedBlock, 0); - - return nextList; + return proposals; } } From ade18bc76139f0d496c164c7298abcda2f810580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 20 Aug 2025 16:31:26 +0200 Subject: [PATCH 9/9] refactor governance proposal handling and remove unused cache utility --- .../components/governance-banner.tsx | 35 +++++++++++------- .../frontend/src/shared/utils/time.ts | 15 ++------ .../server/src/common/utils/cache.ts | 3 -- .../modules/governance/governance.service.ts | 36 ++++++++----------- 4 files changed, 40 insertions(+), 49 deletions(-) delete mode 100644 packages/apps/human-app/server/src/common/utils/cache.ts 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 97936749b3..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 @@ -6,40 +6,49 @@ import { env } from '@/shared/env'; import { useColorMode } from '@/shared/contexts/color-mode'; import { useWorkerIdentityVerificationStatus } from '@/modules/worker/profile/hooks'; import { useProposalQuery } from '../hooks/use-proposal-query'; -import { formatCountdown, getProposalStatus } from '../../../shared/utils/time'; +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 } = useProposalQuery(); + 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) return; - const { voteStart, voteEnd } = data; + if (!proposal) return; + const { voteStart, voteEnd } = proposal; const timer = setInterval(() => { - const now = Date.now(); - const currentStatus = getProposalStatus(voteStart, voteEnd, now); - const target = currentStatus === 'pending' ? voteStart : voteEnd; - const diff = target - now; - setTimeRemaining(formatCountdown(diff)); + const currentStatus = getProposalStatus(proposal); + setTimeRemaining( + formatCountdown(currentStatus === 'pending' ? voteStart : voteEnd) + ); }, 1000); return () => { clearInterval(timer); }; - }, [data]); + }, [proposal]); - if (!isVerificationCompleted || isLoading || isError || !data) { + if (!isVerificationCompleted || isLoading || isError || !proposal) { return null; } - const status = getProposalStatus(data.voteStart, data.voteEnd); + const status = getProposalStatus(proposal); - const totalVotes = data.forVotes + data.againstVotes + data.abstainVotes; + const totalVotes = + proposal.forVotes + proposal.againstVotes + proposal.abstainVotes; return ( { - return ['governance', ...parts].map(String).join(':'); -}; 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 index c0cc4f0988..5eb8b0c64b 100644 --- a/packages/apps/human-app/server/src/modules/governance/governance.service.ts +++ b/packages/apps/human-app/server/src/modules/governance/governance.service.ts @@ -3,9 +3,9 @@ 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 { generateCacheKey } from '../../common/utils/cache'; import { ProposalResponse } from './model/governance.model'; const N_BLOCKS_LOOKBACK = 100000; @@ -35,15 +35,8 @@ export class GovernanceService { ); const currentBlock = await provider.getBlockNumber(); - const lastScannedBlockKey = generateCacheKey( - this.configService.governorAddress, - 'last-scanned-block', - ); - const proposalListKey = generateCacheKey( - this.configService.governorAddress, - 'proposal', - 'list', - ); + const lastScannedBlockKey = this.generateCacheKey('last-scanned-block'); + const proposalListKey = this.generateCacheKey('proposal', 'list'); const cachedLastScannedBlock = (await this.cacheManager.get(lastScannedBlockKey)) ?? 0; @@ -64,20 +57,12 @@ export class GovernanceService { const cachedList = (await this.cacheManager.get(proposalListKey)) || []; - proposalList = [...cachedList, ...newProposals].reduce( - (acc, p) => { - if (!acc.some((x) => x.proposalId === p.proposalId)) { - acc.push(p); - } - return acc; - }, - [], - ); + proposalList = _.uniqBy([...cachedList, ...newProposals], 'proposalId'); } catch (err) { this.logger.warn( 'getProposalCreatedEvents failed, falling back to cached list', { - error: (err as Error)?.message || err, + error: err, }, ); proposalList = @@ -172,10 +157,19 @@ export class GovernanceService { }; proposals.push(proposal); } catch (err) { - this.logger.warn('Failed to parse ProposalCreated log', 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(':'); + } }