diff --git a/packages/cli/package.json b/packages/cli/package.json index b7d3a0b08..b805287b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -54,6 +54,7 @@ "maci-core": "^2.5.0", "maci-crypto": "^2.5.0", "maci-domainobjs": "^2.5.0", + "maci-sdk": "^0.0.1", "prompt": "^1.3.0" }, "devDependencies": { diff --git a/packages/cli/ts/index.ts b/packages/cli/ts/index.ts index 1c428054d..ca0a6a305 100644 --- a/packages/cli/ts/index.ts +++ b/packages/cli/ts/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "@commander-js/extra-typings"; +import { generateTallyCommitments, getPollParams, verify, getPoll } from "maci-sdk"; import fs from "fs"; import path from "path"; @@ -15,14 +16,12 @@ import { deploy, showContracts, deployPoll, - getPoll, publish, setVerifyingKeys, mergeSignups, timeTravel, signup, isRegisteredUser, - verify, genProofs, fundWallet, proveOnChain, @@ -32,7 +31,7 @@ import { joinPoll, isJoinedUser, } from "./commands"; -import { TallyData, logError, promptSensitiveValue, readContractAddress } from "./utils"; +import { TallyData, banner, logError, logGreen, promptSensitiveValue, readContractAddress, success } from "./utils"; // set the description version and name of the cli tool const { description, version, name } = JSON.parse( @@ -535,7 +534,6 @@ program .description("Get deployed poll from MACI contract") .option("-p, --poll ", "the poll id") .option("-x, --maci-address ", "the MACI contract address") - .option("-q, --quiet ", "whether to print values to the console", (value) => value === "true", false) .action(async (cmdObj) => { try { const signer = await getSigner(); @@ -543,12 +541,25 @@ program const maciAddress = cmdObj.maciAddress || (await readContractAddress("MACI", network?.name)); - await getPoll({ + const details = await getPoll({ pollId: cmdObj.poll, maciAddress, signer, - quiet: cmdObj.quiet, }); + + logGreen( + true, + success( + [ + `ID: ${details.id}`, + `Deploy time: ${new Date(Number(details.deployTime) * 1000).toString()}`, + `End time: ${new Date(Number(details.deployTime) + Number(details.duration) * 1000).toString()}`, + `Number of signups ${details.numSignups}`, + `State tree merged: ${details.isMerged}`, + `Mode: ${details.mode === 0n ? "Quadratic Voting" : "Non-Quadratic Voting"}`, + ].join("\n"), + ), + ); } catch (error) { program.error((error as Error).message, { exitCode: 1 }); } @@ -582,6 +593,7 @@ program .option("-r, --rpc-provider ", "the rpc provider URL") .action(async (cmdObj) => { try { + banner(cmdObj.quiet); const signer = await getSigner(); const network = await signer.provider?.getNetwork(); @@ -595,12 +607,20 @@ program const maciAddress = tallyData.maci || cmdObj.maciAddress || (await readContractAddress("MACI", network?.name)); + const pollParams = await getPollParams({ pollId: cmdObj.pollId, maciContractAddress: maciAddress, signer }); + const tallyCommitments = generateTallyCommitments({ + tallyData, + voteOptionTreeDepth: pollParams.voteOptionTreeDepth, + }); + await verify({ tallyData, pollId: cmdObj.pollId, maciAddress, - quiet: cmdObj.quiet, signer, + tallyCommitments, + numVoteOptions: pollParams.numVoteOptions, + voteOptionTreeDepth: pollParams.voteOptionTreeDepth, }); } catch (error) { program.error((error as Error).message, { exitCode: 1 }); diff --git a/packages/sdk/.eslintrc.js b/packages/sdk/.eslintrc.js new file mode 100644 index 000000000..fc75c956d --- /dev/null +++ b/packages/sdk/.eslintrc.js @@ -0,0 +1,20 @@ +const path = require("path"); + +module.exports = { + root: true, + extends: ["../../.eslintrc.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: path.resolve(__dirname, "./tsconfig.json"), + sourceType: "module", + typescript: true, + ecmaVersion: 2022, + experimentalDecorators: true, + requireConfigFile: false, + ecmaFeatures: { + classes: true, + impliedStrict: true, + }, + warnOnUnsupportedTypeScriptVersion: true, + }, +}; diff --git a/packages/sdk/.gitignore b/packages/sdk/.gitignore new file mode 100644 index 000000000..d195e77ec --- /dev/null +++ b/packages/sdk/.gitignore @@ -0,0 +1,6 @@ +contractAddresses.json +contractAddresses.old.json +contractAddress.old +contractAddress.txt +localState.json +zkeys diff --git a/packages/sdk/.npmignore b/packages/sdk/.npmignore new file mode 100644 index 000000000..069ee7f78 --- /dev/null +++ b/packages/sdk/.npmignore @@ -0,0 +1,4 @@ +tests +build/tests +.etherlime-store +.env diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 000000000..38d0842e6 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,12 @@ +# maci-sdk + +[![NPM Package][cli-npm-badge]][cli-npm-link] +[![Actions Status][cli-actions-badge]][cli-actions-link] + +Please refer to the [documentation for the +CLI](https://maci.pse.dev/docs/developers-references/typescript-code/cli). + +[cli-npm-badge]: https://img.shields.io/npm/v/maci-sdk.svg +[cli-actions-badge]: https://github.com/privacy-scaling-explorations/maci/actions/workflows/e2e.yml/badge.svg +[cli-npm-link]: https://www.npmjs.com/package/maci-sdk +[cli-actions-link]: https://github.com/privacy-scaling-explorations/maci/actions?query=workflow%3ACI diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 000000000..0b8e6e664 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,77 @@ +{ + "name": "maci-sdk", + "version": "0.0.1", + "description": "MACI's SDK", + "main": "build/ts/index.js", + "exports": { + ".": { + "types": "./build/ts/index.d.ts", + "default": "./build/ts/index.js" + }, + "./sdk": { + "types": "./build/ts/sdk/index.d.ts", + "default": "./build/ts/sdk/index.js" + } + }, + "bin": { + "maci-sdk": "./build/ts/index.js" + }, + "files": [ + "build", + "CHANGELOG.md", + "README.md" + ], + "scripts": { + "watch": "tsc --watch", + "build": "tsc -p tsconfig.build.json", + "postbuild": "cp package.json ./build && mkdir -p ./zkeys", + "types": "tsc -p tsconfig.json --noEmit", + "test": "nyc ts-mocha --exit tests/unit/*.test.ts", + "docs": "typedoc --plugin typedoc-plugin-markdown --options ./typedoc.json" + }, + "dependencies": { + "@commander-js/extra-typings": "^12.1.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5", + "ethers": "^6.13.4", + "hardhat": "^2.22.8", + "maci-contracts": "^2.5.0", + "maci-crypto": "^2.5.0" + }, + "devDependencies": { + "@types/chai": "^4.3.9", + "@types/chai-as-promised": "^7.1.8", + "@types/mocha": "^10.0.8", + "@types/node": "^22.9.0", + "@types/snarkjs": "^0.7.8", + "chai": "^4.3.10", + "chai-as-promised": "^7.1.2", + "mocha": "^10.7.3", + "nyc": "^17.1.0", + "snarkjs": "^0.7.5", + "ts-mocha": "^10.0.0", + "typescript": "^5.6.3" + }, + "nyc": { + "reporter": [ + "text", + "lcov" + ], + "extensions": [ + ".ts" + ], + "all": true, + "exclude": [ + "**/*.js", + "**/*.d.ts", + "hardhat.config.ts", + "tests/**/*.ts", + "ts/index.ts" + ], + "branches": ">50%", + "lines": ">50%", + "functions": ">50%", + "statements": ">50%" + } +} diff --git a/packages/sdk/ts/index.ts b/packages/sdk/ts/index.ts new file mode 100644 index 000000000..d1f342a2e --- /dev/null +++ b/packages/sdk/ts/index.ts @@ -0,0 +1,16 @@ +export { getPoll, getPollParams } from "./poll"; +export { verify } from "./verify"; +export { generateTallyCommitments } from "./tallyCommitments"; + +export { + linkPoseidonLibraries, + Deployment, + ContractStorage, + EContracts, + EMode, + type IVerifyingKeyStruct, +} from "maci-contracts"; + +export * from "maci-contracts/typechain-types"; + +export type { TallyData, VerifyArgs, IGetPollArgs, IGetPollData } from "./utils"; diff --git a/packages/sdk/ts/poll.ts b/packages/sdk/ts/poll.ts new file mode 100644 index 000000000..5c4d8ed1b --- /dev/null +++ b/packages/sdk/ts/poll.ts @@ -0,0 +1,89 @@ +import { ZeroAddress } from "ethers"; +import { + MACI__factory as MACIFactory, + Poll__factory as PollFactory, + Tally__factory as TallyFactory, +} from "maci-contracts/typechain-types"; + +import type { IGetPollArgs, IGetPollData, IGetPollParamsArgs, IPollParams } from "./utils/interfaces"; + +/** + * Get deployed poll from MACI contract + * @param {IGetPollArgs} args - The arguments for the get poll command + * @returns {IGetPollData} poll data + */ +export const getPoll = async ({ maciAddress, signer, provider, pollId }: IGetPollArgs): Promise => { + if (!signer && !provider) { + throw new Error("No signer and provider are provided"); + } + + const maciContract = MACIFactory.connect(maciAddress, signer ?? provider); + const id = + pollId === undefined ? await maciContract.nextPollId().then((nextPollId) => nextPollId - 1n) : BigInt(pollId); + + if (id < 0n) { + throw new Error(`Invalid poll id ${id}`); + } + + const pollContracts = await maciContract.polls(id); + + if (pollContracts.poll === ZeroAddress) { + throw new Error(`MACI contract doesn't have any deployed poll ${id}`); + } + + const pollContract = PollFactory.connect(pollContracts.poll, signer ?? provider); + + const [[deployTime, duration], mergedStateRoot] = await Promise.all([ + pollContract.getDeployTimeAndDuration(), + pollContract.mergedStateRoot(), + ]); + const isMerged = mergedStateRoot !== BigInt(0); + const numSignups = await (isMerged ? pollContract.numSignups() : maciContract.numSignUps()); + + // get the poll mode + const tallyContract = TallyFactory.connect(pollContracts.tally, signer ?? provider); + const mode = await tallyContract.mode(); + + return { + id, + address: pollContracts.poll, + deployTime, + duration, + numSignups, + isMerged, + mode, + }; +}; + +/** + * Get the parameters for the poll + * @param {IGetPollParamsArgs} args - The arguments for the get poll command + * @returns {IPollParams} poll parameters + */ +export const getPollParams = async ({ + pollId, + signer, + maciContractAddress, +}: IGetPollParamsArgs): Promise => { + // get the contract objects + const maciContract = MACIFactory.connect(maciContractAddress, signer); + const pollContracts = await maciContract.polls(pollId); + const pollContract = PollFactory.connect(pollContracts.poll, signer); + + const treeDepths = await pollContract.treeDepths(); + const voteOptionTreeDepth = Number(treeDepths.voteOptionTreeDepth); + const numVoteOptions = 5 ** voteOptionTreeDepth; + + const messageBatchSize = Number.parseInt((await pollContract.messageBatchSize()).toString(), 10); + + const intStateTreeDepth = Number(treeDepths.intStateTreeDepth); + const tallyBatchSize = 5 ** intStateTreeDepth; + + return { + messageBatchSize, + numVoteOptions, + tallyBatchSize, + voteOptionTreeDepth, + intStateTreeDepth, + }; +}; diff --git a/packages/sdk/ts/tallyCommitments.ts b/packages/sdk/ts/tallyCommitments.ts new file mode 100644 index 000000000..35bb18180 --- /dev/null +++ b/packages/sdk/ts/tallyCommitments.ts @@ -0,0 +1,55 @@ +import { genTreeCommitment, hash2, hash3, hashLeftRight } from "maci-crypto"; + +import { IGenerateTallyCommitmentsArgs, ITallyCommitments } from "./utils/interfaces"; + +/** + * Generate the tally commitments for this current batch of proving + * @param tallyData - The tally data + * @param voteOptionTreeDepth - The vote option tree depth + * @returns The commitments to the Tally data + */ +export const generateTallyCommitments = ({ + tallyData, + voteOptionTreeDepth, +}: IGenerateTallyCommitmentsArgs): ITallyCommitments => { + // compute newResultsCommitment + const newResultsCommitment = genTreeCommitment( + tallyData.results.tally.map((x) => BigInt(x)), + BigInt(tallyData.results.salt), + voteOptionTreeDepth, + ); + + // compute newSpentVoiceCreditsCommitment + const newSpentVoiceCreditsCommitment = hash2([ + BigInt(tallyData.totalSpentVoiceCredits.spent), + BigInt(tallyData.totalSpentVoiceCredits.salt), + ]); + + let newTallyCommitment: bigint; + let newPerVOSpentVoiceCreditsCommitment: bigint | undefined; + + if (tallyData.isQuadratic) { + // compute newPerVOSpentVoiceCreditsCommitment + newPerVOSpentVoiceCreditsCommitment = genTreeCommitment( + tallyData.perVOSpentVoiceCredits!.tally.map((x) => BigInt(x)), + BigInt(tallyData.perVOSpentVoiceCredits!.salt), + voteOptionTreeDepth, + ); + + // compute newTallyCommitment + newTallyCommitment = hash3([ + newResultsCommitment, + newSpentVoiceCreditsCommitment, + newPerVOSpentVoiceCreditsCommitment, + ]); + } else { + newTallyCommitment = hashLeftRight(newResultsCommitment, newSpentVoiceCreditsCommitment); + } + + return { + newTallyCommitment, + newSpentVoiceCreditsCommitment, + newPerVOSpentVoiceCreditsCommitment, + newResultsCommitment, + }; +}; diff --git a/packages/sdk/ts/utils/contracts.ts b/packages/sdk/ts/utils/contracts.ts new file mode 100644 index 000000000..72ea0d6f6 --- /dev/null +++ b/packages/sdk/ts/utils/contracts.ts @@ -0,0 +1,24 @@ +import type { Provider } from "ethers"; + +/** + * Small utility function to check whether a contract exists at a given address + * @param provider - the provider to use to interact with the chain + * @param address - the address of the contract to check + * @returns a boolean indicating whether the contract exists + */ +export const contractExists = async (provider: Provider, address: string): Promise => { + const code = await provider.getCode(address); + return code.length > 2; +}; + +/** + * Small utility to retrieve the current block timestamp from the blockchain + * @param provider the provider to use to interact with the chain + * @returns the current block timestamp + */ +export const currentBlockTimestamp = async (provider: Provider): Promise => { + const blockNum = await provider.getBlockNumber(); + const block = await provider.getBlock(blockNum); + + return Number(block?.timestamp); +}; diff --git a/packages/sdk/ts/utils/index.ts b/packages/sdk/ts/utils/index.ts new file mode 100644 index 000000000..fb750cd78 --- /dev/null +++ b/packages/sdk/ts/utils/index.ts @@ -0,0 +1,12 @@ +export { contractExists, currentBlockTimestamp } from "./contracts"; +export type { + TallyData, + VerifyArgs, + IGetPollArgs, + IGetPollData, + IGenerateTallyCommitmentsArgs, + IGetPollParamsArgs, + ITallyCommitments, + IPollParams, +} from "./interfaces"; +export { verifyPerVOSpentVoiceCredits, verifyTallyResults } from "./verifiers"; diff --git a/packages/sdk/ts/utils/interfaces.ts b/packages/sdk/ts/utils/interfaces.ts new file mode 100644 index 000000000..be043ea59 --- /dev/null +++ b/packages/sdk/ts/utils/interfaces.ts @@ -0,0 +1,291 @@ +import type { Provider, Signer } from "ethers"; + +/** + * Interface for the tally file data. + */ +export interface TallyData { + /** + * The MACI address. + */ + maci: string; + + /** + * The ID of the poll. + */ + pollId: string; + + /** + * The name of the network for which these proofs + * are valid for + */ + network?: string; + + /** + * The chain ID for which these proofs are valid for + */ + chainId?: string; + + /** + * Whether the poll is using quadratic voting or not. + */ + isQuadratic: boolean; + + /** + * The address of the Tally contract. + */ + tallyAddress: string; + + /** + * The new tally commitment. + */ + newTallyCommitment: string; + + /** + * The results of the poll. + */ + results: { + /** + * The tally of the results. + */ + tally: string[]; + + /** + * The salt of the results. + */ + salt: string; + + /** + * The commitment of the results. + */ + commitment: string; + }; + + /** + * The total spent voice credits. + */ + totalSpentVoiceCredits: { + /** + * The spent voice credits. + */ + spent: string; + + /** + * The salt of the spent voice credits. + */ + salt: string; + + /** + * The commitment of the spent voice credits. + */ + commitment: string; + }; + + /** + * The per VO spent voice credits. + */ + perVOSpentVoiceCredits?: { + /** + * The tally of the per VO spent voice credits. + */ + tally: string[]; + + /** + * The salt of the per VO spent voice credits. + */ + salt: string; + + /** + * The commitment of the per VO spent voice credits. + */ + commitment: string; + }; +} + +export type BigNumberish = number | string | bigint; +/** + * Interface for the arguments to the get poll command + */ +export interface IGetPollArgs { + /** + * A signer object + */ + signer?: Signer; + + /** + * A provider fallback object + */ + provider?: Provider; + + /** + * The address of the MACI contract + */ + maciAddress: string; + + /** + * The poll id. If not specified, latest poll id will be used + */ + pollId?: BigNumberish; +} + +/** + * Interface for the return data to the get poll command + */ +export interface IGetPollData { + /** + * The poll id + */ + id: BigNumberish; + + /** + * The poll address + */ + address: string; + + /** + * The poll deployment time + */ + deployTime: BigNumberish; + + /** + * The poll duration + */ + duration: BigNumberish; + + /** + * The poll number of signups + */ + numSignups: BigNumberish; + + /** + * Whether the MACI contract's state root has been merged + */ + isMerged: boolean; + + /** + * Mode of the poll + */ + mode: BigNumberish; +} + +/** + * Interface for the arguments to the verifyProof command + */ +export interface VerifyArgs { + /** + * The id of the poll + */ + pollId: bigint; + + /** + * A signer object + */ + signer: Signer; + + /** + * The tally data + */ + tallyData: TallyData; + + /** + * The address of the MACI contract + */ + maciAddress: string; + + /** + * The tally commitments + */ + tallyCommitments: ITallyCommitments; + + /** + * The number of vote options + */ + numVoteOptions: number; + + /** + * The vote option tree depth + */ + voteOptionTreeDepth: number; +} + +/** + * Arguments for the get poll params command + */ +export interface IGetPollParamsArgs { + /** + * The poll id + */ + pollId: bigint; + /** + * The signer + */ + signer: Signer; + /** + * The MACI contract address + */ + maciContractAddress: string; +} + +/** + * Poll parameters + */ +export interface IPollParams { + /** + * The message batch size + */ + messageBatchSize: number; + /** + * The number of vote options + */ + numVoteOptions: number; + + /** + * Tally Batch Size + */ + tallyBatchSize: number; + + /** + * The vote option tree depth + */ + voteOptionTreeDepth: number; + + /** + * The depth of the tree holding the user ballots + */ + intStateTreeDepth: number; +} + +/** + * Arguments for the generateTallyCommitments function + */ +export interface IGenerateTallyCommitmentsArgs { + /** + * The tally data + */ + tallyData: TallyData; + /** + * The vote option tree depth + */ + voteOptionTreeDepth: number; +} + +/** + * Interface for the tally commitments + */ +export interface ITallyCommitments { + /** + * The new tally commitment + */ + newTallyCommitment: bigint; + /** + * The new spent voice credits commitment + */ + newSpentVoiceCreditsCommitment: bigint; + /** + * The new per vote option spent voice credits commitment + */ + newPerVOSpentVoiceCreditsCommitment?: bigint; + /** + * The commitment to the results tree root + */ + newResultsCommitment: bigint; +} diff --git a/packages/sdk/ts/utils/trees.ts b/packages/sdk/ts/utils/trees.ts new file mode 100644 index 000000000..87dd7186f --- /dev/null +++ b/packages/sdk/ts/utils/trees.ts @@ -0,0 +1,29 @@ +/** + * Utility to calculate the depth of a binary tree + * @param maxLeaves - the number of leaves in the tree + * @returns the depth of the tree + */ +export const calcBinaryTreeDepthFromMaxLeaves = (maxLeaves: number): number => { + let result = 0; + + while (2 ** result < maxLeaves) { + result += 1; + } + + return result; +}; + +/** + * Utility to calculate the depth of a quin tree + * @param maxLeaves the number of leaves in the tree + * @returns the depth of the tree + */ +export const calcQuinTreeDepthFromMaxLeaves = (maxLeaves: number): number => { + let result = 0; + + while (5 ** result < maxLeaves) { + result += 1; + } + + return result; +}; diff --git a/packages/sdk/ts/utils/verifiers.ts b/packages/sdk/ts/utils/verifiers.ts new file mode 100644 index 000000000..ef47542f7 --- /dev/null +++ b/packages/sdk/ts/utils/verifiers.ts @@ -0,0 +1,92 @@ +import { genTreeProof } from "maci-crypto"; + +import type { TallyData } from "./interfaces"; +import type { Tally } from "maci-contracts"; + +/** + * Loop through each per vote option spent voice credits and verify it on-chain + * + * @param tallyContract The tally contract + * @param tallyData The tally.json file data + * @param voteOptionTreeDepth The vote option tree depth + * @param newSpentVoiceCreditsCommitment The total spent voice credits commitment + * @param newResultsCommitment The tally result commitment + * @returns list of the indexes of the tally result that failed on-chain verification + */ +export const verifyPerVOSpentVoiceCredits = async ( + tallyContract: Tally, + tallyData: TallyData, + voteOptionTreeDepth: number, + newSpentVoiceCreditsCommitment: bigint, + newResultsCommitment: bigint, +): Promise => { + const failedIndices: number[] = []; + + for (let i = 0; i < tallyData.perVOSpentVoiceCredits!.tally.length; i += 1) { + const proof = genTreeProof( + i, + tallyData.perVOSpentVoiceCredits!.tally.map((x) => BigInt(x)), + voteOptionTreeDepth, + ); + + // eslint-disable-next-line no-await-in-loop + const isValid = await tallyContract.verifyPerVOSpentVoiceCredits( + i, + tallyData.perVOSpentVoiceCredits!.tally[i], + proof, + tallyData.perVOSpentVoiceCredits!.salt, + voteOptionTreeDepth, + newSpentVoiceCreditsCommitment, + newResultsCommitment, + ); + if (!isValid) { + failedIndices.push(i); + } + } + + return failedIndices; +}; + +/** + * Loop through each tally result and verify it on-chain + * @param tallyContract The tally contract + * @param tallyData The tally.json file data + * @param voteOptionTreeDepth The vote option tree depth + * @param newSpentVoiceCreditsCommitment The total spent voice credits commitment + * @param newPerVOSpentVoiceCreditsCommitment The per vote option voice credits commitment + * @returns list of the indexes of the tally result that failed on-chain verification + */ +export const verifyTallyResults = async ( + tallyContract: Tally, + tallyData: TallyData, + voteOptionTreeDepth: number, + newSpentVoiceCreditsCommitment: bigint, + newPerVOSpentVoiceCreditsCommitment?: bigint, +): Promise => { + const failedIndices: number[] = []; + + for (let i = 0; i < tallyData.results.tally.length; i += 1) { + const proof = genTreeProof( + i, + tallyData.results.tally.map((x) => BigInt(x)), + voteOptionTreeDepth, + ); + + // eslint-disable-next-line no-await-in-loop + const isValid = await tallyContract.verifyTallyResult( + i, + tallyData.results.tally[i], + proof, + tallyData.results.salt, + voteOptionTreeDepth, + newSpentVoiceCreditsCommitment, + newPerVOSpentVoiceCreditsCommitment ?? 0n, + ); + + if (!isValid) { + failedIndices.push(i); + } + } + + return failedIndices; +}; diff --git a/packages/sdk/ts/verify.ts b/packages/sdk/ts/verify.ts new file mode 100644 index 000000000..b15422281 --- /dev/null +++ b/packages/sdk/ts/verify.ts @@ -0,0 +1,111 @@ +import { Tally__factory as TallyFactory, MACI__factory as MACIFactory } from "maci-contracts/typechain-types"; + +import type { VerifyArgs } from "./utils/interfaces"; + +import { contractExists } from "./utils/contracts"; +import { verifyPerVOSpentVoiceCredits, verifyTallyResults } from "./utils/verifiers"; + +/** + * Verify the results of a poll on-chain + * @param VerifyArgs - The arguments for the verify command + */ +export const verify = async ({ + pollId, + tallyData, + signer, + tallyCommitments, + numVoteOptions, + voteOptionTreeDepth, +}: VerifyArgs): Promise => { + const useQv = tallyData.isQuadratic; + const maciContractAddress = tallyData.maci; + + if (!(await contractExists(signer.provider!, maciContractAddress))) { + throw new Error(`There is no MACI contract deployed at ${maciContractAddress}.`); + } + + // get the contract objects + const maciContract = MACIFactory.connect(maciContractAddress, signer); + const pollContracts = await maciContract.polls(pollId); + const tallyContract = TallyFactory.connect(pollContracts.tally, signer); + + // get the on-chain tally commitment\ + const onChainTallyCommitment = BigInt(await tallyContract.tallyCommitment()); + + // check the results commitment + const validResultsCommitment = tallyData.newTallyCommitment.match(/0x[a-fA-F0-9]+/); + + if (!validResultsCommitment) { + throw new Error("Invalid results commitment format"); + } + + if (tallyData.results.tally.length !== numVoteOptions) { + throw new Error("Wrong number of vote options."); + } + + // destructure the tally commitments + const { + newTallyCommitment, + newSpentVoiceCreditsCommitment, + newPerVOSpentVoiceCreditsCommitment, + newResultsCommitment, + } = tallyCommitments; + + // verify that the results commitment matches the output of genTreeCommitment() + if (onChainTallyCommitment !== newTallyCommitment) { + throw new Error("The on-chain tally commitment does not match."); + } + + // verify total spent voice credits on-chain + if ( + !(await tallyContract.verifySpentVoiceCredits( + tallyData.totalSpentVoiceCredits.spent, + tallyData.totalSpentVoiceCredits.salt, + newResultsCommitment, + newPerVOSpentVoiceCreditsCommitment ?? 0n, + )) + ) { + throw new Error("The on-chain verification of total spent voice credits failed."); + } + + // verify tally result on-chain for each vote option + const failedPerVOSpentCredits = await verifyTallyResults( + tallyContract, + tallyData, + voteOptionTreeDepth, + newSpentVoiceCreditsCommitment, + newPerVOSpentVoiceCreditsCommitment, + ); + + if (failedPerVOSpentCredits.length > 0) { + throw new Error( + `At least one spent voice credits entry in the tally results failed the on-chain verification. Please check your tally results at these indexes: ${failedPerVOSpentCredits.join( + ", ", + )}`, + ); + } + + if (useQv) { + if (tallyData.perVOSpentVoiceCredits?.tally.length !== numVoteOptions) { + throw new Error("Wrong number of vote options."); + } + // verify per vote option voice credits on-chain + const failedSpentCredits = await verifyPerVOSpentVoiceCredits( + tallyContract, + tallyData, + voteOptionTreeDepth, + newSpentVoiceCreditsCommitment, + newResultsCommitment, + ); + + if (failedSpentCredits.length > 0) { + throw new Error( + `At least one tally result failed the on-chain verification. Please check your Tally data at these indexes: ${failedSpentCredits.join( + ", ", + )}`, + ); + } + } + + return true; +}; diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 000000000..aba0b4ab7 --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["./ts"], + "files": [] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 000000000..85e579109 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["./ts", "./tests"], + "files": [] +} diff --git a/packages/sdk/typedoc.json b/packages/sdk/typedoc.json new file mode 100644 index 000000000..ce67283bf --- /dev/null +++ b/packages/sdk/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["ts/index.ts"], + "out": "../../apps/website/typedoc/sdk" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f279b93d..13b865b68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,6 +429,9 @@ importers: maci-domainobjs: specifier: ^2.5.0 version: link:../domainobjs + maci-sdk: + specifier: ^0.0.1 + version: link:../sdk prompt: specifier: ^1.3.0 version: 1.3.0 @@ -754,6 +757,70 @@ importers: specifier: ^5.7.3 version: 5.7.3 + packages/sdk: + dependencies: + '@commander-js/extra-typings': + specifier: ^12.1.0 + version: 12.1.0(commander@12.1.0) + '@nomicfoundation/hardhat-toolbox': + specifier: ^5.0.0 + version: 5.0.0(xxl2qhkxzbwobweo6ovit3fcve) + commander: + specifier: ^12.1.0 + version: 12.1.0 + dotenv: + specifier: ^16.4.5 + version: 16.4.7 + ethers: + specifier: ^6.13.4 + version: 6.13.5 + hardhat: + specifier: ^2.22.8 + version: 2.22.18(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3))(typescript@5.7.3) + maci-contracts: + specifier: ^2.5.0 + version: link:../contracts + maci-crypto: + specifier: ^2.5.0 + version: link:../crypto + devDependencies: + '@types/chai': + specifier: ^4.3.9 + version: 4.3.16 + '@types/chai-as-promised': + specifier: ^7.1.8 + version: 7.1.8 + '@types/mocha': + specifier: ^10.0.8 + version: 10.0.10 + '@types/node': + specifier: ^22.9.0 + version: 22.10.7 + '@types/snarkjs': + specifier: ^0.7.8 + version: 0.7.9 + chai: + specifier: ^4.3.10 + version: 4.4.1 + chai-as-promised: + specifier: ^7.1.2 + version: 7.1.2(chai@4.4.1) + mocha: + specifier: ^10.7.3 + version: 10.8.2 + nyc: + specifier: ^17.1.0 + version: 17.1.0 + snarkjs: + specifier: ^0.7.5 + version: 0.7.5 + ts-mocha: + specifier: ^10.0.0 + version: 10.0.0(mocha@10.8.2) + typescript: + specifier: ^5.6.3 + version: 5.7.3 + packages: '@achingbrain/nat-port-mapper@4.0.1': @@ -2095,10 +2162,6 @@ packages: resolution: {integrity: sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.24.7': - resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.26.0': resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} engines: {node: '>=6.9.0'} @@ -2152,6 +2215,11 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@commander-js/extra-typings@12.1.0': + resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} + peerDependencies: + commander: ~12.1.0 + '@commander-js/extra-typings@13.0.0': resolution: {integrity: sha512-4or44L3saI49QRBvdSzfCtzqONlFg0/qy0Cfl+LRynDaxlGs7r2KRTLCELaAoKh4oQguICxRwQfm77/Up+0wTw==} peerDependencies: @@ -5748,10 +5816,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -16396,10 +16460,6 @@ snapshots: core-js-pure: 3.37.1 regenerator-runtime: 0.14.1 - '@babel/runtime@7.24.7': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.26.0': dependencies: regenerator-runtime: 0.14.1 @@ -16498,6 +16558,10 @@ snapshots: '@colors/colors@1.5.0': {} + '@commander-js/extra-typings@12.1.0(commander@12.1.0)': + dependencies: + commander: 12.1.0 + '@commander-js/extra-typings@13.0.0(commander@13.0.0)': dependencies: commander: 13.0.0 @@ -16646,7 +16710,7 @@ snapshots: '@commitlint/types@19.5.0': dependencies: '@types/conventional-commits-parser': 5.0.0 - chalk: 5.3.0 + chalk: 5.4.1 '@cspotcode/source-map-support@0.8.1': dependencies: @@ -22437,8 +22501,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.3.0: {} - chalk@5.4.1: {} char-regex@1.0.2: {} @@ -26808,7 +26870,7 @@ snapshots: jest-diff@29.7.0: dependencies: - chalk: 4.1.0 + chalk: 4.1.2 diff-sequences: 29.6.3 jest-get-type: 29.6.3 pretty-format: 29.7.0 @@ -29027,7 +29089,7 @@ snapshots: '@yarnpkg/parsers': 3.0.0-rc.46 '@zkochan/js-yaml': 0.0.7 axios: 1.7.2 - chalk: 4.1.0 + chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.6.1 cliui: 8.0.1 @@ -30334,7 +30396,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@19.0.0))(webpack@5.97.1): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.0.0)' webpack: 5.97.1 @@ -30407,13 +30469,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@19.0.0))(react@19.0.0): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 react: 19.0.0 react-router: 5.3.4(react@19.0.0) react-router-dom@5.3.4(react@19.0.0): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -30424,7 +30486,7 @@ snapshots: react-router@5.3.4(react@19.0.0): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -32082,6 +32144,13 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.7) + ts-mocha@10.0.0(mocha@10.8.2): + dependencies: + mocha: 10.8.2 + ts-node: 7.0.1 + optionalDependencies: + tsconfig-paths: 3.15.0 + ts-mocha@10.0.0(mocha@11.0.1): dependencies: mocha: 11.0.1 @@ -32466,7 +32535,7 @@ snapshots: update-notifier@6.0.2: dependencies: boxen: 7.1.1 - chalk: 5.3.0 + chalk: 5.4.1 configstore: 6.0.0 has-yarn: 3.0.0 import-lazy: 4.0.0