Skip to content

Commit

Permalink
Implement upvote fetching
Browse files Browse the repository at this point in the history
Improve celoscan utilities
  • Loading branch information
jmrossy committed Feb 25, 2024
1 parent bc6a71e commit 22dccb7
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 106 deletions.
17 changes: 13 additions & 4 deletions src/app/governance/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { ExternalLink } from 'src/components/buttons/ExternalLink';
import { Section } from 'src/components/layout/Section';
import { links } from 'src/config/links';
import { ProposalBadgeRow } from 'src/features/governance/ProposalCard';
import { ProposalUpvotersTable } from 'src/features/governance/ProposalUpvotersTable';
import {
ProposalUpvoteButton,
ProposalVoteButtons,
} from 'src/features/governance/ProposalVoteButtons';
import { ProposalVoteChart } from 'src/features/governance/ProposalVoteChart';
import { ProposalQuorumChart, ProposalVoteChart } from 'src/features/governance/ProposalVoteChart';
import { ProposalVotersTable } from 'src/features/governance/ProposalVotersTable';
import { ProposalStage } from 'src/features/governance/contractTypes';
import {
Expand Down Expand Up @@ -110,10 +111,18 @@ function ProposalChainData({ propData }: { propData: MergedProposalData }) {
<div>{`Voting ends in ${getHumanReadableDuration(expiryTimestamp)}`}</div>
)}
{stage >= ProposalStage.Referendum && <ProposalVoteChart propData={propData} />}
{stage === ProposalStage.Referendum && <ProposalQuorumChart propData={propData} />}
</div>
<div className="border border-taupe-300 p-3">
{stage >= ProposalStage.Referendum && <ProposalVotersTable propData={propData} />}
</div>
{stage >= ProposalStage.Queued && stage < ProposalStage.Referendum && (
<div className="border border-taupe-300 p-3">
<ProposalUpvotersTable propData={propData} />
</div>
)}
{stage >= ProposalStage.Referendum && (
<div className="border border-taupe-300 p-3">
<ProposalVotersTable propData={propData} />
</div>
)}
</div>
);
}
24 changes: 20 additions & 4 deletions src/features/explorers/celoscan.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { config } from 'src/config/config';
import { links } from 'src/config/links';
import { fetchWithTimeout, retryAsync } from 'src/utils/async';
import { ExplorerResponse } from './types';
import { logger } from 'src/utils/logger';
import { ExplorerResponse, TransactionLog } from './types';

