From a2fe1c0ac23576552beb437be3382905ad1f7641 Mon Sep 17 00:00:00 2001 From: Marc Velmer Date: Tue, 28 Nov 2023 18:49:05 +0100 Subject: [PATCH] New anonymous flow --- CHANGELOG.md | 12 ++++ package.json | 2 +- src/client.ts | 129 ++++++++++++++++++++---------------- src/services/anonymous.ts | 41 +++++++----- src/services/election.ts | 40 ++++++++++- src/services/vote.ts | 8 +-- src/types/client/account.ts | 7 ++ src/types/vote/anonymous.ts | 13 +++- test/integration/zk.test.ts | 63 ++++++++++++++++-- 9 files changed, 227 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e123ea52..1da49d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2023-11-28 + +### Added + +- New election service functions `nextElectionId` and `getElectionSalt`. + +### Changed + +- [**BREAKING**] Refactored options for `isInCensus`, `hasAlreadyVoted`, `isAbleToVote` and `votesLeftCount`. +- [**BREAKING**] New options for `AnonymousVote` which enable to add the user's signature. +- [**BREAKING**] New internal anonymous flow when signature is given by the consumer. + ## [0.5.3] - 2023-11-28 ### Added diff --git a/package.json b/package.json index 0b8f04b9..0abd4660 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@vocdoni/sdk", "author": "Vocdoni", - "version": "0.5.3", + "version": "0.6.0", "description": "⚒️An SDK for building applications on top of Vocdoni API", "repository": "https://github.com/vocdoni/vocdoni-sdk.git", "license": "AGPL-3.0-or-later", diff --git a/src/client.ts b/src/client.ts index 33bcb29f..c2c1a81c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,13 +14,17 @@ import { CspVote, ElectionStatus, ElectionStatusReady, + HasAlreadyVotedOptions, InvalidElection, + IsAbleToVoteOptions, + IsInCensusOptions, PlainCensus, PublishedElection, SendTokensOptions, TokenCensus, UnpublishedElection, Vote, + VotesLeftCountOptions, WeightedCensus, } from './types'; import { API_URL, CENSUS_CHUNK_SIZE, EXPLORER_URL, FAUCET_URL, TX_WAIT_OPTIONS } from './util/constants'; @@ -212,14 +216,14 @@ export class VocdoniSDKClient { private setAccountSIK( electionId: string, - sik: string, + signature: string, password: string, censusProof: CensusProof, wallet: Wallet | Signer ): Promise { return wallet .getAddress() - .then((address) => AnonymousService.calcSik(address, sik, password)) + .then((address) => AnonymousService.calcSik(address, signature, password)) .then((calculatedSIK) => { const registerSIKTx = AccountCore.generateRegisterSIKTransaction(electionId, calculatedSIK, censusProof); return this.accountService.signTransaction(registerSIKTx.tx, registerSIKTx.message, wallet); @@ -233,30 +237,31 @@ export class VocdoniSDKClient { * * @param election * @param wallet + * @param signature * @param password * @returns {Promise} */ private async calcZKProofForWallet( election: PublishedElection, wallet: Wallet | Signer, + signature: string, password: string = '0' ): Promise { - const [address, sik, censusProof] = await Promise.all([ + const [address, censusProof] = await Promise.all([ wallet.getAddress(), - this.anonymousService.signSIKPayload(wallet), this.fetchProofForWallet(election.census.censusId, wallet), ]); return this.anonymousService .fetchAccountSIK(address) - .catch(() => this.setAccountSIK(election.id, sik, password, censusProof, wallet)) + .catch(() => this.setAccountSIK(election.id, signature, password, censusProof, wallet)) .then(() => this.anonymousService.fetchZKProof(address)) .then((zkProof) => AnonymousService.prepareCircuitInputs( election.id, address, password, - sik, + signature, censusProof.value, censusProof.value, zkProof.censusRoot, @@ -621,55 +626,52 @@ export class VocdoniSDKClient { /** * Checks if the user is in census. * - * @param {string} electionId The id of the election - * @param {Object} key The key in the census to check + * @param {HasAlreadyVotedOptions} options Options for is in census * @returns {Promise} */ - async isInCensus(electionId?: string, key?: string): Promise { - if (!this.electionId && !electionId) { - throw Error('No election set'); - } - if (!this.wallet && !key) { - throw Error('No key given or Wallet not found'); - } - - const election = await this.fetchElection(electionId ?? this.electionId); - let proofPromise; - - if (key) { - proofPromise = this.censusService.fetchProof(election.census.censusId, key); - } else if (election) { - proofPromise = this.fetchProofForWallet(election.census.censusId, this.wallet); - } else { - proofPromise = Promise.reject(); - } + async isInCensus(options?: IsInCensusOptions): Promise { + const settings = { + wallet: options?.wallet ?? this.wallet, + electionId: options?.electionId ?? this.electionId, + ...options, + }; + invariant(settings.wallet, 'No wallet or signer set or given'); + invariant(settings.electionId, 'No election identifier set or given'); - return proofPromise.then(() => true).catch(() => false); + return this.fetchElection(settings.electionId) + .then((election) => this.fetchProofForWallet(election.census.censusId, settings.wallet)) + .then(() => true) + .catch(() => false); } /** * Checks if the user has already voted * - * @param {string} electionId The id of the election + * @param {HasAlreadyVotedOptions} options Options for has already voted * @returns {Promise} The id of the vote */ - async hasAlreadyVoted(electionId?: string): Promise { - if (!this.electionId && !electionId) { - throw Error('No election set'); - } - if (!this.wallet) { - throw Error('No wallet found'); - } + async hasAlreadyVoted(options?: HasAlreadyVotedOptions): Promise { + const settings = { + wallet: options?.wallet ?? this.wallet, + electionId: options?.electionId ?? this.electionId, + ...options, + }; + invariant(settings.wallet, 'No wallet or signer set or given'); + invariant(settings.electionId, 'No election identifier set or given'); - const election = await this.fetchElection(electionId ?? this.electionId); + const election = await this.fetchElection(settings.electionId); - if (election.electionType.anonymous) { - throw Error('This function cannot be used with an anonymous election'); + if (election.electionType.anonymous && !settings?.voteId) { + throw Error('This function cannot be used without a vote identifier for an anonymous election'); } - return this.wallet + return settings.wallet .getAddress() - .then((address) => this.voteService.info(address.toLowerCase(), election.id)) + .then((address) => + this.voteService.info( + election.electionType.anonymous ? settings.voteId : keccak256(address.toLowerCase() + election.id) + ) + ) .then((voteInfo) => voteInfo.voteID) .catch(() => null); } @@ -677,41 +679,46 @@ export class VocdoniSDKClient { /** * Checks if the user is able to vote * - * @param {string} electionId The id of the election + * @param {IsAbleToVoteOptions} options Options for is able to vote * @returns {Promise} */ - isAbleToVote(electionId?: string): Promise { - return this.votesLeftCount(electionId).then((votesLeftCount) => votesLeftCount > 0); + isAbleToVote(options?: IsAbleToVoteOptions): Promise { + return this.votesLeftCount(options).then((votesLeftCount) => votesLeftCount > 0); } /** * Checks how many times a user can submit their vote * - * @param {string} electionId The id of the election + * @param {VotesLeftCountOptions} options Options for votes left count * @returns {Promise} */ - async votesLeftCount(electionId?: string): Promise { - if (!this.electionId && !electionId) { - throw Error('No election set'); - } - if (!this.wallet) { - throw Error('No wallet found'); - } + async votesLeftCount(options?: VotesLeftCountOptions): Promise { + const settings = { + wallet: options?.wallet ?? this.wallet, + electionId: options?.electionId ?? this.electionId, + ...options, + }; + invariant(settings.wallet, 'No wallet or signer set or given'); + invariant(settings.electionId, 'No election identifier set or given'); - const election = await this.fetchElection(electionId ?? this.electionId); + const election = await this.fetchElection(settings.electionId); - if (election.electionType.anonymous) { - throw Error('This function cannot be used with an anonymous election'); + if (election.electionType.anonymous && !settings?.voteId) { + throw Error('This function cannot be used without a vote identifier for an anonymous election'); } - const isInCensus = await this.isInCensus(election.id); + const isInCensus = await this.isInCensus({ electionId: election.id }); if (!isInCensus) { return Promise.resolve(0); } return this.wallet .getAddress() - .then((address) => this.voteService.info(address.toLowerCase(), election.id)) + .then((address) => + this.voteService.info( + election.electionType.anonymous ? settings.voteId : keccak256(address.toLowerCase() + election.id) + ) + ) .then((voteInfo) => election.voteType.maxVoteOverwrites - voteInfo.overwriteCount) .catch(() => election.voteType.maxVoteOverwrites + 1); } @@ -737,10 +744,16 @@ export class VocdoniSDKClient { if (election.census.type == CensusType.WEIGHTED) { censusProof = await this.fetchProofForWallet(election.census.censusId, this.wallet); } else if (election.census.type == CensusType.ANONYMOUS) { + let signature: string; + if (vote instanceof AnonymousVote) { + signature = vote.signature ?? (await this.anonymousService.signSIKPayload(this.wallet)); + } else { + signature = await this.anonymousService.signSIKPayload(this.wallet); + } if (vote instanceof AnonymousVote) { - censusProof = await this.calcZKProofForWallet(election, this.wallet, vote.password); + censusProof = await this.calcZKProofForWallet(election, this.wallet, signature, vote.password); } else { - censusProof = await this.calcZKProofForWallet(election, this.wallet); + censusProof = await this.calcZKProofForWallet(election, this.wallet, signature); } } else if (election.census.type == CensusType.CSP && vote instanceof CspVote) { censusProof = { diff --git a/src/services/anonymous.ts b/src/services/anonymous.ts index b4a4b1b8..a91dfd70 100644 --- a/src/services/anonymous.ts +++ b/src/services/anonymous.ts @@ -203,35 +203,46 @@ export class AnonymousService extends Service implements AnonymousServicePropert censusRoot: string, censusSiblings: string[] ): Promise { - signature = AnonymousService.signatureToVocdoniSikSignature(strip0x(signature)); - - const arboElectionId = await AnonymousService.arbo_utils.toHash(electionId); - const ffsignature = AnonymousService.ff_utils.hexToFFBigInt(strip0x(signature)).toString(); - const ffpassword = AnonymousService.ff_utils.hexToFFBigInt(hexlify(toUtf8Bytes(password))).toString(); - return Promise.all([ - AnonymousService.calcNullifier(ffsignature, ffpassword, arboElectionId), + AnonymousService.calcCircuitInputs(signature, password, electionId), AnonymousService.arbo_utils.toHash(AnonymousService.hex_utils.fromBigInt(BigInt(ensure0x(availableWeight)))), - ]).then((data) => ({ - electionId: arboElectionId, - nullifier: data[0].toString(), + ]).then(([circuitInputs, voteHash]) => ({ + electionId: circuitInputs.arboElectionId, + nullifier: circuitInputs.nullifier.toString(), availableWeight: AnonymousService.arbo_utils.toBigInt(availableWeight).toString(), - voteHash: data[1], + voteHash, sikRoot: AnonymousService.arbo_utils.toBigInt(sikRoot).toString(), censusRoot: AnonymousService.arbo_utils.toBigInt(censusRoot).toString(), address: AnonymousService.arbo_utils.toBigInt(strip0x(address)).toString(), - password: ffpassword, - signature: ffsignature, + password: circuitInputs.ffpassword, + signature: circuitInputs.ffsignature, voteWeight: AnonymousService.arbo_utils.toBigInt(voteWeight).toString(), sikSiblings, censusSiblings, })); } - static async calcNullifier(ffsignature: string, ffpassword: string, arboElectionId: string[]): Promise { + static async calcCircuitInputs(signature: string, password: string, electionId: string) { + signature = AnonymousService.signatureToVocdoniSikSignature(strip0x(signature)); + const arboElectionId = await AnonymousService.arbo_utils.toHash(electionId); + const ffsignature = AnonymousService.ff_utils.hexToFFBigInt(strip0x(signature)).toString(); + const ffpassword = AnonymousService.ff_utils.hexToFFBigInt(hexlify(toUtf8Bytes(password))).toString(); + const poseidon = await buildPoseidon(); const hash = poseidon([ffsignature, ffpassword, arboElectionId[0], arboElectionId[1]]); - return poseidon.F.toObject(hash); + const nullifier = poseidon.F.toObject(hash); + + return { nullifier, arboElectionId, ffsignature, ffpassword }; + } + + static async calcNullifier(signature: string, password: string, electionId: string): Promise { + return this.calcCircuitInputs(signature, password, electionId).then((circuitInputs) => circuitInputs.nullifier); + } + + static async calcVoteId(signature: string, password: string, electionId: string): Promise { + return this.calcNullifier(signature, password ?? '0', electionId).then((nullifier) => + nullifier.toString().length === 76 ? nullifier.toString() : nullifier.toString() + '0' + ); } static async calcSik(address: string, personal_sign: string, password: string = '0'): Promise { diff --git a/src/services/election.ts b/src/services/election.ts index 89fa12c5..e3296552 100644 --- a/src/services/election.ts +++ b/src/services/election.ts @@ -1,5 +1,12 @@ import { Service, ServiceProperties } from './service'; -import { Census, InvalidElection, PublishedCensus, PublishedElection, UnpublishedElection } from '../types'; +import { + ArchivedElection, + Census, + InvalidElection, + PublishedCensus, + PublishedElection, + UnpublishedElection, +} from '../types'; import { AccountAPI, ElectionAPI, IElectionCreateResponse, IElectionKeysResponse } from '../api'; import { CensusService } from './census'; import { allSettled } from '../util/promise'; @@ -9,7 +16,8 @@ import { ChainService } from './chain'; import { Wallet } from '@ethersproject/wallet'; import { Signer } from '@ethersproject/abstract-signer'; import { ArchivedCensus } from '../types/census/archived'; -import { ArchivedElection } from '../types/election/archived'; +import { keccak256 } from '@ethersproject/keccak256'; +import { Buffer } from 'buffer'; interface ElectionServiceProperties { censusService: CensusService; @@ -217,6 +225,34 @@ export class ElectionService extends Service implements ElectionServicePropertie }).then((response) => response.electionID); } + /** + * Returns an election salt for address + * + * @param {string} address The address of the account + * @param {number} electionCount The election count + * @returns {Promise} The election salt + */ + getElectionSalt(address: string, electionCount: number): Promise { + invariant(this.url, 'No URL set'); + invariant(this.chainService, 'No chain service set'); + return this.chainService.fetchChainData().then((chainData) => { + return keccak256(Buffer.from(address + chainData.chainId + electionCount.toString())); + }); + } + + /** + * Returns a numeric election identifier + * + * @param {string} electionId The identifier of the election + * @returns {number} The numeric identifier + */ + getNumericElectionId(electionId: string): number { + const arr = electionId.substring(electionId.length - 8, electionId.length).match(/.{1,2}/g); + const uint32Array = new Uint8Array(arr.map((byte) => parseInt(byte, 16))); + const dataView = new DataView(uint32Array.buffer); + return dataView.getUint32(0); + } + /** * Fetches the encryption keys from the specified process. * diff --git a/src/services/vote.ts b/src/services/vote.ts index 7a3aac7b..d6438ebe 100644 --- a/src/services/vote.ts +++ b/src/services/vote.ts @@ -5,7 +5,6 @@ import { Wallet } from '@ethersproject/wallet'; import { Signer } from '@ethersproject/abstract-signer'; import { VoteCore } from '../core/vote'; import { IVoteInfoResponse, IVoteSubmitResponse, VoteAPI } from '../api'; -import { keccak256 } from '@ethersproject/keccak256'; interface VoteServiceProperties { chainService: ChainService; @@ -44,14 +43,13 @@ export class VoteService extends Service implements VoteServiceProperties { /** * Get the vote information * - * @param {string} address The address of the voter - * @param {string} electionId The id of the election + * @param {string} voteId The identifier of the vote * * @returns {Promise} */ - info(address: string, electionId: string): Promise { + info(voteId: string): Promise { invariant(this.url, 'No URL set'); - return VoteAPI.info(this.url, keccak256(address.toLowerCase() + electionId)); + return VoteAPI.info(this.url, voteId); } /** diff --git a/src/types/client/account.ts b/src/types/client/account.ts index c75f35a3..4e019f37 100644 --- a/src/types/client/account.ts +++ b/src/types/client/account.ts @@ -2,4 +2,11 @@ import { Wallet } from '@ethersproject/wallet'; import { Signer } from '@ethersproject/abstract-signer'; export type WalletOption = { wallet: Wallet | Signer }; +export type ElectionIdOption = { electionId: string }; +export type VoteIdOption = { voteId: string }; + export type SendTokensOptions = Partial & { to: string; amount: number }; +export type IsInCensusOptions = Partial; +export type HasAlreadyVotedOptions = Partial; +export type VotesLeftCountOptions = Partial; +export type IsAbleToVoteOptions = Partial; diff --git a/src/types/vote/anonymous.ts b/src/types/vote/anonymous.ts index 9adb6ac4..b5255515 100644 --- a/src/types/vote/anonymous.ts +++ b/src/types/vote/anonymous.ts @@ -2,16 +2,19 @@ import { Vote } from './vote'; export class AnonymousVote extends Vote { private _password: string; + private _signature: string; /** * Constructs a csp vote * * @param votes The list of votes values + * @param signature The signature of the payload * @param password The password of the anonymous vote */ - public constructor(votes: Array, password: string = '0') { + public constructor(votes: Array, signature?: string, password: string = '0') { super(votes); this.password = password; + this.signature = signature; } get password(): string { @@ -21,4 +24,12 @@ export class AnonymousVote extends Vote { set password(value: string) { this._password = value; } + + get signature(): string { + return this._signature; + } + + set signature(value: string) { + this._signature = value; + } } diff --git a/test/integration/zk.test.ts b/test/integration/zk.test.ts index fd057f05..1bac65e2 100644 --- a/test/integration/zk.test.ts +++ b/test/integration/zk.test.ts @@ -1,6 +1,7 @@ // @ts-ignore import { clientParams, setFaucetURL } from './util/client.params'; import { + AnonymousService, AnonymousVote, Election, ElectionStatus, @@ -87,12 +88,12 @@ describe('zkSNARK test', () => { await expect(async () => { await client.submitVote(new Vote([0])); }).rejects.toThrow(); - const vote = new AnonymousVote([0], 'password123'); + const vote = new AnonymousVote([0], null, 'password123'); return client.submitVote(vote); }) .then(() => { client.wallet = voter1; - const vote = new AnonymousVote([0], 'password456'); + const vote = new AnonymousVote([0], null, 'password456'); return client.submitVote(vote); }) .then(() => { @@ -113,6 +114,56 @@ describe('zkSNARK test', () => { expect(election.census.weight).toEqual(BigInt(3)); }); }, 285000); + it('should create an anonymous election, vote and check if the user has voted successfully', async () => { + const census = new PlainCensus(); + census.add((client.wallet as Wallet).address); + + const election = createElection( + census, + { + anonymous: true, + }, + { + maxVoteOverwrites: 9, + } + ); + + let nullifier: string; + let vote: AnonymousVote; + + await client.createAccount(); + + await client + .createElection(election) + .then((electionId) => { + expect(electionId).toMatch(/^[0-9a-fA-F]{64}$/); + client.setElectionId(electionId); + return client.fetchElection(); + }) + .then((publishedElection) => { + expect(publishedElection.electionType.anonymous).toBeTruthy(); + return waitForElectionReady(client, publishedElection.id); + }) + .then(async () => { + const signature = await client.anonymousService.signSIKPayload(client.wallet); + + vote = new AnonymousVote([0], signature); + nullifier = await AnonymousService.calcVoteId(signature, null, client.electionId); + + const hasAlreadyVoted = await client.hasAlreadyVoted({ voteId: nullifier }); + expect(hasAlreadyVoted).toBeFalsy(); + + return client.submitVote(vote); + }) + .then(() => client.submitVote(vote)) + .then(async (voteId) => { + expect(voteId).toEqual(nullifier); + const hasAlreadyVoted = await client.hasAlreadyVoted({ voteId }); + expect(hasAlreadyVoted).toBeTruthy(); + const votesLeftCount = await client.votesLeftCount({ voteId }); + expect(votesLeftCount).toEqual(8); // The user voted twice + }); + }, 285000); it('should create a weighted anonymous election and vote successfully', async () => { const census = new WeightedCensus(); const voter1 = Wallet.createRandom(); @@ -157,12 +208,12 @@ describe('zkSNARK test', () => { await expect(async () => { await client.submitVote(new Vote([0])); }).rejects.toThrow(); - const vote = new AnonymousVote([0], 'password123'); + const vote = new AnonymousVote([0], null, 'password123'); return client.submitVote(vote); }) .then(() => { client.wallet = voter1; - const vote = new AnonymousVote([0], 'password456'); + const vote = new AnonymousVote([0], null, 'password456'); return client.submitVote(vote); }) .then(() => { @@ -214,9 +265,9 @@ describe('zkSNARK test', () => { password: participants[i].address, }); await expect(async () => { - await client.submitVote(new AnonymousVote([i % 2], 'wrongpassword')); + await client.submitVote(new AnonymousVote([i % 2], null, 'wrongpassword')); }).rejects.toThrow(); - vote = new AnonymousVote([i % 2], participants[i].address); + vote = new AnonymousVote([i % 2], null, participants[i].address); } else if (i % 3 == 1) { await client.createAccount({ sik: false }); }