diff --git a/.env.example b/.env.example index abb99cb9..0e86b964 100644 --- a/.env.example +++ b/.env.example @@ -97,3 +97,6 @@ BLOB_READ_WRITE_TOKEN="" # URL with tally-{pollId}.json hosted NEXT_PUBLIC_TALLY_URL=https://upblxu2duoxmkobt.public.blob.vercel-storage.com + +# Whether the poll is in qv or non qv mode +NEXT_PUBLIC_POLL_MODE="non-qv" diff --git a/src/config.ts b/src/config.ts index 39769816..a9a9bea5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ export const config = { maciSubgraphUrl: process.env.NEXT_PUBLIC_MACI_SUBGRAPH_URL ?? "", tallyUrl: process.env.NEXT_PUBLIC_TALLY_URL, roundOrganizer: process.env.NEXT_PUBLIC_ROUND_ORGANIZER ?? "Optimism", + pollMode: process.env.NEXT_PUBLIC_POLL_MODE ?? "non-qv", }; export const theme = { diff --git a/src/contexts/Ballot.tsx b/src/contexts/Ballot.tsx index ef1a5464..06c59ded 100644 --- a/src/contexts/Ballot.tsx +++ b/src/contexts/Ballot.tsx @@ -1,6 +1,8 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from "react"; import { useAccount } from "wagmi"; +import { config } from "~/config"; + import type { BallotContextType, BallotProviderProps } from "./types"; import type { Ballot, Vote } from "~/features/ballot/types"; @@ -13,9 +15,14 @@ export const BallotProvider: React.FC = ({ children }: Ball const { isDisconnected } = useAccount(); + // when summing the ballot we take the individual vote and square it + // if the mode is quadratic voting, otherwise we just add the amount const sumBallot = useCallback( (votes?: Vote[]) => - (votes ?? []).reduce((sum, x) => sum + (!Number.isNaN(Number(x.amount)) ? Number(x.amount) : 0), 0), + (votes ?? []).reduce((sum, x) => { + const amount = !Number.isNaN(Number(x.amount)) ? Number(x.amount) : 0; + return sum + (config.pollMode === "qv" ? amount ** 2 : amount); + }, 0), [], ); @@ -71,7 +78,7 @@ export const BallotProvider: React.FC = ({ children }: Ball localStorage.removeItem("ballot"); }, [setBallot]); - // set published to tru + // set published to true const publishBallot = useCallback(() => { setBallot({ ...ballot, published: true }); }, [ballot, setBallot]); diff --git a/src/env.js b/src/env.js index 1abce393..53480895 100644 --- a/src/env.js +++ b/src/env.js @@ -69,6 +69,8 @@ export const env = createEnv({ NEXT_PUBLIC_MACI_SUBGRAPH_URL: z.string().url().optional(), NEXT_PUBLIC_TALLY_URL: z.string().url(), + + NEXT_PUBLIC_POLL_MODE: z.enum(["qv", "non-qv"]).default("non-qv"), }, /** @@ -107,6 +109,8 @@ export const env = createEnv({ NEXT_PUBLIC_MACI_SUBGRAPH_URL: process.env.NEXT_PUBLIC_MACI_SUBGRAPH_URL, NEXT_PUBLIC_TALLY_URL: process.env.NEXT_PUBLIC_TALLY_URL, + + NEXT_PUBLIC_POLL_MODE: process.env.NEXT_PUBLIC_POLL_MODE, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/features/projects/components/AddToBallot.tsx b/src/features/projects/components/AddToBallot.tsx index 2b325fea..ae50a692 100644 --- a/src/features/projects/components/AddToBallot.tsx +++ b/src/features/projects/components/AddToBallot.tsx @@ -34,7 +34,7 @@ const ProjectAllocation = ({ const form = useFormContext(); const formAmount = form.watch("amount") as string; const amount = formAmount ? parseFloat(String(formAmount).replace(/,/g, "")) : 0; - const total = amount + current; + const total = (config.pollMode === "qv" ? amount ** 2 : amount) + current; const { initialVoiceCredits } = useMaci(); const exceededProjectTokens = amount > initialVoiceCredits; @@ -125,7 +125,7 @@ export const ProjectAddToBallot = ({ id = "", name = "" }: IProjectAddToBallotPr {!ballot?.published && inBallot && ( - {formatNumber(inBallot.amount)} allocated + {formatNumber(config.pollMode === "qv" ? inBallot.amount ** 2 : inBallot.amount)} allocated )} @@ -151,7 +151,7 @@ export const ProjectAddToBallot = ({ id = "", name = "" }: IProjectAddToBallotPr amount: z .number() .min(0) - .max(Math.min(initialVoiceCredits, initialVoiceCredits - sum)) + .max(Math.sqrt(Math.min(initialVoiceCredits, initialVoiceCredits - sum))) .default(0), })} onSubmit={({ amount }) => { diff --git a/src/features/projects/hooks/useSelectProjects.ts b/src/features/projects/hooks/useSelectProjects.ts index c4c514e1..934070cd 100644 --- a/src/features/projects/hooks/useSelectProjects.ts +++ b/src/features/projects/hooks/useSelectProjects.ts @@ -27,7 +27,6 @@ export function useSelectProjects(): IUseSelectProjectsReturn { return { count: toAdd.length, - // isLoading: add.isPending, add: () => { addToBallot(toAdd, pollId!); setSelected({}); diff --git a/src/utils/calculateResults.test.ts b/src/utils/calculateResults.test.ts deleted file mode 100644 index 9350a6f3..00000000 --- a/src/utils/calculateResults.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { calculateVotes } from "./calculateResults"; - -describe("Calculate results", () => { - const ballots = [ - { - voterId: "voterA", - votes: [ - { - projectId: "projectA", - amount: 20, - }, - { - projectId: "projectB", - amount: 30, - }, - ], - }, - { - voterId: "voterB", - votes: [ - { - projectId: "projectA", - amount: 22, - }, - { - projectId: "projectB", - amount: 50, - }, - ], - }, - { - voterId: "voterC", - votes: [ - { - projectId: "projectA", - amount: 30, - }, - { - projectId: "projectB", - amount: 40, - }, - { - projectId: "projectC", - amount: 60, - }, - ], - }, - { - voterId: "voterD", - votes: [ - { - projectId: "projectA", - amount: 35, - }, - { - projectId: "projectC", - amount: 70, - }, - ], - }, - ]; - test("custom payout", () => { - const actual = calculateVotes(ballots, { style: "custom" }); - - expect(actual).toMatchInlineSnapshot(` - { - "projectA": { - "voters": 4, - "votes": 107, - }, - "projectB": { - "voters": 3, - "votes": 120, - }, - "projectC": { - "voters": 2, - "votes": 130, - }, - } - `); - }); - test("OP-style payout", () => { - const actual = calculateVotes(ballots, { style: "op", threshold: 3 }); - expect(actual).toMatchInlineSnapshot(` - { - "projectA": { - "voters": 4, - "votes": 26, - }, - "projectB": { - "voters": 3, - "votes": 40, - }, - } - `); - }); -}); diff --git a/src/utils/calculateResults.ts b/src/utils/calculateResults.ts deleted file mode 100644 index 798b3bc4..00000000 --- a/src/utils/calculateResults.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type Vote } from "~/features/ballot/types"; - -/* -Payout styles: -Custom: -- Sum up all the votes -OP-style: -- A project must have a minimum of x voters (threshold) -- Median value is counted -*/ - -export interface PayoutOptions { - style: "custom" | "op"; - threshold?: number; -} - -export type BallotResults = Record< - string, - { - voters: number; - votes: number; - } ->; - -export function calculateVotes( - ballots: { voterId: string; votes: Vote[] }[], - payoutOpts: PayoutOptions = { style: "custom" }, -): BallotResults { - const projectVotes: Record< - string, - { - total: number; - amounts: number[]; - voterIds: Set; - } - > = {}; - - ballots.forEach((ballot) => { - ballot.votes.forEach((vote) => { - if (!projectVotes[vote.projectId]) { - projectVotes[vote.projectId] = { - total: 0, - amounts: [], - voterIds: new Set(), - }; - } - projectVotes[vote.projectId]!.total += vote.amount; - projectVotes[vote.projectId]!.amounts.push(vote.amount); - projectVotes[vote.projectId]!.voterIds.add(ballot.voterId); - }); - }); - - const projects: BallotResults = {}; - - Object.entries(projectVotes).forEach(([projectId, value]) => { - const { total, amounts, voterIds } = value; - - const voteIsCounted = - payoutOpts.style === "custom" || (payoutOpts.threshold && voterIds.size >= payoutOpts.threshold); - - if (voteIsCounted) { - projects[projectId] = { - voters: voterIds.size, - votes: payoutOpts.style === "op" ? calculateMedian(amounts.sort((a, b) => a - b)) : total, - }; - } - }); - - return projects; -} - -function calculateMedian(arr: number[]): number { - const mid = Math.floor(arr.length / 2); - return arr.length % 2 !== 0 ? arr[mid] ?? 0 : ((arr[mid - 1] ?? 0) + (arr[mid] ?? 0)) / 2; -}