export async function queryCeloscan<R>(url: string) {
url = config.celoscanApiKey ? `${url}&apikey=${config.celoscanApiKey}` : url;
const result = await retryAsync(() => executeQuery<R>(url));
/**
* @param relativeUrl The relative URL to query (e.g. /api?module=account&action=balance&address=0x1234)
*/
export function queryCeloscanLogs(address: Address, topicParams: string) {
// Not using from block 0 here because of some explorers have issues with incorrect txs in low blocks
const url = `/api?module=logs&action=getLogs&fromBlock=100&toBlock=latest&address=${address}&${topicParams}`;
return queryCeloscanPath<TransactionLog[]>(url);
}

/**
* @param path the path including query params but excluding API key
*/
export async function queryCeloscanPath<R>(path: string) {
const url = new URL(path, links.celoscanApi);
logger.debug(`Querying celoscan: ${url.toString()}`);
if (config.celoscanApiKey) url.searchParams.append('apikey', config.celoscanApiKey);
const result = await retryAsync(() => executeQuery<R>(url.toString()));
return result;
}

Expand Down
65 changes: 65 additions & 0 deletions src/features/governance/ProposalUpvotersTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { SpinnerWithLabel } from 'src/components/animation/Spinner';
import { Identicon } from 'src/components/icons/Identicon';
import { Collapse } from 'src/components/menus/Collapse';
import { MergedProposalData } from 'src/features/governance/useGovernanceProposals';
import { useProposalUpvoters } from 'src/features/governance/useProposalUpvoters';
import { useValidatorGroups } from 'src/features/validators/useValidatorGroups';
import { cleanGroupName } from 'src/features/validators/utils';
import { shortenAddress } from 'src/utils/addresses';
import { objKeys } from 'src/utils/objects';

export function ProposalUpvotersTable({ propData }: { propData: MergedProposalData }) {
return (
<div className="hidden md:block">
<Collapse
button={<h2 className="text-left font-serif text-2xl">Upvoters</h2>}
buttonClasses="w-full"
defaultOpen={true}
>
<UpvoterTableContent propData={propData} />
</Collapse>
</div>
);
}

function UpvoterTableContent({ propData }: { propData: MergedProposalData }) {
const { isLoading, upvoters } = useProposalUpvoters(propData.id);
const { addressToGroup } = useValidatorGroups();

const tableData = useMemo(() => {
if (!upvoters) return [];
return objKeys(upvoters).map((address) => {
const groupName = cleanGroupName(addressToGroup?.[address]?.name || '');
const label = groupName || shortenAddress(address);
return { label, address };
});
}, [upvoters, addressToGroup]);

if (isLoading) {
return (
<SpinnerWithLabel size="md" className="py-6">
Loading upvoters
</SpinnerWithLabel>
);
}

if (!tableData.length) {
return <div className="py-6 text-center text-sm text-gray-600">No upvoters found</div>;
}

return (
<table>
<tbody>
{tableData.map((row) => (
<tr key={row.address}>
<td className="py-2">
<Identicon address={row.address} size={20} />
</td>
<td className="px-4 py-2 text-sm">{row.label}</td>
</tr>
))}
</tbody>
</table>
);
}
83 changes: 40 additions & 43 deletions src/features/governance/ProposalVoteChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { useMemo } from 'react';
import { SpinnerWithLabel } from 'src/components/animation/Spinner';
import { ColoredChartDataItem, StackedBarChart } from 'src/components/charts/StackedBarChart';
import { Amount, formatNumberString } from 'src/components/numbers/Amount';
import {
ProposalStage,
VoteToColor,
VoteType,
VoteTypes,
} from 'src/features/governance/contractTypes';
import { VoteToColor, VoteType, VoteTypes } from 'src/features/governance/contractTypes';
import { MergedProposalData } from 'src/features/governance/useGovernanceProposals';
import { useProposalQuorum } from 'src/features/governance/useProposalQuorum';
import { useProposalVoteTotals } from 'src/features/governance/useProposalVoteTotals';
Expand All @@ -18,9 +13,7 @@ import { toTitleCase } from 'src/utils/strings';

export function ProposalVoteChart({ propData }: { propData: MergedProposalData }) {
const { isLoading, votes } = useProposalVoteTotals(propData);
const quorumRequired = useProposalQuorum(propData);

const yesVotes = votes?.[VoteType.Yes] || 0n;
const totalVotes = bigIntSum(Object.values(votes || {}));

const voteBarChartData = useMemo(
Expand All @@ -40,6 +33,39 @@ export function ProposalVoteChart({ propData }: { propData: MergedProposalData }
[votes, totalVotes],
);

if (isLoading) {
return (
<SpinnerWithLabel size="md" className="py-6">
Loading votes
</SpinnerWithLabel>
);
}

return (
<div className="space-y-2">
<h2 className="font-serif text-2xl">Result</h2>
<div className="space-y-1.5">
{Object.values(VoteTypes).map((v) => (
<div key={v} className="relative text-xs">
<StackedBarChart data={[voteBarChartData[v]]} showBorder={false} height="h-7" />
<span className="absolute left-2 top-1/2 -translate-y-1/2">{toTitleCase(v)}</span>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<span className="text-gray-500">{formatNumberString(voteBarChartData[v].value)}</span>
<span>{voteBarChartData[v].percentage?.toFixed(0) + '%'}</span>
</div>
</div>
))}
</div>
</div>
);
}

export function ProposalQuorumChart({ propData }: { propData: MergedProposalData }) {
const { votes } = useProposalVoteTotals(propData);
const quorumRequired = useProposalQuorum(propData);

const yesVotes = votes?.[VoteType.Yes] || 0n;

const quorumBarChartData = useMemo(
() => [
{
Expand All @@ -52,42 +78,13 @@ export function ProposalVoteChart({ propData }: { propData: MergedProposalData }
[yesVotes, quorumRequired],
);

if (isLoading) {
return (
<SpinnerWithLabel size="md" className="py-6">
Loading votes
</SpinnerWithLabel>
);
}

return (
<>
<div className="space-y-2">
<h2 className="font-serif text-2xl">Result</h2>
<div className="space-y-1.5">
{Object.values(VoteTypes).map((v) => (
<div key={v} className="relative text-xs">
<StackedBarChart data={[voteBarChartData[v]]} showBorder={false} height="h-7" />
<span className="absolute left-2 top-1/2 -translate-y-1/2">{toTitleCase(v)}</span>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<span className="text-gray-500">
{formatNumberString(voteBarChartData[v].value)}
</span>
<span>{voteBarChartData[v].percentage?.toFixed(0) + '%'}</span>
</div>
</div>
))}
</div>
<div className="space-y-2 border-t border-taupe-300 pt-2">
<Amount valueWei={yesVotes} className="text-2xl" decimals={0} />
<StackedBarChart data={quorumBarChartData} showBorder={false} />
<div className="flex items-center text-sm text-taupe-600">
{`Quorum required: ${formatNumberString(quorumRequired, 0, true)} CELO`}
</div>
{propData.stage === ProposalStage.Referendum && quorumRequired && (
<div className="space-y-2 border-t border-taupe-300 pt-2">
<Amount valueWei={yesVotes} className="text-2xl" decimals={0} />
<StackedBarChart data={quorumBarChartData} showBorder={false} />
<div className="flex items-center text-sm text-taupe-600">
{`Quorum required: ${formatNumberString(quorumRequired, 0, true)} CELO`}
</div>
</div>
)}
</>
</div>
);
}
45 changes: 24 additions & 21 deletions src/features/governance/ProposalVotersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ const NUM_VOTERS_TO_SHOW = 20;

export function ProposalVotersTable({ propData }: { propData: MergedProposalData }) {
return (
<div className="hidden md:block">
<Collapse
button={<h2 className="text-left font-serif text-2xl">Voters</h2>}
buttonClasses="w-full"
defaultOpen={propData.stage >= ProposalStage.Execution}
>
<VoterTableContent propData={propData} />
</Collapse>
</div>
<Collapse
button={<h2 className="text-left font-serif text-2xl">Voters</h2>}
buttonClasses="w-full"
defaultOpen={propData.stage >= ProposalStage.Execution}
>
<VoterTableContent propData={propData} />
</Collapse>
);
}

Expand Down Expand Up @@ -57,6 +55,7 @@ function VoterTableContent({ propData }: { propData: MergedProposalData }) {
const combinedByType = objMap(votesByType, (type) =>
sortAndCombineChartData(votesByType[type], NUM_VOTERS_TO_SHOW),
);
// Weave in type and flatten
const combined = objKeys(combinedByType)
.map((type) => combinedByType[type].map((v) => ({ ...v, type })))
.flat();
Expand All @@ -79,18 +78,22 @@ function VoterTableContent({ propData }: { propData: MergedProposalData }) {

return (
<table>
{tableData.map((row) => (
<tr key={row.label}>
<td className="py-2 text-sm">{row.label}</td>
<td className="px-4 py-2 text-sm font-medium">{toTitleCase(row.type)}</td>
<td>
<div className="flex items-center space-x-2 rounded-full bg-taupe-300 px-2">
<span className="text-sm">{`${row.percentage?.toFixed(1) || 0}%`}</span>
<span className="text-xs text-gray-500">{`(${formatNumberString(row.value)})`}</span>
</div>
</td>
</tr>
))}
<tbody>
{tableData.map((row) => (
<tr key={row.label}>
<td className="py-2 text-sm">{row.label}</td>
<td className="px-4 py-2 text-sm font-medium">{toTitleCase(row.type)}</td>
<td>
<div className="flex w-fit items-center space-x-2 rounded-full bg-taupe-300 px-2">
<span className="text-sm">{`${row.percentage?.toFixed(1) || 0}%`}</span>
<span className="text-xs text-gray-500">{`(${formatNumberString(
row.value,
)})`}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
);
}
76 changes: 76 additions & 0 deletions src/features/governance/useProposalUpvoters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { governanceABI } from '@celo/abis';
import { useQuery } from '@tanstack/react-query';
import { useToastError } from 'src/components/notifications/useToastError';
import { Addresses } from 'src/config/contracts';
import { queryCeloscanLogs } from 'src/features/explorers/celoscan';
import { TransactionLog } from 'src/features/explorers/types';
import { isValidAddress } from 'src/utils/addresses';
import { logger } from 'src/utils/logger';
import { objFilter } from 'src/utils/objects';
import { decodeEventLog, encodeEventTopics } from 'viem';

export function useProposalUpvoters(id?: number) {
const { isLoading, isError, error, data } = useQuery({
queryKey: ['useProposalUpvoters', id],
queryFn: () => {
if (!id) return null;
logger.debug(`Fetching proposals upvoters for ${id}`);
return fetchProposalUpvoters(id);
},
gcTime: Infinity,
staleTime: 60 * 60 * 1000, // 1 hour
});

useToastError(error, 'Error fetching proposals upvoters');

return {
isLoading,
isError,
upvoters: data || undefined,
};
}

async function fetchProposalUpvoters(id: number): Promise<AddressTo<bigint>> {
// Get encoded topics
const upvoteTopics = encodeEventTopics({
abi: governanceABI,
eventName: 'ProposalUpvoted',
args: { proposalId: BigInt(id) },
});

// Prep query URLs
const upvoteParams = `topic0=${upvoteTopics[0]}&topic1=${upvoteTopics[1]}&topic0_1_opr=and`;
const upvoteEvents = await queryCeloscanLogs(Addresses.Governance, upvoteParams);

// Reduce logs to a map of voters to upvotes
const voterToUpvotes: AddressTo<bigint> = {};
reduceLogs(voterToUpvotes, upvoteEvents);

// Filter out stakers who have already revoked all votes
return objFilter(voterToUpvotes, (_, votes): votes is bigint => votes > 0n);
}

function reduceLogs(voterToUpvotes: AddressTo<bigint>, logs: TransactionLog[]) {
for (const log of logs) {
try {
if (!log.topics || log.topics.length < 3) continue;
const { eventName, args } = decodeEventLog({
abi: governanceABI,
data: log.data,
// @ts-ignore https://github.com/wevm/viem/issues/381
topics: log.topics,
strict: false,
});

if (eventName !== 'ProposalUpvoted') continue;

const { account, upvotes } = args;
if (!account || !isValidAddress(account) || !upvotes) continue;

voterToUpvotes[account] ||= 0n;
voterToUpvotes[account] += upvotes;
} catch (error) {
logger.warn('Error decoding event log', error, log);
}
}
}
Loading

0 comments on commit 22dccb7

Please sign in to comment.