diff --git a/src/app/proposals/actions/getProposalsFromDB.ts b/src/app/proposals/actions/getProposalsFromDB.ts index f8f713ea1..05b4c8218 100644 --- a/src/app/proposals/actions/getProposalsFromDB.ts +++ b/src/app/proposals/actions/getProposalsFromDB.ts @@ -2,7 +2,26 @@ import { db } from '@/lib/db' import { ProposalApiResponse } from '@/app/proposals/shared/types' import { buildProposal } from '@/app/proposals/actions/utils' -function transformProposal(proposal: any): ProposalApiResponse { +interface ProposalDBRow { + proposalId: string + description: string + votesFor: string | null + votesAgainst: string | null + votesAbstains: string | null + voteEnd: string + voteStart: string + quorum: string | null + rawState: number | null + state: string | null + proposer: string + calldatas: string[] + values: string[] + createdAtBlock: string + targets: string[] + createdAt: string +} + +function transformProposal(proposal: ProposalDBRow): ProposalApiResponse { const parseBytea = (el: string) => Buffer.from(el.slice(2), 'hex').toString() return buildProposal(proposal, { diff --git a/src/app/proposals/actions/getProposalsFromNode.ts b/src/app/proposals/actions/getProposalsFromNode.ts index 660d13c4e..16599c74c 100644 --- a/src/app/proposals/actions/getProposalsFromNode.ts +++ b/src/app/proposals/actions/getProposalsFromNode.ts @@ -1,5 +1,5 @@ import Big from '@/lib/big' -import { parseEventLogs } from 'viem' +import { parseEventLogs, Log, Address, Hash, getAddress, isAddress, isHex } from 'viem' import { fetchProposalCreated } from '@/app/user/Balances/actions' import { GovernorAbi } from '@/lib/abis/Governor' import { @@ -9,9 +9,119 @@ import { serializeBigInts, } from '@/app/proposals/shared/utils' import { ProposalApiResponse } from '@/app/proposals/shared/types' +import { BackendEventByTopic0ResponseValue } from '@/shared/utils' -function transformEventLogProposal(proposal: any): ProposalApiResponse { - const eventArgs = getProposalEventArguments(proposal as unknown as EventArgumentsParameter) +type ElementType = T extends (infer U)[] ? U : never + +type ProposalCreatedEventLog = ElementType< + ReturnType> +> + +type ProposalCreatedEventLogWithTimestamp = ProposalCreatedEventLog & { + timeStamp: string + blockNumber: string +} + +/** + * Converts a proposal with timestamp to EventArgumentsParameter format + * This makes the conversion explicit and type-safe + */ +function toEventArgumentsParameter(proposal: ProposalCreatedEventLogWithTimestamp): EventArgumentsParameter { + return { + args: { + description: proposal.args.description, + proposalId: proposal.args.proposalId, + proposer: proposal.args.proposer, + targets: [...proposal.args.targets], + calldatas: [...proposal.args.calldatas], + voteStart: proposal.args.voteStart, + voteEnd: proposal.args.voteEnd, + values: [...proposal.args.values], + }, + timeStamp: proposal.timeStamp, + blockNumber: proposal.blockNumber, + } +} + +/** + * Validates and converts a string to Address type + * Throws if the string is not a valid address + */ +function toAddress(value: string): Address { + if (!isAddress(value, { strict: false })) { + throw new Error(`Invalid address: ${value}`) + } + return getAddress(value) +} + +/** + * Validates and converts a string to Hash type + * Throws if the string is not a valid hex string + */ +function toHash(value: string): Hash { + if (!isHex(value)) { + throw new Error(`Invalid hash: ${value}`) + } + return value as Hash +} + +/** + * Validates and converts topics array + * Topics can be addresses or hashes, we validate they're valid hex strings + */ +function toTopics(topics: Array): [] | [Address, ...Address[]] { + const filtered = topics.filter((t): t is string => t !== null) + + if (filtered.length === 0) { + return [] + } + + // Validate all topics are valid hex strings + // For event logs, topics[0] is typically the event signature (hash) + // and subsequent topics can be indexed parameters (addresses or hashes) + const validatedTopics = filtered.map(topic => { + if (!isHex(topic)) { + throw new Error(`Invalid topic: ${topic}`) + } + // If it looks like an address (42 chars), validate and normalize it + if (topic.length === 42 && isAddress(topic, { strict: false })) { + return getAddress(topic) + } + // Otherwise, it's a hash - return as Address (which is compatible with Hash) + return topic as Address + }) + + return validatedTopics as [Address, ...Address[]] +} + +/** + * Converts BackendEventByTopic0ResponseValue logs to viem Log format + * This makes the conversion explicit and type-safe with proper validation + */ +function convertBackendLogsToViemLogs(logs: BackendEventByTopic0ResponseValue[]): Log[] { + return logs.map((log, index) => { + try { + return { + address: toAddress(log.address), + blockHash: null, + blockNumber: BigInt(log.blockNumber), + data: toHash(log.data), + logIndex: Number(log.logIndex), + transactionHash: toHash(log.transactionHash), + transactionIndex: Number(log.transactionIndex), + removed: false, + topics: toTopics(log.topics), + } + } catch (error) { + throw new Error( + `Failed to convert log at index ${index}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) +} + +function transformEventLogProposal(proposal: ProposalCreatedEventLogWithTimestamp): ProposalApiResponse { + const eventArgs = getProposalEventArguments(toEventArgumentsParameter(proposal)) const category = getProposalCategory(eventArgs.calldatasParsed) return { @@ -23,29 +133,36 @@ function transformEventLogProposal(proposal: any): ProposalApiResponse { proposer: proposal.args.proposer, description: proposal.args.description, proposalId: proposal.args.proposalId.toString(), - blockNumber: proposal.blockNumber.toString(), + blockNumber: proposal.blockNumber, name: eventArgs.name, Starts: eventArgs.Starts.toISOString(), calldatasParsed: serializeBigInts(eventArgs.calldatasParsed), } } -export async function getProposalsFromNode() { +export async function getProposalsFromNode(): Promise { const data = await fetchProposalCreated(0) - let proposals = parseEventLogs({ + const viemLogs = convertBackendLogsToViemLogs(data.data) + const parsedProposals = parseEventLogs({ abi: GovernorAbi, - logs: data.data as any, + logs: viemLogs, eventName: 'ProposalCreated', }) + // Add timestamp and blockNumber from original data + let proposals: ProposalCreatedEventLogWithTimestamp[] = parsedProposals.map((proposal, index) => ({ + ...proposal, + timeStamp: data.data[index]?.timeStamp ?? '0', + blockNumber: data.data[index]?.blockNumber ?? '0', + })) as ProposalCreatedEventLogWithTimestamp[] + proposals = proposals .filter( (proposal, index, self) => self.findIndex(p => p.args.proposalId === proposal.args.proposalId) === index, ) - // @ts-ignore - .sort((a, b) => b.timeStamp - a.timeStamp) + .sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp)) return proposals.map(transformEventLogProposal) } diff --git a/src/app/proposals/hooks/useGetProposalsWithGraph.ts b/src/app/proposals/hooks/useGetProposalsWithGraph.ts index 4fdabbe0d..d7e0cfb81 100644 --- a/src/app/proposals/hooks/useGetProposalsWithGraph.ts +++ b/src/app/proposals/hooks/useGetProposalsWithGraph.ts @@ -4,27 +4,39 @@ import { useQuery } from '@tanstack/react-query' import { useReadContracts } from 'wagmi' import { AVERAGE_BLOCKTIME } from '@/lib/constants' import Big from '@/lib/big' -import { ProposalState, ProposalCategory } from '@/shared/types' +import { ProposalState } from '@/shared/types' import { Proposal } from '../shared/types' import { ProposalApiResponse } from '@/app/proposals/shared/types' import moment from 'moment' import { GovernorAbi } from '@/lib/abis/Governor' import { GOVERNOR_ADDRESS } from '@/lib/constants' -import { formatEther } from 'viem' +import { formatEther, Address } from 'viem' -function proposalStateToRawState(proposalState: string): number { - const stateMap: Record = { - Pending: ProposalState.Pending, - Active: ProposalState.Active, - Succeeded: ProposalState.Succeeded, - Defeated: ProposalState.Defeated, - Executed: ProposalState.Executed, - Canceled: ProposalState.Canceled, - Queued: ProposalState.Queued, - Expired: ProposalState.Expired, +function toProposalState(value: number | string | undefined): ProposalState { + if (typeof value === 'number') { + // Validate that the number is a valid ProposalState enum value + if (value >= 0 && value <= 7) { + return value as ProposalState + } + } + if (typeof value === 'string') { + const stateMap: Record = { + Pending: ProposalState.Pending, + Active: ProposalState.Active, + Succeeded: ProposalState.Succeeded, + Defeated: ProposalState.Defeated, + Executed: ProposalState.Executed, + Canceled: ProposalState.Canceled, + Queued: ProposalState.Queued, + Expired: ProposalState.Expired, + } + return stateMap[value] || ProposalState.Pending } + return ProposalState.Pending +} - return stateMap[proposalState] || ProposalState.Pending +function proposalStateToRawState(proposalState: string): number { + return toProposalState(proposalState) } async function fetchProposalsFromAPI(): Promise { @@ -52,9 +64,41 @@ function parseBlockNumber(blockNumber: string | undefined): string { return blockNumber.startsWith('0x') ? parseInt(blockNumber, 16).toString() : blockNumber } +interface BlockchainProposalData { + proposalId: string + votes: { + againstVotes: Big + forVotes: Big + abstainVotes: Big + } + quorum: Big + rawState: ProposalState +} + +interface ProposalVotesContract { + address: Address + abi: typeof GovernorAbi + functionName: 'proposalVotes' + args: [string] +} + +interface QuorumContract { + address: Address + abi: typeof GovernorAbi + functionName: 'quorum' + args: [string] +} + +interface StateContract { + address: Address + abi: typeof GovernorAbi + functionName: 'state' + args: [string] +} + function transformProposalsData( proposalsData: ProposalApiResponse[] | undefined, - blockchainData: any[] | undefined, + blockchainData: BlockchainProposalData[] | undefined, latestBlockNumber: bigint | undefined, ) { if (!proposalsData) { @@ -71,8 +115,18 @@ function transformProposalsData( const transformedProposals = proposalsData.map((proposal: ProposalApiResponse) => { const blockchainInfo = blockchainData?.find(b => b.proposalId === proposal.proposalId) - const votes = proposal.votes || blockchainInfo?.votes - const quorum = proposal.quorumAtSnapshot || blockchainInfo?.quorum + // Convert blockchain votes (Big) to API format (string) if needed + const votes: ProposalApiResponse['votes'] | undefined = proposal.votes + ? proposal.votes + : blockchainInfo?.votes + ? { + againstVotes: blockchainInfo.votes.againstVotes.toString(), + forVotes: blockchainInfo.votes.forVotes.toString(), + abstainVotes: blockchainInfo.votes.abstainVotes.toString(), + } + : undefined + + const quorum = proposal.quorumAtSnapshot || blockchainInfo?.quorum?.toString() const rawState = blockchainInfo?.rawState const voteData = convertVotesToBigNumbers(votes) @@ -86,16 +140,16 @@ function transformProposalsData( }, blocksUntilClosure: Big(proposal.proposalDeadline).minus(Big(latestBlockNumber?.toString() || '0')), votingPeriod: Big(proposal.votingPeriod || '0'), - quorumAtSnapshot: Big(quorum || '0'), + quorumAtSnapshot: Big(quorum ?? blockchainInfo?.quorum?.toString() ?? '0'), proposalDeadline: deadlineBlock, proposalState: handleProposalState(proposal, latestBlockNumber ?? 0n, rawState), - category: proposal.category as ProposalCategory, + category: proposal.category, name: proposal.name, proposer: proposal.proposer, description: proposal.description, proposalId: proposal.proposalId, Starts: moment(proposal.Starts), - calldatasParsed: proposal.calldatasParsed as any, + calldatasParsed: proposal.calldatasParsed, blockNumber, voteStart: proposal.voteStart, voteEnd: proposal.voteEnd, @@ -144,62 +198,65 @@ export function useGetProposalsWithGraph() { ) const { votesContracts, quorumContracts, stateContracts } = useMemo(() => { - return proposalsFromNode.reduce( - (acc, proposal) => { - acc.votesContracts.push({ - address: GOVERNOR_ADDRESS, - abi: GovernorAbi, - functionName: 'proposalVotes', - args: [proposal.proposalId], - }) - - acc.quorumContracts.push({ - address: GOVERNOR_ADDRESS, - abi: GovernorAbi, - functionName: 'quorum', - args: [proposal.blockNumber], - }) - - acc.stateContracts.push({ - address: GOVERNOR_ADDRESS, - abi: GovernorAbi, - functionName: 'state', - args: [proposal.proposalId], - }) - - return acc - }, - { - votesContracts: [] as any[], - quorumContracts: [] as any[], - stateContracts: [] as any[], - }, - ) + const initialValue: { + votesContracts: ProposalVotesContract[] + quorumContracts: QuorumContract[] + stateContracts: StateContract[] + } = { + votesContracts: [], + quorumContracts: [], + stateContracts: [], + } + return proposalsFromNode.reduce((acc, proposal) => { + acc.votesContracts.push({ + address: GOVERNOR_ADDRESS, + abi: GovernorAbi, + functionName: 'proposalVotes', + args: [proposal.proposalId], + }) + acc.quorumContracts.push({ + address: GOVERNOR_ADDRESS, + abi: GovernorAbi, + functionName: 'quorum', + args: [proposal.blockNumber], + }) + acc.stateContracts.push({ + address: GOVERNOR_ADDRESS, + abi: GovernorAbi, + functionName: 'state', + args: [proposal.proposalId], + }) + return acc + }, initialValue) }, [proposalsFromNode]) - const { data: proposalVotes } = useReadContracts({ + const proposalVotesResult = useReadContracts({ contracts: votesContracts, query: { enabled: proposalsFromNode.length > 0, staleTime: AVERAGE_BLOCKTIME, }, - }) as { data?: Array<{ status: string; result: bigint[] }> } + }) - const { data: quorum } = useReadContracts({ + const quorumResult = useReadContracts({ contracts: quorumContracts, query: { enabled: proposalsFromNode.length > 0, staleTime: 24 * 60 * 60 * 1000, }, - }) as { data?: Array<{ status: string; result: bigint }> } + }) - const { data: state } = useReadContracts({ + const stateResult = useReadContracts({ contracts: stateContracts, query: { enabled: proposalsFromNode.length > 0, staleTime: AVERAGE_BLOCKTIME, }, - }) as { data?: Array<{ status: string; result: bigint }> } + }) + + const proposalVotes = proposalVotesResult.data + const quorum = quorumResult.data + const state = stateResult.data const blockchainData = useMemo(() => { if (!proposalsFromNode) return [] @@ -224,7 +281,7 @@ export function useGetProposalsWithGraph() { abstainVotes, }, quorum: quorumValue, - rawState: Big(proposalState.toString()).toNumber() as ProposalState, + rawState: toProposalState(proposalState), } }) }, [proposalVotes, quorum, state, proposalsFromNode]) @@ -250,16 +307,16 @@ function handleProposalState( blockNumber?: bigint, rawState?: number, ): ProposalState { - if (rawState) { - return rawState as ProposalState + if (rawState !== undefined) { + return toProposalState(rawState) } const proposalState = proposalStateToRawState(proposal.proposalState || 'Pending') if (!blockNumber) { - return proposalState as ProposalState + return proposalState } if (proposalState != ProposalState.Pending && proposalState != ProposalState.Active) { - return proposalState as ProposalState + return proposalState } if (!proposal.votes || !proposal.quorumAtSnapshot) { diff --git a/src/app/proposals/shared/types/index.ts b/src/app/proposals/shared/types/index.ts index fe460912f..b12f68f32 100644 --- a/src/app/proposals/shared/types/index.ts +++ b/src/app/proposals/shared/types/index.ts @@ -1,4 +1,5 @@ import Big from '@/lib/big' +import { Address } from 'viem' import { DecodedData } from '@/app/proposals/shared/utils' import { ProposalCategory, ProposalState } from '@/shared/types' @@ -21,7 +22,7 @@ export interface Proposal { proposalState: ProposalState category: ProposalCategory name: string - proposer: `0x${string}` + proposer: Address description: string proposalId: string Starts: moment.Moment @@ -47,15 +48,15 @@ export interface Eta extends Omit { type: 'vote end in' | 'queue ends in' } -export type ProposalApiResponse = { +export interface ProposalApiResponse { blockNumber: string - calldatasParsed: any[] - category: string + calldatasParsed: DecodedData[] + category: ProposalCategory description: string name: string proposalDeadline: string proposalId: string - proposer: `0x${string}` + proposer: Address Starts: string voteStart: string voteEnd: string diff --git a/src/app/proposals/shared/utils.ts b/src/app/proposals/shared/utils.ts index cf00ccc6f..10379adb8 100644 --- a/src/app/proposals/shared/utils.ts +++ b/src/app/proposals/shared/utils.ts @@ -236,7 +236,7 @@ export const parseProposalDescription = (description: string): ParsedDescription } // Helper function to determine proposal category -export function getProposalCategory(calldatasParsed: any[]): string { +export function getProposalCategory(calldatasParsed: DecodedData[]): ProposalCategory { const hasWithdrawAction = calldatasParsed .filter(data => data.type === 'decoded') .find(data => ['withdraw', 'withdrawERC20'].includes(data.functionName))