Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added quadratic voting #410

Merged
merged 3 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/types/election/published.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Vote } from '../vote';
import { MultiChoiceElection } from './multichoice';
import { BudgetElection } from './budget';
import { ApprovalElection } from './approval';
import { QuadraticElection } from './quadratic';

export enum ElectionStatus {
PROCESS_UNKNOWN = 'PROCESS_UNKNOWN',
Expand Down Expand Up @@ -115,6 +116,8 @@ export class PublishedElection extends Election {
return ApprovalElection.checkVote(vote, this.voteType);
case ElectionResultsTypeNames.BUDGET:
return BudgetElection.checkVote(vote, this.resultsType, this.voteType);
case ElectionResultsTypeNames.QUADRATIC:
return QuadraticElection.checkVote(vote, this.resultsType, this.voteType);
case ElectionResultsTypeNames.SINGLE_CHOICE_MULTIQUESTION:
default:
return PublishedElection.checkVote(vote, this.voteType);
Expand Down
169 changes: 169 additions & 0 deletions src/types/election/quadratic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { MultiLanguage } from '../../util/lang';
import { IElectionParameters, IVoteType } from './election';
import { UnpublishedElection } from './unpublished';
import { ElectionMetadata, ElectionMetadataTemplate, ElectionResultsType, ElectionResultsTypeNames } from '../metadata';
import { Vote } from '../vote';

export interface IQuadraticElectionParametersInfo extends IElectionParameters {
minStep?: number;
forceFullBudget?: boolean;
quadraticCost?: number;
}

export interface IQuadraticElectionParametersWithCensusWeight extends IQuadraticElectionParametersInfo {
useCensusWeightAsBudget: true;
}

export interface IQuadraticElectionParametersWithBudget extends IQuadraticElectionParametersInfo {
useCensusWeightAsBudget: false;
maxBudget: number;
}

export type IQuadraticElectionParameters =
| IQuadraticElectionParametersWithCensusWeight
| IQuadraticElectionParametersWithBudget;

/**
* Represents a quadratic election
*/
export class QuadraticElection extends UnpublishedElection {
private _minStep: number;
private _forceFullBudget: boolean;
private _quadraticCost: number;

/**
* Constructs a budget election
*
* @param params - Quadratic election parameters
*/
public constructor(params: IQuadraticElectionParameters) {
super(params);
this.quadraticCost = params.quadraticCost ?? 2;
this.minStep = params.minStep ?? 1;
this.forceFullBudget = params.forceFullBudget ?? false;
this.useCensusWeightAsBudget = params.useCensusWeightAsBudget;
if ('maxBudget' in params) {
this.maxBudget = params.maxBudget;
}
}

public static from(params: IQuadraticElectionParameters) {
return new QuadraticElection(params);
}

public addQuestion(
title: string | MultiLanguage<string>,
description: string | MultiLanguage<string>,
choices: Array<{ title: string } | { title: MultiLanguage<string> }>
) {
if (this.questions.length > 0) {
throw new Error('This type of election can only have one question');
}

return super.addQuestion(
title,
description,
choices.map((choice, index) => ({
title: typeof choice.title === 'string' ? { default: choice.title } : choice.title,
value: index,
}))
);
}

public generateVoteOptions(): object {
const maxCount = this.questions[0].choices.length;
const maxValue = 0;
const maxVoteOverwrites = this.voteType.maxVoteOverwrites;
const maxTotalCost = this.useCensusWeightAsBudget ? 0 : this.maxBudget;
const costExponent = this.quadraticCost;

return { maxCount, maxValue, maxVoteOverwrites, maxTotalCost, costExponent };
}

public generateEnvelopeType(): object {
const serial = false; // TODO
const anonymous = this.electionType.anonymous;
const encryptedVotes = this.electionType.secretUntilTheEnd;
const uniqueValues = false;
const costFromWeight = this.useCensusWeightAsBudget;

return { serial, anonymous, encryptedVotes, uniqueValues, costFromWeight };
}

public generateMetadata(): ElectionMetadata {
const metadata = ElectionMetadataTemplate;

metadata.type = {
name: ElectionResultsTypeNames.QUADRATIC,
properties: {
useCensusWeightAsBudget: this.useCensusWeightAsBudget,
maxBudget: this.useCensusWeightAsBudget ? null : this.maxBudget,
forceFullBudget: this.forceFullBudget,
minStep: this.minStep,
quadraticCost: this.quadraticCost,
},
};

return super.generateMetadata(metadata);
}

public static checkVote(vote: Vote, resultsType: ElectionResultsType, voteType: IVoteType): void {
if (resultsType.name != ElectionResultsTypeNames.QUADRATIC) {
throw new Error('Invalid results type');
}

if (voteType.maxCount != vote.votes.length) {
throw new Error('Invalid number of choices');
}

if (!voteType.costFromWeight) {
const voteWeight = vote.votes.reduce((a, b) => BigInt(a) + BigInt(b) ** BigInt(voteType.costExponent), 0);
if (voteType.maxTotalCost < voteWeight) {
throw new Error('Too much budget spent');
}
if (resultsType.properties.forceFullBudget && voteType.maxTotalCost != voteWeight) {
throw new Error('Not full budget used');
}
}
}

get minStep(): number {
return this._minStep;
}

set minStep(value: number) {
this._minStep = value;
}

get forceFullBudget(): boolean {
return this._forceFullBudget;
}

set forceFullBudget(value: boolean) {
this._forceFullBudget = value;
}

get useCensusWeightAsBudget(): boolean {
return this.voteType.costFromWeight;
}

set useCensusWeightAsBudget(value: boolean) {
this.voteType.costFromWeight = value;
}

get maxBudget(): number {
return this.voteType.maxTotalCost;
}

set maxBudget(value: number) {
this.voteType.maxTotalCost = value;
}

get quadraticCost(): number {
return this._quadraticCost;
}

set quadraticCost(value: number) {
this._quadraticCost = value;
}
}
12 changes: 12 additions & 0 deletions src/types/metadata/election.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum ElectionResultsTypeNames {
MULTIPLE_CHOICE = 'multiple-choice',
BUDGET = 'budget-based',
APPROVAL = 'approval',
QUADRATIC = 'quadratic',
}

export type ElectionResultsType =
Expand Down Expand Up @@ -69,6 +70,16 @@ export type ElectionResultsType =
rejectValue: number;
acceptValue: number;
};
}
| {
name: ElectionResultsTypeNames.QUADRATIC;
properties: {
useCensusWeightAsBudget: boolean;
maxBudget: number;
minStep: number;
forceFullBudget: boolean;
quadraticCost: number;
};
};

