diff --git a/pages/governance/v3/proposal/index.governance.tsx b/pages/governance/v3/proposal/index.governance.tsx index f5cd942ba0..08800f0d44 100644 --- a/pages/governance/v3/proposal/index.governance.tsx +++ b/pages/governance/v3/proposal/index.governance.tsx @@ -33,10 +33,7 @@ export default function ProposalPage() { error: proposalError, } = useGovernanceProposalDetail(proposalId); - // For graph path, get votingChainId from rawProposal - const votingChainId = proposal?.rawProposal - ? +proposal.rawProposal.subgraphProposal.votingPortal.votingMachineChainId - : undefined; + const votingChainId = proposal?.voteProposalData?.votingMachineChainId; const voters = useGovernanceVotersSplit(proposalId, votingChainId); const { data: payloads, isLoading: payloadsLoading } = useProposalPayloadsCache(proposalId); @@ -62,7 +59,7 @@ export default function ProposalPage() { /> - {proposal?.rawProposal && } + {proposal?.voteProposalData && } (undefined); - const proposalId = +proposal.subgraphProposal.id; - const blockHash = proposal.subgraphProposal.snapshotBlockHash; - const votingChainId = +proposal.subgraphProposal.votingPortal.votingMachineChainId; + const proposalId = +proposal.proposalId; + const blockHash = proposal.snapshotBlockHash; + const votingChainId = proposal.votingMachineChainId; const votingMachineAddress = governanceV3Config.votingChainConfig[votingChainId].votingMachineAddress; @@ -267,7 +267,16 @@ export const GovVoteActions = ({ success: true, }); queryClient.invalidateQueries({ queryKey: ['governance_proposal', proposalId, user] }); + queryClient.invalidateQueries({ + queryKey: ['governance-detail-cache', proposalId, user], + }); queryClient.invalidateQueries({ queryKey: ['proposalVotes', proposalId] }); + queryClient.invalidateQueries({ + queryKey: ['governance-voters-cache-for', proposalId], + }); + queryClient.invalidateQueries({ + queryKey: ['governance-voters-cache-against', proposalId], + }); return; } else { setTimeout(checkForStatus, 5000); @@ -293,7 +302,14 @@ export const GovVoteActions = ({ }); queryClient.invalidateQueries({ queryKey: ['governance_proposal', proposalId, user] }); + queryClient.invalidateQueries({ queryKey: ['governance-detail-cache', proposalId, user] }); queryClient.invalidateQueries({ queryKey: ['proposalVotes', proposalId] }); + queryClient.invalidateQueries({ + queryKey: ['governance-voters-cache-for', proposalId], + }); + queryClient.invalidateQueries({ + queryKey: ['governance-voters-cache-against', proposalId], + }); } } catch (err) { setMainTxState({ diff --git a/src/components/transactions/GovVote/GovVoteModalContent.tsx b/src/components/transactions/GovVote/GovVoteModalContent.tsx index e5fc6b1286..6765c4e252 100644 --- a/src/components/transactions/GovVote/GovVoteModalContent.tsx +++ b/src/components/transactions/GovVote/GovVoteModalContent.tsx @@ -1,8 +1,8 @@ import { Trans } from '@lingui/macro'; import { Box, Button, Typography, useTheme } from '@mui/material'; -import { Proposal } from 'src/hooks/governance/useProposals'; import { useModalContext } from 'src/hooks/useModal'; import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { VoteProposalData } from 'src/modules/governance/types'; import { useRootStore } from 'src/store/root'; import { AIP } from 'src/utils/events'; import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; @@ -17,7 +17,7 @@ import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; import { GovVoteActions } from './GovVoteActions'; export type GovVoteModalContentProps = { - proposal: Proposal; + proposal: VoteProposalData; support: boolean; power: string; }; @@ -63,7 +63,7 @@ export const GovVoteModalContent = ({ } }; - const proposalVotingChain = +proposal.subgraphProposal.votingPortal.votingMachineChainId; + const proposalVotingChain = proposal.votingMachineChainId; const isWrongNetwork = connectedChainId !== proposalVotingChain; diff --git a/src/hooks/governance/useGovernanceProposals.ts b/src/hooks/governance/useGovernanceProposals.ts index 4f7028dfe7..e624e3c11d 100644 --- a/src/hooks/governance/useGovernanceProposals.ts +++ b/src/hooks/governance/useGovernanceProposals.ts @@ -11,7 +11,7 @@ import { adaptGraphProposalToListItem, } from 'src/modules/governance/adapters'; import { lifecycleToBadge } from 'src/modules/governance/StateBadge'; -import { ProposalListItem, VotersSplitDisplay } from 'src/modules/governance/types'; +import { ProposalListItem, VoteDisplay, VotersSplitDisplay } from 'src/modules/governance/types'; import { getLifecycleState, getProposalVoteInfo, @@ -20,6 +20,7 @@ import { getProposalDetailFromCache, getProposalsFromCache, getProposalVotesFromCache, + getUserVoteFromCache, searchProposalsFromCache, } from 'src/services/GovernanceCacheService'; import { useRootStore } from 'src/store/root'; @@ -41,6 +42,7 @@ const USE_GOVERNANCE_CACHE = process.env.NEXT_PUBLIC_USE_GOVERNANCE_CACHE === 't const PAGE_SIZE = 10; const VOTES_PAGE_SIZE = 50; const SEARCH_RESULTS_LIMIT = 10; +export const ENS_REVERSE_REGISTRAR = '0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C'; // ============================================ // Subgraph search query @@ -214,9 +216,12 @@ export const useGovernanceProposalDetail = (proposalId: number) => { queryFn: async () => { const detail = await getProposalDetailFromCache(String(proposalId)); if (!detail) return null; - return adaptCacheProposalToDetail(detail); + + const userVote = user ? await getUserVoteFromCache(String(proposalId), user) : null; + + return adaptCacheProposalToDetail(detail, userVote); }, - queryKey: ['governance-detail-cache', proposalId], + queryKey: ['governance-detail-cache', proposalId, user], enabled: USE_GOVERNANCE_CACHE && !isNaN(proposalId), refetchOnMount: false, refetchOnReconnect: false, @@ -331,7 +336,7 @@ export const useGovernanceVotersSplit = ( const votes = await fetchSubgraphVotes(proposalId, votingChainId as ChainId); try { const provider = getProvider(governanceV3Config.coreChainId); - const contract = new Contract('0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C', ensAbi); + const contract = new Contract(ENS_REVERSE_REGISTRAR, ensAbi); const connectedContract = contract.connect(provider); const ensNames: string[] = await connectedContract.getNames(votes.map((v) => v.voter)); return votes.map((vote, i) => ({ @@ -349,9 +354,44 @@ export const useGovernanceVotersSplit = ( refetchOnReconnect: false, }); + // ENS resolution for cache path voters + const cacheVoterAddresses = USE_GOVERNANCE_CACHE + ? [ + ...(cacheForData?.pages.flatMap((p) => p.votes.map((v) => v.voter)) || []), + ...(cacheAgainstData?.pages.flatMap((p) => p.votes.map((v) => v.voter)) || []), + ] + : []; + + const { data: cacheEnsNames } = useQuery({ + queryFn: async () => { + const provider = getProvider(governanceV3Config.coreChainId); + const contract = new Contract(ENS_REVERSE_REGISTRAR, ensAbi); + const connectedContract = contract.connect(provider); + const names: string[] = await connectedContract.getNames(cacheVoterAddresses); + const map: Record = {}; + cacheVoterAddresses.forEach((addr, i) => { + if (names[i]) map[addr.toLowerCase()] = names[i]; + }); + return map; + }, + queryKey: ['governance-voters-ens', proposalId, cacheVoterAddresses.length], + enabled: USE_GOVERNANCE_CACHE && cacheVoterAddresses.length > 0, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + if (USE_GOVERNANCE_CACHE) { - const yaeVotes = cacheForData?.pages.flatMap((p) => p.votes.map(adaptCacheVote)) || []; - const nayVotes = cacheAgainstData?.pages.flatMap((p) => p.votes.map(adaptCacheVote)) || []; + const withEns = (vote: VoteDisplay): VoteDisplay => ({ + ...vote, + ensName: cacheEnsNames?.[vote.voter.toLowerCase()], + }); + const yaeVotes = (cacheForData?.pages.flatMap((p) => p.votes.map(adaptCacheVote)) || []).map( + withEns + ); + const nayVotes = ( + cacheAgainstData?.pages.flatMap((p) => p.votes.map(adaptCacheVote)) || [] + ).map(withEns); const combinedVotes = [...yaeVotes, ...nayVotes].sort( (a, b) => parseFloat(b.votingPower) - parseFloat(a.votingPower) ); @@ -366,21 +406,29 @@ export const useGovernanceVotersSplit = ( const sortByPower = (a: { votingPower: string }, b: { votingPower: string }) => +a.votingPower < +b.votingPower ? 1 : +a.votingPower > +b.votingPower ? -1 : 0; + const toVoteDisplay = (v: { + voter: string; + support: boolean; + votingPower: string; + ensName?: string; + }): VoteDisplay => ({ + voter: v.voter, + support: v.support, + votingPower: v.votingPower, + ensName: v.ensName, + }); + const yaeVotes = graphVotes ?.filter((v) => v.support) .sort(sortByPower) - .map((v) => ({ voter: v.voter, support: v.support, votingPower: v.votingPower })) || []; + .map(toVoteDisplay) || []; const nayVotes = graphVotes ?.filter((v) => !v.support) .sort(sortByPower) - .map((v) => ({ voter: v.voter, support: v.support, votingPower: v.votingPower })) || []; - const combinedVotes = graphVotes - ? [...graphVotes] - .sort(sortByPower) - .map((v) => ({ voter: v.voter, support: v.support, votingPower: v.votingPower })) - : []; + .map(toVoteDisplay) || []; + const combinedVotes = graphVotes ? [...graphVotes].sort(sortByPower).map(toVoteDisplay) : []; return { yaeVotes, nayVotes, combinedVotes, isFetching: graphFetching }; }; diff --git a/src/hooks/governance/useProposalVotes.ts b/src/hooks/governance/useProposalVotes.ts index 861dc3dc7e..4dc0c46fcc 100644 --- a/src/hooks/governance/useProposalVotes.ts +++ b/src/hooks/governance/useProposalVotes.ts @@ -7,6 +7,8 @@ import { governanceV3Config } from 'src/ui-config/governanceConfig'; import { getProvider } from 'src/utils/marketsAndNetworksConfig'; import { subgraphRequest } from 'src/utils/subgraphRequest'; +import { ENS_REVERSE_REGISTRAR } from './useGovernanceProposals'; + export type ProposalVote = { proposalId: string; support: boolean; @@ -71,7 +73,7 @@ const fetchProposalVotes = async ( const fetchProposalVotesEnsNames = async (addresses: string[]) => { const provider = getProvider(governanceV3Config.coreChainId); - const contract = new Contract('0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C', abi); + const contract = new Contract(ENS_REVERSE_REGISTRAR, abi); const connectedContract = contract.connect(provider); return connectedContract.getNames(addresses) as Promise; }; diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index f307d864d7..2ee215ce1c 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -2,13 +2,12 @@ import { ChainId, Stake } from '@aave/contract-helpers'; import { AaveV3Ethereum } from '@bgd-labs/aave-address-book'; import { createContext, PropsWithChildren, useContext, useState } from 'react'; import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { VoteProposalData } from 'src/modules/governance/types'; import { ActionName, SwapActionFields, TransactionHistoryItem } from 'src/modules/history/types'; import { useRootStore } from 'src/store/root'; import { TxErrorType } from 'src/ui-config/errorMapping'; import { GENERAL } from 'src/utils/events'; -import { Proposal } from './governance/useProposals'; - export enum ModalType { Supply, Withdraw, @@ -50,7 +49,7 @@ export enum ModalType { export interface ModalArgsType { underlyingAsset?: string; - proposal?: Proposal; + proposal?: VoteProposalData; support?: boolean; power?: string; icon?: string; @@ -139,7 +138,7 @@ export interface ModalContextType { openGovDelegation: () => void; openRevokeGovDelegation: () => void; openV3Migration: () => void; - openGovVote: (proposal: Proposal, support: boolean, power: string) => void; + openGovVote: (proposal: VoteProposalData, support: boolean, power: string) => void; openSwitch: (underlyingAsset?: string, chainId?: number) => void; openBridge: () => void; openStakingMigrate: () => void; @@ -392,7 +391,7 @@ export const ModalContextProvider: React.FC = ({ children }) openGovVote: (proposal, support, power) => { trackEvent(GENERAL.OPEN_MODAL, { modal: 'Vote', - proposalId: proposal.subgraphProposal.id, + proposalId: proposal.proposalId, voteSide: support, }); setType(ModalType.GovVote); diff --git a/src/modules/governance/adapters.ts b/src/modules/governance/adapters.ts index 52c576ea2d..6c86ef41c8 100644 --- a/src/modules/governance/adapters.ts +++ b/src/modules/governance/adapters.ts @@ -1,10 +1,13 @@ +import { VotingMachineProposalState } from '@aave/contract-helpers'; import { normalizeBN } from '@aave/math-utils'; +import { constants } from 'ethers'; import { Proposal } from 'src/hooks/governance/useProposals'; import { ProposalDetail, ProposalVote, SimplifiedProposal, } from 'src/services/GovernanceCacheService'; +import { governanceV3Config } from 'src/ui-config/governanceConfig'; import { ProposalBadgeState } from './StateBadge'; import { @@ -12,6 +15,7 @@ import { ProposalListItem, ProposalVoteDisplayInfo, VoteDisplay, + VoteProposalData, } from './types'; // ============================================ @@ -79,6 +83,76 @@ export function calculateCacheVoteDisplayInfo( }; } +// ============================================ +// VoteProposalData builders +// ============================================ + +/** + * Parse rindexer FixedBytes format into a clean hex string. + * e.g. "FixedBytes(0xabc123..., 32)" → "0xabc123..." + */ +function parseFixedBytes(raw: string | null): string | null { + if (!raw) return null; + const match = raw.match(/FixedBytes\((0x[a-fA-F0-9]+)/); + return match ? match[1] : raw; +} + +/** Map a VotingMachine contract address to its chain ID via governance config. */ +function votingMachineAddressToChainId(address: string): number | undefined { + const lowerAddress = address.toLowerCase(); + for (const [chainId, config] of Object.entries(governanceV3Config.votingChainConfig)) { + if (config.votingMachineAddress.toLowerCase() === lowerAddress) { + return Number(chainId); + } + } + return undefined; +} + +/** Build VoteProposalData from a graph Proposal. */ +export function buildVoteProposalFromGraph(proposal: Proposal): VoteProposalData { + const votedInfo = proposal.votingMachineData.votedInfo; + return { + proposalId: proposal.subgraphProposal.id, + snapshotBlockHash: proposal.subgraphProposal.snapshotBlockHash, + votingMachineChainId: +proposal.subgraphProposal.votingPortal.votingMachineChainId, + votingAssets: proposal.votingMachineData.votingAssets, + votingState: proposal.votingMachineData.state, + votedInfo: + votedInfo && votedInfo.votingPower !== '0' + ? { support: votedInfo.support, votingPower: votedInfo.votingPower } + : undefined, + }; +} + +/** Build VoteProposalData from a cache ProposalDetail. Returns undefined if voting chain can't be determined. */ +export function buildVoteProposalFromCache( + detail: ProposalDetail, + userVote?: ProposalVote | null +): VoteProposalData | undefined { + if (!detail.votingMachineAddress) return undefined; + + const votingMachineChainId = votingMachineAddressToChainId(detail.votingMachineAddress); + if (votingMachineChainId === undefined) return undefined; + + const { aaveTokenAddress, aAaveTokenAddress, stkAaveTokenAddress } = + governanceV3Config.votingAssets; + + return { + proposalId: detail.id, + snapshotBlockHash: parseFixedBytes(detail.snapshotBlockHash) || constants.HashZero, + votingMachineChainId, + votingAssets: [aaveTokenAddress, aAaveTokenAddress, stkAaveTokenAddress], + votingState: + detail.state === 'active' + ? VotingMachineProposalState.Active + : VotingMachineProposalState.Finished, + votedInfo: + userVote && userVote.votingPower !== '0' + ? { support: userVote.support, votingPower: userVote.votingPower } + : undefined, + }; +} + // ============================================ // Graph -> canonical adapters // ============================================ @@ -121,6 +195,7 @@ export function adaptGraphProposalToDetail(p: Proposal): ProposalDetailDisplay { discussions: p.subgraphProposal.proposalMetadata.discussions || null, ipfsHash: p.subgraphProposal.proposalMetadata.ipfsHash, rawProposal: p, + voteProposalData: buildVoteProposalFromGraph(p), }; } @@ -140,7 +215,10 @@ export function adaptCacheProposalToListItem(p: SimplifiedProposal): ProposalLis }; } -export function adaptCacheProposalToDetail(p: ProposalDetail): ProposalDetailDisplay { +export function adaptCacheProposalToDetail( + p: ProposalDetail, + userVote?: ProposalVote | null +): ProposalDetailDisplay { const voteInfo = calculateCacheVoteDisplayInfo( p.votesFor, p.votesAgainst, @@ -158,6 +236,7 @@ export function adaptCacheProposalToDetail(p: ProposalDetail): ProposalDetailDis badgeState: cacheStateToBadge(p.state), voteInfo, rawCacheDetail: p, + voteProposalData: buildVoteProposalFromCache(p, userVote), }; } diff --git a/src/modules/governance/proposal/ProposalOverview.tsx b/src/modules/governance/proposal/ProposalOverview.tsx index a247ac9549..0f2d55709b 100644 --- a/src/modules/governance/proposal/ProposalOverview.tsx +++ b/src/modules/governance/proposal/ProposalOverview.tsx @@ -80,11 +80,11 @@ export const ProposalOverview = ({ proposal, loading, error }: ProposalOverviewP - {proposal.author && ( + {/* {proposal.author && ( by {proposal.author} - )} + )} */} @@ -148,7 +143,7 @@ export function VoteInfo({ proposal }: VoteInfoProps) { color="error" variant="contained" fullWidth - onClick={() => openGovVote(proposal, false, powerAtProposalStart)} + onClick={() => openGovVote(voteData, false, powerAtProposalStart)} sx={{ mt: 2 }} > Vote NAY diff --git a/src/modules/governance/types.ts b/src/modules/governance/types.ts index d86f2c81cb..a8589e202d 100644 --- a/src/modules/governance/types.ts +++ b/src/modules/governance/types.ts @@ -1,3 +1,4 @@ +import { VotingMachineProposalState } from '@aave/contract-helpers'; import { Proposal } from 'src/hooks/governance/useProposals'; import { ProposalDetail } from 'src/services/GovernanceCacheService'; @@ -52,6 +53,24 @@ export type ProposalDetailDisplay = { rawProposal?: Proposal; /** Present only for cache data path. Used by ProposalLifecycleCache. */ rawCacheDetail?: ProposalDetail; + /** Voting data derived from either data path. Used by VoteInfo and GovVoteModal. */ + voteProposalData?: VoteProposalData; +}; + +/** + * Minimal data needed by VoteInfo and the vote modal flow. + * Built from either the graph Proposal or the cache ProposalDetail. + */ +export type VoteProposalData = { + proposalId: string; + snapshotBlockHash: string; + votingMachineChainId: number; + votingAssets: string[]; + votingState: VotingMachineProposalState; + votedInfo?: { + support: boolean; + votingPower: string; + }; }; /** @@ -62,6 +81,7 @@ export type VoteDisplay = { voter: string; support: boolean; votingPower: string; + ensName?: string; }; /** diff --git a/src/services/GovernanceCacheService.ts b/src/services/GovernanceCacheService.ts index 916172bcf2..d5b002aa5f 100644 --- a/src/services/GovernanceCacheService.ts +++ b/src/services/GovernanceCacheService.ts @@ -269,6 +269,8 @@ export interface ProposalDetail { votingStartTime: string | null; votingEndTime: string | null; l1BlockHash: string | null; + // VotingMachine contract address (identifies the voting chain) + votingMachineAddress: string | null; // Voting thresholds quorum: string | null; requiredDifferential: string | null; @@ -311,6 +313,7 @@ interface ProposalDetailResponse { votingStartTime: string | null; votingEndTime: string | null; l1BlockHash: string | null; + votingMachineAddress: string | null; quorum: string | null; requiredDifferential: string | null; }>; @@ -362,6 +365,7 @@ export async function getProposalDetailFromCache(id: string): Promise { + const query = ` + query GetUserVote($proposalId: BigFloat!, $voter: String!) { + getUserVote(pProposalId: $proposalId, pVoter: $voter) { + nodes { + voter + support + votingPower + votingNetwork + votedAt + } + } + } + `; + + const response = await graphqlRequest<{ + data: { + getUserVote: { + nodes: Array<{ + voter: string; + support: boolean; + votingPower: string; + votingNetwork: string; + votedAt: string | null; + }>; + }; + }; + }>(query, { + proposalId: parseFloat(proposalId), + voter, + }); + + const nodes = response.data.getUserVote.nodes; + if (nodes.length === 0) return null; + + const v = nodes[0]; + return { + voter: v.voter, + support: v.support, + votingPower: v.votingPower, + votingNetwork: v.votingNetwork, + votedAt: v.votedAt, + }; +} + export async function getProposalVoteCountsFromCache( proposalId: string ): Promise<{ forCount: number; againstCount: number; totalCount: number }> { diff --git a/src/services/GovernanceService.ts b/src/services/GovernanceService.ts index 7eee55047a..126a854d20 100644 --- a/src/services/GovernanceService.ts +++ b/src/services/GovernanceService.ts @@ -74,11 +74,17 @@ export class GovernanceService { const options: { blockTag?: string } = {}; if (blockHash) { - options.blockTag = blockHash; + // Resolve block hash to block number — passing a raw 256-bit hash as blockTag + // causes "number too large" errors on most RPC providers including Alchemy. + const provider = this.getProvider(govChainId); + const block = await provider.getBlock(blockHash); + if (block) { + options.blockTag = block.number.toString(); + } } const [aaveTokenPower, stkAaveTokenPower, aAaveTokenPower] = - // pass blockhash here as optional + // pass block number here as optional await aaveGovernanceService.getTokensPower( { user: user, diff --git a/src/services/WalletBalanceService.ts b/src/services/WalletBalanceService.ts index 0193774a14..f9ccb17d3e 100644 --- a/src/services/WalletBalanceService.ts +++ b/src/services/WalletBalanceService.ts @@ -40,7 +40,13 @@ export class WalletBalanceService { const options: { blockTag?: string } = {}; if (blockHash) { - options.blockTag = blockHash; + // Resolve block hash to block number — passing a raw 256-bit hash as blockTag + // causes "number too large" errors on most RPC providers including Alchemy. + const provider = this.getProvider(chainId); + const block = await provider.getBlock(blockHash); + if (block) { + options.blockTag = `0x${block.number.toString(16)}`; + } } const balances = await walletBalanceService.batchBalanceOf( [user],