const electionMetadataSchema = object()
Expand Down Expand Up @@ -108,6 +119,7 @@ const electionMetadataSchema = object()
ElectionResultsTypeNames.MULTIPLE_CHOICE,
ElectionResultsTypeNames.BUDGET,
ElectionResultsTypeNames.APPROVAL,
ElectionResultsTypeNames.QUADRATIC,
]),
properties: object().optional().nullable(),
})
Expand Down
79 changes: 79 additions & 0 deletions test/integration/election.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { clientParams, setFaucetURL } from './util/client.params';
// @ts-ignore
import { waitForElectionReady } from './util/client.utils';
import { SDK_VERSION } from '../../src/version';
import { QuadraticElection } from '../../src/types/election/quadratic';

let client: VocdoniSDKClient;
let wallet: Wallet;
Expand Down Expand Up @@ -751,6 +752,84 @@ describe('Election integration tests', () => {
}).toThrow('Not full budget used');
});
}, 850000);
it('should create a quadratic election without weights and have the correct values set', async () => {
const census = new PlainCensus();
const participants: Wallet[] = [...new Array(5)].map(() => Wallet.createRandom());
census.add(participants.map((participant) => participant.address));

const election = QuadraticElection.from({
title: 'SDK Testing - Title',
description: 'SDK Testing - Description',
endDate: new Date().getTime() + 10000000,
census,
useCensusWeightAsBudget: false,
maxBudget: 18,
forceFullBudget: true,
quadraticCost: 2,
});

election.addQuestion('This is a title', 'This is a description', [
{
title: 'Red',
},
{
title: 'Green',
},
{
title: 'Blue',
},
{
title: 'White',
},
{
title: 'Black',
},
]);

await client.createAccount();
await client
.createElection(election)
.then((electionId) => {
client.setElectionId(electionId);
return electionId;
})
.then((electionId) =>
Promise.all(
participants.map(async (participant) => {
const pClient = new VocdoniSDKClient(clientParams(participant));
pClient.setElectionId(electionId);
const vote = new Vote([4, 1, 1, 0, 0]);
return pClient.submitVote(vote);
})
)
)
.then(() => client.fetchElection())
.then((election) => {
expect(election.voteType.maxCount).toEqual(5);
expect(election.voteType.maxValue).toEqual(0);
expect(election.voteType.maxTotalCost).toEqual(18);
expect(election.voteType.uniqueChoices).toEqual(false);
expect(election.resultsType.name).toEqual(ElectionResultsTypeNames.QUADRATIC);
expect(election.resultsType.properties).toStrictEqual({
useCensusWeightAsBudget: false,
maxBudget: 18,
forceFullBudget: true,
minStep: 1,
quadraticCost: 2,
});
expect(election.results).toStrictEqual([['20'], ['5'], ['5'], ['0'], ['0']]);
expect(election.checkVote(new Vote([4, 1, 1, 0, 0]))).toBeUndefined();
expect(() => {
election.checkVote(new Vote([15, 3, 1, 0]));
}).toThrow('Invalid number of choices');
expect(() => {
election.checkVote(new Vote([4, 2, 0, 0, 0]));
}).toThrow('Too much budget spent');
expect(() => {
election.checkVote(new Vote([2, 2, 0, 0, 0]));
}).toThrow('Not full budget used');
});
}, 850000);
it('should create a budget election with weights and have the correct values set', async () => {
const census = new WeightedCensus();
const participants: Wallet[] = [...new Array(5)].map(() => Wallet.createRandom());
Expand Down
Loading