diff --git a/.eslintignore b/.eslintignore index 240d8094..6c822ea8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,14 +1,10 @@ node_modules dist -.next coverage build typechain-types .eslintrc.js commitlint.config.js -subgraph/generated -public/mockServiceWorker.js zkeys -playwright-report -test-results -next.config.js +interface/playwright.config.ts +interface/playwright/fixtures.ts \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 9594edad..8bee0e46 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,7 +10,6 @@ module.exports = { extends: [ "airbnb", "prettier", - "next/core-web-vitals", "plugin:import/recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", @@ -20,26 +19,23 @@ module.exports = { "plugin:@typescript-eslint/stylistic", "plugin:@typescript-eslint/stylistic-type-checked", "plugin:import/typescript", - "plugin:react/recommended", - "plugin:playwright/playwright-test", ], - plugins: ["json", "prettier", "unused-imports", "import", "@typescript-eslint", "react-hooks"], + plugins: ["json", "prettier", "unused-imports", "import", "@typescript-eslint"], parser: "@typescript-eslint/parser", env: { - browser: true, node: true, - jest: true, + mocha: true, es2022: true, }, settings: { react: { - version: "18", + version: "999.999.999", }, "import/resolver": { typescript: {}, node: { - extensions: [".ts", ".js", ".tsx", ".jsx"], - moduleDirectory: ["node_modules", "src", "playwright"], + extensions: [".ts", ".js"], + moduleDirectory: ["node_modules", "ts", "src"], }, }, }, @@ -63,15 +59,7 @@ module.exports = { "import/no-extraneous-dependencies": [ "error", { - devDependencies: [ - "**/*.test.ts", - "./src/test-msw.ts", - "./src/test-setup.ts", - "./src/lib/eas/*.ts", - "./playwright/**/*.ts", - "./playwright.config.ts", - "./vitest.config.ts", - ], + devDependencies: ["**/*.test.ts", "**/__benchmarks__/**"], }, ], "no-debugger": isProduction ? "error" : "off", @@ -113,86 +101,9 @@ module.exports = { "error", { builtinGlobals: true, - allow: [ - "alert", - "location", - "event", - "history", - "name", - "status", - "Option", - "Image", - "Lock", - "test", - "expect", - "describe", - "beforeAll", - "afterAll", - ], + allow: ["location", "event", "history", "name", "status", "Option", "test", "expect"], }, ], "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], - - "react/jsx-filename-extension": [ - "error", - { - extensions: [".tsx", ".jsx", ".js"], - }, - ], - "react/no-unknown-property": ["error", { ignore: ["tw", "global", "jsx"] }], - "react/jsx-sort-props": [ - "error", - { - callbacksLast: true, - shorthandFirst: true, - ignoreCase: true, - reservedFirst: true, - }, - ], - "react/sort-prop-types": [ - "error", - { - callbacksLast: true, - }, - ], - "react/react-in-jsx-scope": "off", - "react/jsx-boolean-value": "error", - "react/jsx-handler-names": "error", - "react/prop-types": "error", - "react/jsx-no-bind": "error", - "react-hooks/rules-of-hooks": "error", - "react/no-array-index-key": "warn", - "jsx-a11y/no-static-element-interactions": "warn", - "jsx-a11y/click-events-have-key-events": "warn", - "jsx-a11y/anchor-is-valid": "warn", - "react/jsx-props-no-spreading": "off", - "react/forbid-prop-types": "off", - "react/state-in-constructor": "off", - "react/jsx-fragments": "off", - "react/static-property-placement": ["off"], - "react/jsx-newline": ["error", { prevent: false }], - "jsx-a11y/label-has-associated-control": "off", - "jsx-a11y/label-has-for": "off", - "react/require-default-props": [ - "error", - { - functions: "defaultArguments", - }, - ], - "react/no-unused-prop-types": "error", - "react/function-component-definition": ["error", { namedComponents: ["arrow-function"] }], - - "playwright/prefer-lowercase-title": "error", - "playwright/prefer-to-be": "error", - "playwright/prefer-to-have-length": "error", - "playwright/prefer-strict-equal": "error", - "playwright/max-nested-describe": ["error", { max: 1 }], - "playwright/no-restricted-matchers": [ - "error", - { - toBeFalsy: "Use `toBe(false)` instead.", - not: null, - }, - ], }, }; diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh new file mode 100644 index 00000000..733197ed --- /dev/null +++ b/.github/scripts/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -ex + +cp packages/coordinator/.env.example packages/coordinator/.env + +sed -i "s|^\(COORDINATOR_RPC_URL=\).*|\1$1|" packages/coordinator/.env +sed -i "s|^\(COORDINATOR_ADDRESSES=\).*|\1$2|" packages/coordinator/.env +sed -i "s|^\(COORDINATOR_ALLOWED_ORIGINS=\).*|\1$3|" packages/coordinator/.env + +aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin 490752553772.dkr.ecr.eu-central-1.amazonaws.com + +docker build -t maci-coordinator -f packages/coordinator/apps/Dockerfile . +docker tag maci-coordinator:latest 490752553772.dkr.ecr.eu-central-1.amazonaws.com/maci-coordinator:latest +docker push 490752553772.dkr.ecr.eu-central-1.amazonaws.com/maci-coordinator:latest + +exit 0 diff --git a/.github/scripts/deploy.sh b/.github/scripts/deploy.sh new file mode 100644 index 00000000..3d987b14 --- /dev/null +++ b/.github/scripts/deploy.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -ex + +tasks="maci-coordinator" +for task in $tasks; do + maci_coordinator_revision=$(aws ecs describe-task-definition --task-definition $task --query "taskDefinition.revision") + aws ecs update-service --cluster maci-coordinator --service $task --force-new-deployment --task-definition $task:$maci_coordinator_revision +done + +for loop in {1..3}; do + [ "$loop" -eq 3 ] && exit 1 + aws ecs wait services-stable --cluster maci-coordinator --services $tasks && break || continue +done diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8e3634bf..6ff76288 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - command: ["prettier", "types", "lint", "test"] + command: ["prettier", "types", "lint"] runs-on: ubuntu-22.04 @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 9 - name: Use Node.js 20 uses: actions/setup-node@v4 diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index fe2541d3..955b0447 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 9 - name: Use Node.js 20 uses: actions/setup-node@v4 diff --git a/.github/workflows/coordinator-build.yml b/.github/workflows/coordinator-build.yml new file mode 100644 index 00000000..7e881668 --- /dev/null +++ b/.github/workflows/coordinator-build.yml @@ -0,0 +1,114 @@ +name: Coordinator + +on: + push: + branches: [main] + pull_request: + +env: + COORDINATOR_RPC_URL: "http://localhost:8545" + COORDINATOR_PUBLIC_KEY_PATH: "./pub.key" + COORDINATOR_PRIVATE_KEY_PATH: "./priv.key" + COORDINATOR_TALLY_ZKEY_NAME: "TallyVotes_10-1-2_test" + COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME: "ProcessMessages_10-2-1-2_test" + COORDINATOR_ZKEY_PATH: "./zkeys/" + COORDINATOR_RAPIDSNARK_EXE: "~/rapidsnark/build/prover" + SUBGRAPH_FOLDER: "./node_modules/maci-subgraph" + SUBGRAPH_NAME: ${{ vars.SUBGRAPH_NAME }} + SUBGRAPH_PROVIDER_URL: ${{ vars.SUBGRAPH_PROVIDER_URL }} + SUBGRAPH_DEPLOY_KEY: ${{ secrets.SUBGRAPH_DEPLOY_KEY }} + # interface related variables as they are needed for building the monorepo + NEXT_PUBLIC_CHAIN_NAME: ${{ vars.NEXT_PUBLIC_CHAIN_NAME }} + NEXT_PUBLIC_ADMIN_ADDRESS: ${{ vars.NEXT_PUBLIC_ADMIN_ADDRESS }} + NEXT_PUBLIC_APPROVAL_SCHEMA: ${{ vars.NEXT_PUBLIC_APPROVAL_SCHEMA }} + NEXT_PUBLIC_METADATA_SCHEMA: ${{ vars.NEXT_PUBLIC_METADATA_SCHEMA }} + NEXT_PUBLIC_ROUND_ID: ${{ vars.NEXT_PUBLIC_ROUND_ID }} + NEXT_PUBLIC_SKIP_APPROVED_VOTER_CHECK: false + NEXT_PUBLIC_MACI_ADDRESS: ${{ vars.NEXT_PUBLIC_MACI_ADDRESS }} + NEXT_PUBLIC_TALLY_URL: ${{ vars.NEXT_PUBLIC_TALLY_URL }} + NEXT_PUBLIC_WALLETCONNECT_ID: ${{ secrets.NEXT_PUBLIC_WALLETCONNECT_ID }} + NEXT_PUBLIC_FEEDBACK_URL: ${{ vars.NEXT_PUBLIC_FEEDBACK_URL }} + NEXT_PUBLIC_MACI_START_BLOCK: ${{ vars.NEXT_PUBLIC_MACI_START_BLOCK }} + NEXT_PUBLIC_MACI_SUBGRAPH_URL: ${{ vars.NEXT_PUBLIC_MACI_SUBGRAPH_URL }} + NEXT_PUBLIC_TOKEN_NAME: ${{ vars.NEXT_PUBLIC_TOKEN_NAME }} + NEXT_PUBLIC_MAX_VOTES_TOTAL: ${{ vars.NEXT_PUBLIC_MAX_VOTES_TOTAL }} + NEXT_PUBLIC_MAX_VOTES_PROJECT: ${{ vars.NEXT_PUBLIC_MAX_VOTES_PROJECT }} + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + NEXT_PUBLIC_ALCHEMY_ID: ${{ secrets.NEXT_PUBLIC_ALCHEMY_ID }} + NEXT_PUBLIC_START_DATE: ${{ vars.NEXT_PUBLIC_START_DATE }} + NEXT_PUBLIC_REGISTRATION_END_DATE: ${{ vars.NEXT_PUBLIC_REGISTRATION_END_DATE }} + NEXT_PUBLIC_REVIEW_END_DATE: ${{ vars.NEXT_PUBLIC_REVIEW_END_DATE }} + NEXT_PUBLIC_VOTING_END_DATE: ${{ vars.NEXT_PUBLIC_VOTING_END_DATE }} + NEXT_PUBLIC_RESULTS_DATE: ${{ vars.NEXT_PUBLIC_RESULTS_DATE }} + NEXT_PUBLIC_POLL_MODE: ${{ vars.NEXT_PUBLIC_POLL_MODE }} + TEST_MNEMONIC: ${{ secrets.TEST_MNEMONIC }} + WALLET_PRIVATE_KEY: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --yes \ + build-essential \ + libgmp-dev \ + libsodium-dev \ + nasm \ + nlohmann-json3-dev + + - name: Install + run: | + pnpm install --frozen-lockfile --prefer-offline + + - name: Build + run: | + pnpm run build + + - name: Run hardhat + run: | + pnpm run hardhat & + sleep 5 + working-directory: packages/coordinator + + - name: Download rapidsnark (1c137) + run: | + mkdir -p ~/rapidsnark/build + wget -qO ~/rapidsnark/build/prover https://maci-devops-zkeys.s3.ap-northeast-2.amazonaws.com/rapidsnark-linux-amd64-1c137 + chmod +x ~/rapidsnark/build/prover + + - name: Download circom Binary v2.1.6 + run: | + wget -qO ${{ github.workspace }}/circom https://github.com/iden3/circom/releases/download/v2.1.6/circom-linux-amd64 + chmod +x ${{ github.workspace }}/circom + sudo mv ${{ github.workspace }}/circom /bin/circom + + - name: Download zkeys + run: | + pnpm download-zkeys:test + + - name: Generate keypair + run: | + pnpm generate-keypair + working-directory: packages/coordinator + + - name: Test + run: pnpm run test + working-directory: packages/coordinator diff --git a/.github/workflows/coordinator-deploy.yml b/.github/workflows/coordinator-deploy.yml new file mode 100644 index 00000000..58665e92 --- /dev/null +++ b/.github/workflows/coordinator-deploy.yml @@ -0,0 +1,37 @@ +name: CoordinatorDeploy +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-22.04 + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::490752553772:role/maci-coordinator-ecs-deploy-slc + role-duration-seconds: 2700 + aws-region: eu-central-1 + + - name: Build and Push images to ECR + run: | + .github/scripts/build.sh ${{ secrets.COORDINATOR_RPC_URL }} ${{ secrets.COORDINATOR_ADDRESSES }} ${{ secrets.COORDINATOR_ALLOWED_ORIGINS }} + + - name: Create Deployment + run: | + .github/scripts/deploy.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4c3fe025..f83c5b26 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 8 + version: 9 - name: Use Node.js 20 uses: actions/setup-node@v4 @@ -96,14 +96,17 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: |- pnpm run install:chromium + working-directory: packages/interface - name: Build run: pnpm run build + working-directory: packages/interface - name: Run Playwright tests uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a with: run: pnpm run test:e2e + working-directory: packages/interface - uses: actions/upload-artifact@v4 if: always() diff --git a/.gitignore b/.gitignore index d36a51f6..6f4ab1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,22 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules -/.pnp +node_modules +.pnp .pnp.js # testing -/coverage -/playwright-report -/test-results +coverage +playwright-report +test-results # next.js -/.next/ -/out/ +.next/ +out/ next-env.d.ts # production -/build +build # misc .DS_Store diff --git a/.husky/pre-commit b/.husky/pre-commit index 035932cf..ae5682d2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm exec lint-staged && pnpm run types +pnpm lint && pnpm run types diff --git a/README.md b/README.md index bd5f0d47..012620f2 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,13 @@ -# MACI-RPGF +# MACI Platform -
-View demo -| -Discord (#🗳️-maci channel) -| -Video Tutorial -
+MACI Platform is a complete solution for running voting and funding rounds using [MACI](https://maci.pse.dev). -[](https://easy-retro-pgf.vercel.app) +It is comprised of two components: -## Documentation - -MACI-RPGF uses EAS as backbone to run Retroactive Public Goods Funding to reward contributors ([As used by the Optimism Collective](https://community.optimism.io/docs/governance/citizens-house/#how-retro-funding-works)) while adding a privacy layer to reduce bribery and collusion using MACI. - -## Video Tutorial - -A complete installation tutorial can be seen here: - -[![Watch the Video](https://img.youtube.com/vi/86VBbO1E4Vk/0.jpg)](https://www.youtube.com/watch?v=86VBbO1E4Vk) +- Coordinator Service - the complete automation of MACI operations +- Interface - a web app for managing and voting on MACI polls -### MACI-RPGF docs +### MACI-Platform docs - [Setup & Deployment](./docs/01_setup.md) - [Adding Projects & Approving](./docs/02_adding_projects.md) @@ -33,55 +20,39 @@ A complete installation tutorial can be seen here: - [Documentation](https://maci.pse.dev/docs/introduction) -## Supported Networks - -All networks EAS is deployed to are supported. If a network is not supported, you can follow the EAS documentation to deploy the contracts to the network. - -- https://docs.attest.sh/docs/quick--start/contracts - -#### Mainnets - -- Ethereum -- Optimism -- Base -- Arbitrum One & Nova -- Polygon -- Scroll -- Celo -- Linea - -#### Testnets - -- Sepolia -- Optimism Sepolia -- Base Sepolia -- Polygon Mumbai -- Scroll Sepolia - ## Development To run locally follow these instructions: ```sh -git clone https://github.com/privacy-scaling-explorations/maci-rpgf +git clone https://github.com/privacy-scaling-explorations/maci-platform + +pnpm install && pnpm build -cp .env.example .env # and update .env variables +cp packages/interface/.env.example packages/interface/.env # and update .env variables ``` At the very minimum you need to configure the subgraph url, admin address, maci address and the voting periods. For more details head to [Setup & Deployment](./docs/01_setup.md). Once you have set everything run: ```sh -pnpm install - -pnpm run dev +pnpm run dev:interface open localhost:3000 ``` -### Technical details +## Documentation + +MACI-Platform uses EAS as backbone to run Retroactive Public Goods Funding to reward contributors ([As used by the Optimism Collective](https://community.optimism.io/docs/governance/citizens-house/#how-retro-funding-works)) while adding a privacy layer to reduce bribery and collusion using MACI. + +## Video Tutorial + +A complete installation tutorial can be seen here: + +[![Watch the Video](https://img.youtube.com/vi/86VBbO1E4Vk/0.jpg)](https://www.youtube.com/watch?v=86VBbO1E4Vk) + +## Credits + +The interface started as a fork of [easy-rpgf](https://github.com/gitcoinco/easy-retro-pgf), but now has gone a completely different direction and thus we decided to detach the fork to clarify the new direction of the project, which is not focusing anymore on RPGF only, but other types of voting and funding. -- **EAS** - Projects, profiles, etc are all stored on-chain in Ethereum Attestation Service -- **Batched requests with tRPC** - Multiple requests are batched into one (for example when the frontend requests the metadata for 24 projects they are batched into 1 request) -- **Server-side caching of requests to EAS and IPFS** - Immediately returns the data without calling EAS and locally serving ipfs cids. -- **MACI** - Minimal Anti-Collusion Infrastructure (MACI) is an open-source public good that serves as infrastructure for private on-chain voting, handles the rounds and private voting of the badgeholders. +We are very thankful to the developers and all contributors of the [easy-rpgf](https://github.com/gitcoinco/easy-retro-pgf) project, and we hope to continue collaborating and wish to see their project succeed and help more communities/projects get funded. diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000..beafc3c4 --- /dev/null +++ b/lerna.json @@ -0,0 +1,47 @@ +{ + "packages": ["packages/*"], + "version": "0.1.0", + "npmClient": "pnpm", + "changelogPreset": { + "name": "conventionalcommits", + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "refactor", + "section": "Code Refactoring" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "chore", + "section": "Miscellaneous" + }, + { + "type": "docs", + "section": "Miscellaneous" + }, + { + "type": "style", + "section": "Miscellaneous" + }, + { + "type": "test", + "section": "Miscellaneous" + } + ], + "issuePrefixes": ["#"], + "issueUrlFormat": "{{host}}/{{owner}}/{{repository}}/issues/{{id}}", + "commitUrlFormat": "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}", + "compareUrlFormat": "{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}", + "userUrlFormat": "{{host}}/{{user}}" + } +} diff --git a/package.json b/package.json index 8ae1fc6f..b3154bed 100644 --- a/package.json +++ b/package.json @@ -1,159 +1,64 @@ { - "name": "maci-rpgf", + "name": "maci-platform", "version": "0.1.0", - "private": true, + "description": "Minimal Anti-Collusion Infrastructure Platform", + "repository": "https://github.com/privacy-scaling-explorations/maci-rpgf", + "license": "MIT", "scripts": { - "build": "next build", - "dev": "next dev", - "lint": "next lint", - "lint:fix": "next lint --fix", - "start": "next start", - "test": "vitest run", + "build": "lerna run build", + "clean": "lerna exec -- rm -rf node_modules build && rm -rf node_modules", + "commit": "git cz", + "download-zkeys:test": "lerna run download-zkeys:test --scope=maci-coordinator", + "download-zkeys:prod": "lerna run download-zkeys:prod --scope=maci-coordinator", "prettier": "prettier -c .", "prettier:fix": "prettier -w .", - "types": "tsc -p tsconfig.json --noEmit", - "eas:registerSchemas": "npx tsx src/lib/eas/registerSchemas", - "install:chromium": "playwright install chromium", - "test:e2e": "playwright test --project=chromium", - "prepare": "is-ci || husky" - }, - "dependencies": { - "@ethereum-attestation-service/eas-sdk": "^1.5.0", - "@hookform/resolvers": "^3.3.4", - "@nivo/boxplot": "^0.84.0", - "@nivo/line": "^0.84.0", - "@pinata/sdk": "^2.1.0", - "@radix-ui/react-accordion": "^1.1.2", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@rainbow-me/rainbowkit": "^2.0.1", - "@rainbow-me/rainbowkit-siwe-next-auth": "^0.4.0", - "@semaphore-protocol/core": "4.0.0-beta.16", - "@semaphore-protocol/data": "4.0.0-beta.16", - "@t3-oss/env-nextjs": "^0.8.0", - "@tailwindcss/forms": "^0.5.7", - "@tanstack/react-query": "^5.24.1", - "@testing-library/react": "^14.1.2", - "@trpc/client": "11.0.0-next-beta.294", - "@trpc/next": "11.0.0-next-beta.294", - "@trpc/react-query": "11.0.0-next-beta.294", - "@trpc/server": "11.0.0-next-beta.294", - "@vercel/blob": "^0.19.0", - "clsx": "^2.1.0", - "cmdk": "^0.2.0", - "date-fns": "^3.6.0", - "ethers": "^6.13.1", - "formidable": "^3.5.1", - "graphql-request": "^6.1.0", - "lowdb": "^1.0.0", - "lucide-react": "^0.316.0", - "maci-cli": "0.0.0-ci.4d2d340", - "maci-domainobjs": "0.0.0-ci.4d2d340", - "next": "^14.1.0", - "next-auth": "^4.24.5", - "next-themes": "^0.2.1", - "node-fetch-cache": "^4.1.0", - "nuqs": "^1.17.1", - "p-limit": "^5.0.0", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-hook-form": "^7.49.3", - "react-icons": "^5.0.1", - "react-markdown": "^9.0.1", - "react-number-format": "^5.3.1", - "react-use": "^17.5.0", - "siwe": "^2.1.4", - "sonner": "^1.4.0", - "superjson": "^2.2.1", - "tailwindcss": "^3.4.1", - "tailwind-merge": "^2.2.1", - "tailwind-variants": "^0.1.20", - "viem": "^2.7.15", - "wagmi": "^2.9.8", - "zod": "3.22.4" + "lint": "pnpm run lint:coordinator && pnpm run lint:interface", + "lint:coordinator": "eslint './packages/coordinator/**/*.ts'", + "lint:coordinator:fix": "eslint './packages/coordinator/**/*.ts' --fix", + "lint:interface": "lerna run lint --scope=maci-platform-interface", + "lint:interface:fix": "lerna run lint:fix --scope=maci-platform-interface", + "lint:fix": "pnpm run lint:coordinator:fix && pnpm run lint:interface:fix", + "types": "lerna run types", + "docs": "lerna run docs", + "prepare": "is-ci || husky", + "start": "lerna run start --scope=maci-platform-interface", + "dev:interface": "lerna run dev --scope=maci-platform-interface", + "test:coordinator": "lerna run test --scope=maci-coordinator", + "test:interface:e2e": "lerna run test:e2e --scope=maci-platform-interface", + "hardhat": "lerna run hardhat --scope=maci-platform-interface" }, + "author": "PSE", "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", - "@next/eslint-plugin-next": "^14.2.3", - "@playwright/test": "^1.45.0", - "@synthetixio/synpress": "^3.7.3", - "@tailwindcss/typography": "^0.5.10", - "@testing-library/jest-dom": "^6.4.5", - "@types/eslint": "^8.56.2", - "@types/formidable": "^3.4.5", - "@types/lowdb": "^1.0.15", - "@types/node": "^20.11.10", - "@types/node-fetch-cache": "^3.0.5", - "@types/papaparse": "^5.3.14", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@typescript-eslint/eslint-plugin": "^6.19.1", - "@typescript-eslint/parser": "^6.19.1", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.17", - "dotenv": "^16.4.1", + "@typescript-eslint/eslint-plugin": "^7.17.0", + "@typescript-eslint/parser": "^7.17.0", + "conventional-changelog-conventionalcommits": "^8.0.0", + "cz-conventional-changelog": "^3.3.0", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", - "eslint-config-next": "14.2.4", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^9.0.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-json": "^4.0.0", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-playwright": "^1.6.2", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-react": "^7.34.3", + "eslint-plugin-jsx-a11y": "^6.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-unused-imports": "^4.0.0", - "happy-dom": "^13.3.2", - "hardhat": "^2.22.3", - "husky": "^9.0.11", + "eslint-plugin-unused-imports": "^3.2.0", + "husky": "^9.1.1", "is-ci": "^3.0.1", - "jsdom": "^24.0.0", - "ky": "^1.2.0", - "lint-staged": "^15.2.7", - "msw": "^2.1.5", - "msw-trpc": "2.0.0-beta.0", - "mws": "^2.0.11", - "next-router-mock": "^0.9.11", - "postcss": "^8.4.33", - "prettier": "^3.3.2", - "prettier-plugin-tailwindcss": "^0.5.11", - "typescript": "^5.3.3", - "vite": "^5.3.1", - "vite-tsconfig-paths": "^4.3.2", - "vitest": "^1.6.0", - "vitest-mock-extended": "^1.3.1" - }, - "ct3aMetadata": { - "initVersion": "7.24.1" - }, - "msw": { - "workerDirectory": "public" + "lerna": "^8.1.7", + "prettier": "^3.3.3", + "typescript": "^5.5.4" }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": ".", - "roots": [ - "/src" - ], - "testRegex": ".*\\.test\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s", - "!/ts/main.ts" - ], - "coverageDirectory": "/coverage", - "testEnvironment": "node" + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } }, - "browser": { - "child_process": false + "engines": { + "node": "20", + "pnpm": "9" } } diff --git a/packages/coordinator/.env.example b/packages/coordinator/.env.example new file mode 100644 index 00000000..067c41dc --- /dev/null +++ b/packages/coordinator/.env.example @@ -0,0 +1,48 @@ +# Rate limit configuation +TTL=60000 +LIMIT=10 + +# Make sure your private and public RSA keys are generated (see package.json scripts) +# Public key must be copied to frontend app to encrypt user sensitive data +COORDINATOR_PUBLIC_KEY_PATH="./pub.key" +COORDINATOR_PRIVATE_KEY_PATH="./priv.key" + +# Make sure you have zkeys folder +# https://maci.pse.dev/docs/trusted-setup +COORDINATOR_TALLY_ZKEY_NAME=TallyVotes_10-1-2_test + +# Make sure you have zkeys folder +# https://maci.pse.dev/docs/trusted-setup +COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME=ProcessMessages_10-2-1-2_test + +# Rapidsnark executable path +COORDINATOR_RAPIDSNARK_EXE= + +# Location of zkey, wasm files +COORDINATOR_ZKEY_PATH="./zkeys" + +# Coordinator RPC url +COORDINATOR_RPC_URL=http://localhost:8545 + +# Coordinator Ethereum addresses (see ts/auth/AccountSignatureGuard.service.ts) +COORDINATOR_ADDRESSES= + +# Allowed origin host, use comma to separate each of them +COORDINATOR_ALLOWED_ORIGINS= + +# Specify port for coordinator service (optional) +COORDINATOR_PORT= + +# Subgraph name +SUBGRAPH_NAME="maci-subgraph" + +# Subgraph provider url +SUBGRAPH_PROVIDER_URL=https://api.studio.thegraph.com/deploy/ + +# Subgraph deploy key +SUBGRAPH_DEPLOY_KEY= + +# Subgraph project folder +SUBGRAPH_FOLDER=./node_modules/maci-subgraph + + diff --git a/packages/coordinator/.eslintrc.js b/packages/coordinator/.eslintrc.js new file mode 100644 index 00000000..9d837bea --- /dev/null +++ b/packages/coordinator/.eslintrc.js @@ -0,0 +1,32 @@ +const path = require("path"); + +module.exports = { + root: true, + env: { + node: true, + jest: 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, + }, + overrides: [ + { + files: ["./ts/**/*.module.ts"], + rules: { + "@typescript-eslint/no-extraneous-class": "off", + }, + }, + ], +}; diff --git a/packages/coordinator/.gitignore b/packages/coordinator/.gitignore new file mode 100644 index 00000000..1751988f --- /dev/null +++ b/packages/coordinator/.gitignore @@ -0,0 +1,6 @@ +*.key +*.key +zkeys/ +proofs/ +tally.json + diff --git a/packages/coordinator/README.md b/packages/coordinator/README.md new file mode 100644 index 00000000..3ff05698 --- /dev/null +++ b/packages/coordinator/README.md @@ -0,0 +1,43 @@ +# Coordinator service + +## Instructions + +1. Add `.env` file (see `.env.example`). +2. Generate RSA key pair with `pnpm run generate-keypair`. +3. Download zkey files using `pnpm run download-zkeys:{type}` (only test type is available for now). +4. Make sure you copied RSA public key to your application. This will be needed for encrypting `Authorization` header and coordinator private key for proof generation. Also it can be accessed through API method `GET v1/proof/publicKey`. +5. Run `pnpm run start` to run the service. +6. All API calls must be called with `Authorization` header, where the value is encrypted with RSA public key you generated before. Header value contains message signature and message digest created by `COORDINATOR_ADDRESSES`. The format is `publicEncrypt({signature}:{digest})`. + Make sure you set `COORDINATOR_ADDRESSES` env variable and sign any message with the addresses from your application (see [AccountSignatureGuard](./ts/auth/AccountSignatureGuard.service.ts)). +7. Proofs can be generated with `POST v1/proof/generate` API method or with Websockets (see [dto spec](./ts/proof/dto.ts), [controller](./ts/app.controller.ts) and [wsgateway](./ts/events/events.gateway.ts)). +8. [Swagger documentation for API methods](https://maci-coordinator.pse.dev/api) + +## Subgraph deployment + +It is possible to deploy subgraph using coordinator service. + +First, you need to setup subgraph and create a project. [Subgraph dashboard](https://thegraph.com/studio/). + +Then, set env variables: + +``` +# Subgraph name +SUBGRAPH_NAME="maci-subgraph" + +# Subgraph provider url +SUBGRAPH_PROVIDER_URL=https://api.studio.thegraph.com/deploy/ + +# Subgraph deploy key +SUBGRAPH_DEPLOY_KEY=******* + +# Subgraph project folder +SUBGRAPH_FOLDER=../subgraph +``` + +After deployment, subgraph url will be available in studio dashboard and you can use this type of url to get latest deployed version in your application: + +``` +https://api.studio.thegraph.com/.../{SUBGRAPH_NAME}/version/latest +``` + +API method Details are available [here](https://maci-coordinator.pse.dev/api) diff --git a/packages/coordinator/apps/Dockerfile b/packages/coordinator/apps/Dockerfile new file mode 100644 index 00000000..7a165362 --- /dev/null +++ b/packages/coordinator/apps/Dockerfile @@ -0,0 +1,33 @@ +# Copy source code and build the project +FROM node:20-alpine as builder + +WORKDIR /builder + +COPY . . + +RUN npm i -g pnpm@9 +RUN pnpm install --frozen-lockfile --prefer-offline +RUN pnpm run build + +# Create image by copying build artifacts +FROM node:20-alpine as runner +RUN npm i -g pnpm@9 + +RUN mkdir -p ~/rapidsnark/build; \ + wget -qO ~/rapidsnark/build/prover https://maci-devops-zkeys.s3.ap-northeast-2.amazonaws.com/rapidsnark-linux-amd64-1c137; \ + chmod +x ~/rapidsnark/build/prover +RUN wget -qO ~/circom https://github.com/iden3/circom/releases/download/v2.1.6/circom-linux-amd64; \ + chmod +x ~/circom; \ + mv ~/circom /bin + +USER node +ARG PORT=3000 + +WORKDIR ./maci +COPY --chown=node:node --from=builder /builder/ ./ +RUN pnpm run download-zkeys-coordinator:test +WORKDIR /maci/coordinator +RUN pnpm run generate-keypair + +EXPOSE ${PORT} +CMD ["node", "build/ts/main.js"] diff --git a/packages/coordinator/hardhat.config.js b/packages/coordinator/hardhat.config.js new file mode 100644 index 00000000..c26fed08 --- /dev/null +++ b/packages/coordinator/hardhat.config.js @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +require("@nomicfoundation/hardhat-toolbox"); +const dotenv = require("dotenv"); + +const path = require("path"); + +dotenv.config(); + +const parentDir = __dirname.includes("build") ? ".." : ""; +const TEST_MNEMONIC = "test test test test test test test test test test test junk"; + +module.exports = { + defaultNetwork: "localhost", + networks: { + localhost: { + url: process.env.COORDINATOR_RPC_URL, + accounts: { + mnemonic: TEST_MNEMONIC, + path: "m/44'/60'/0'/0", + initialIndex: 0, + count: 20, + }, + loggingEnabled: false, + }, + hardhat: { + loggingEnabled: false, + }, + }, + paths: { + sources: path.resolve(__dirname, parentDir, "./node_modules/maci-contracts/contracts"), + artifacts: path.resolve(__dirname, parentDir, "./node_modules/maci-contracts/build/artifacts"), + }, +}; diff --git a/packages/coordinator/nest-cli.json b/packages/coordinator/nest-cli.json new file mode 100644 index 00000000..bfeb2a1c --- /dev/null +++ b/packages/coordinator/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "ts", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/coordinator/package.json b/packages/coordinator/package.json new file mode 100644 index 00000000..d595fc5b --- /dev/null +++ b/packages/coordinator/package.json @@ -0,0 +1,95 @@ +{ + "name": "maci-coordinator", + "version": "0.1.0", + "private": true, + "description": "Coordinator service for MACI", + "main": "build/ts/main.js", + "files": [ + "build", + "CHANGELOG.md", + "README.md" + ], + "scripts": { + "hardhat": "hardhat node", + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "test": "jest --forceExit", + "test:coverage": "jest --coverage --forceExit", + "types": "tsc -p tsconfig.json --noEmit", + "generate-keypair": "ts-node ./scripts/generateKeypair.ts", + "download-zkeys:test": "ts-node ./scripts/downloadZKeys.ts test ./zkeys", + "download-zkeys:prod": "ts-node ./scripts/downloadZKeys.ts prod ./zkeys" + }, + "dependencies": { + "@graphprotocol/graph-cli": "^0.79.0", + "@nestjs/common": "^10.3.10", + "@nestjs/core": "^10.3.10", + "@nestjs/platform-express": "^10.3.10", + "@nestjs/platform-socket.io": "^10.3.10", + "@nestjs/swagger": "^7.4.0", + "@nestjs/throttler": "^6.0.0", + "@nestjs/websockets": "^10.3.10", + "@nomicfoundation/hardhat-ethers": "^3.0.6", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.5", + "ethers": "^6.13.1", + "hardhat": "^2.22.6", + "helmet": "^7.1.0", + "maci-circuits": "0.0.0-ci.03f7713", + "maci-cli": "0.0.0-ci.03f7713", + "maci-contracts": "0.0.0-ci.03f7713", + "maci-domainobjs": "0.0.0-ci.03f7713", + "maci-subgraph": "0.0.0-ci.03f7713", + "mustache": "^4.2.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "socket.io": "^4.7.5", + "tar": "^7.4.1", + "ts-node": "^10.9.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.2", + "@nestjs/schematics": "^10.1.2", + "@nestjs/testing": "^10.3.10", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.14.11", + "@types/supertest": "^6.0.0", + "fast-check": "^3.20.0", + "jest": "^29.5.0", + "socket.io-client": "^4.7.5", + "supertest": "^7.0.0", + "ts-jest": "^29.2.3", + "typescript": "^5.5.4" + }, + "jest": { + "testTimeout": 900000, + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "roots": [ + "/ts", + "/tests" + ], + "testRegex": ".*\\.test\\.ts$", + "transform": { + "^.+\\.js$": "/ts/jest/transform.js", + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s", + "!/ts/main.ts", + "!/ts/jest/*.js", + "!/hardhat.config.js" + ], + "coverageDirectory": "/coverage", + "testEnvironment": "node" + } +} diff --git a/packages/coordinator/scripts/downloadZKeys.ts b/packages/coordinator/scripts/downloadZKeys.ts new file mode 100644 index 00000000..da4f700f --- /dev/null +++ b/packages/coordinator/scripts/downloadZKeys.ts @@ -0,0 +1,42 @@ +import * as tar from "tar"; + +import fs from "fs"; +import https from "https"; +import path from "path"; + +const ZKEY_PATH = path.resolve(process.argv.slice(3)[0]); +const ZKEYS_URLS = { + test: "https://maci-develop-fra.s3.eu-central-1.amazonaws.com/v1.3.0/maci_artifacts_10-2-1-2_test.tar.gz", + prod: "https://maci-develop-fra.s3.eu-central-1.amazonaws.com/v1.2.0/maci_artifacts_6-9-2-3_prod.tar.gz", +}; +const ARCHIVE_NAME = path.resolve(ZKEY_PATH, "maci_keys.tar.gz"); + +export async function downloadZkeys(): Promise { + const [type] = process.argv.slice(2).map((arg) => arg.trim() as keyof typeof ZKEYS_URLS); + + if (!Object.keys(ZKEYS_URLS).includes(type)) { + throw new Error(`${type} doesn't exist`); + } + + if (!fs.existsSync(ZKEY_PATH)) { + await fs.promises.mkdir(ZKEY_PATH); + } + + const file = fs.createWriteStream(ARCHIVE_NAME); + + https + .get(ZKEYS_URLS[type], (response) => { + response.pipe(file); + + file + .on("finish", () => { + file.close(); + + tar.x({ f: ARCHIVE_NAME, C: ZKEY_PATH, strip: 1 }).then(() => fs.promises.rm(ARCHIVE_NAME)); + }) + .on("error", () => fs.promises.unlink(ARCHIVE_NAME)); + }) + .on("error", () => fs.promises.unlink(ARCHIVE_NAME)); +} + +downloadZkeys(); diff --git a/packages/coordinator/scripts/generateKeypair.ts b/packages/coordinator/scripts/generateKeypair.ts new file mode 100644 index 00000000..85a38dad --- /dev/null +++ b/packages/coordinator/scripts/generateKeypair.ts @@ -0,0 +1,25 @@ +import dotenv from "dotenv"; + +import { generateKeyPairSync } from "crypto"; +import fs from "fs"; +import path from "path"; + +dotenv.config({ path: [path.resolve(__dirname, "../.env"), path.resolve(__dirname, "../.env.example")] }); + +const MODULUS_LENGTH = 4096; + +export async function generateRsaKeypair(): Promise { + const keypair = generateKeyPairSync("rsa", { + modulusLength: MODULUS_LENGTH, + }); + + const publicKey = keypair.publicKey.export({ type: "pkcs1", format: "pem" }); + const privateKey = keypair.privateKey.export({ type: "pkcs1", format: "pem" }); + + await Promise.all([ + fs.promises.writeFile(path.resolve(process.env.COORDINATOR_PUBLIC_KEY_PATH!), publicKey), + fs.promises.writeFile(path.resolve(process.env.COORDINATOR_PRIVATE_KEY_PATH!), privateKey), + ]); +} + +generateRsaKeypair(); diff --git a/packages/coordinator/tests/app.test.ts b/packages/coordinator/tests/app.test.ts new file mode 100644 index 00000000..db411264 --- /dev/null +++ b/packages/coordinator/tests/app.test.ts @@ -0,0 +1,903 @@ +import { HttpStatus, ValidationPipe, type INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { ValidationError } from "class-validator"; +import { getBytes, hashMessage, type Signer } from "ethers"; +import hardhat from "hardhat"; +import { + type DeployedContracts, + type PollContracts, + deploy, + deployPoll, + deployVkRegistryContract, + setVerifyingKeys, + signup, + publish, + timeTravel, + mergeMessages, + mergeSignups, +} from "maci-cli"; +import { Proof, TallyData } from "maci-contracts"; +import { Poll__factory as PollFactory } from "maci-contracts/typechain-types"; +import { Keypair } from "maci-domainobjs"; +import { io, Socket } from "socket.io-client"; +import request from "supertest"; + +import fs from "fs"; +import path from "path"; + +import type { App } from "supertest/types"; + +import { AppModule } from "../ts/app.module"; +import { ErrorCodes, ESupportedNetworks } from "../ts/common"; +import { CryptoService } from "../ts/crypto/crypto.service"; +import { FileModule } from "../ts/file/file.module"; +import { EProofGenerationEvents, IGenerateArgs } from "../ts/proof/types"; +import { ESubgraphEvents, IDeploySubgraphArgs } from "../ts/subgraph/types"; + +const STATE_TREE_DEPTH = 10; +const INT_STATE_TREE_DEPTH = 1; +const MSG_TREE_DEPTH = 2; +const VOTE_OPTION_TREE_DEPTH = 2; +const MSG_BATCH_DEPTH = 1; + +describe("e2e", () => { + const coordinatorKeypair = new Keypair(); + let app: INestApplication; + let signer: Signer; + let maciAddresses: DeployedContracts; + let pollContracts: PollContracts; + let socket: Socket; + + const cryptoService = new CryptoService(); + + const getAuthorizationHeader = async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const signature = await signer.signMessage("message"); + const digest = Buffer.from(getBytes(hashMessage("message"))).toString("hex"); + return `Bearer ${cryptoService.encrypt(publicKey, `${signature}:${digest}`)}`; + }; + + beforeAll(async () => { + [signer] = await hardhat.ethers.getSigners(); + + process.env.COORDINATOR_ADDRESSES = await signer.getAddress(); + + await deployVkRegistryContract({ signer }); + await setVerifyingKeys({ + quiet: true, + stateTreeDepth: STATE_TREE_DEPTH, + intStateTreeDepth: INT_STATE_TREE_DEPTH, + messageTreeDepth: MSG_TREE_DEPTH, + voteOptionTreeDepth: VOTE_OPTION_TREE_DEPTH, + messageBatchDepth: MSG_BATCH_DEPTH, + processMessagesZkeyPathNonQv: path.resolve( + __dirname, + "../zkeys/ProcessMessagesNonQv_10-2-1-2_test/ProcessMessagesNonQv_10-2-1-2_test.0.zkey", + ), + tallyVotesZkeyPathNonQv: path.resolve( + __dirname, + "../zkeys/TallyVotesNonQv_10-1-2_test/TallyVotesNonQv_10-1-2_test.0.zkey", + ), + useQuadraticVoting: false, + signer, + }); + + maciAddresses = await deploy({ stateTreeDepth: 10, signer }); + + pollContracts = await deployPoll({ + pollDuration: 30, + intStateTreeDepth: INT_STATE_TREE_DEPTH, + messageTreeSubDepth: MSG_BATCH_DEPTH, + messageTreeDepth: MSG_TREE_DEPTH, + voteOptionTreeDepth: VOTE_OPTION_TREE_DEPTH, + coordinatorPubkey: coordinatorKeypair.pubKey.serialize(), + useQuadraticVoting: false, + signer, + }); + + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule, FileModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.listen(3000); + }); + + beforeEach(async () => { + const authorization = await getAuthorizationHeader(); + + await new Promise((resolve) => { + app.getUrl().then((url) => { + socket = io(url, { + extraHeaders: { + authorization, + }, + }); + socket.on("connect", () => { + resolve(true); + }); + }); + }); + }); + + afterEach(() => { + socket.disconnect(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("validation /v1/proof/generate POST", () => { + beforeAll(async () => { + const user = new Keypair(); + + await signup({ maciAddress: maciAddresses.maciAddress, maciPubKey: user.pubKey.serialize(), signer }); + await publish({ + pubkey: user.pubKey.serialize(), + stateIndex: 1n, + voteOptionIndex: 0n, + nonce: 1n, + pollId: 0n, + newVoteWeight: 9n, + maciAddress: maciAddresses.maciAddress, + salt: 0n, + privateKey: user.privKey.serialize(), + signer, + }); + }); + + test("should throw an error if poll id is invalid", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: "-1", + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["poll must not be less than 0", "poll must be an integer number"], + }); + }); + + test("should throw an error if poll id is invalid (ws)", async () => { + const publicKey = fs.readFileSync(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: "-1" as unknown as number, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise<{ min?: string; isInt?: string }>((resolve) => { + socket + .emit(EProofGenerationEvents.START, args) + .on(EProofGenerationEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ min: error?.min, isInt: error?.isInt }); + }); + }); + + expect(result.min).toBe("poll must not be less than 0"); + expect(result.isInt).toBe("poll must be an integer number"); + }); + + test("should throw an error if encrypted key is invalid", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey: "", + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["encryptedCoordinatorPrivateKey must be longer than or equal to 1 characters"], + }); + }); + + test("should throw an error if encrypted key is invalid (ws)", async () => { + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: "", + }; + + const result = await new Promise<{ isLength?: string }>((resolve) => { + socket + .emit(EProofGenerationEvents.START, args) + .on(EProofGenerationEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ isLength: error?.isLength }); + }); + }); + + expect(result.isLength).toBe("encryptedCoordinatorPrivateKey must be longer than or equal to 1 characters"); + }); + + test("should throw an error if maci address is invalid", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: "wrong", + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["maciContractAddress must be an Ethereum address"], + }); + }); + + test("should throw an error if maci address is invalid (ws)", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: "wrong", + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise<{ isEthereumAddress?: string }>((resolve) => { + socket + .emit(EProofGenerationEvents.START, args) + .on(EProofGenerationEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ isEthereumAddress: error?.isEthereumAddress }); + }); + }); + + expect(result.isEthereumAddress).toBe("maciContractAddress must be an Ethereum address"); + }); + + test("should throw an error if tally address is invalid", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: "invalid", + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["tallyContractAddress must be an Ethereum address"], + }); + }); + + test("should throw an error if tally address is invalid (ws)", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: "wrong", + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise<{ isEthereumAddress?: string }>((resolve) => { + socket + .emit(EProofGenerationEvents.START, args) + .on(EProofGenerationEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ isEthereumAddress: error?.isEthereumAddress }); + }); + }); + + expect(result.isEthereumAddress).toBe("tallyContractAddress must be an Ethereum address"); + }); + }); + + describe("validation /v1/subgraph/deploy POST", () => { + test("should throw an error if network is invalid", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/subgraph/deploy") + .set("Authorization", encryptedHeader) + .send({ + network: "unknown", + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: [`network must be one of the following values: ${Object.values(ESupportedNetworks).join(", ")}`], + }); + }); + + test("should throw an error if network is invalid (ws)", async () => { + const args: IDeploySubgraphArgs = { + network: "unknown" as ESupportedNetworks, + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }; + + const result = await new Promise<{ network?: string }>((resolve) => { + socket.emit(ESubgraphEvents.START, args).on(ESubgraphEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ network: error?.isEnum }); + }); + }); + + expect(result.network).toBe( + `network must be one of the following values: ${Object.values(ESupportedNetworks).join(", ")}`, + ); + }); + + test("should throw an error if maci contract is invalid", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/subgraph/deploy") + .set("Authorization", encryptedHeader) + .send({ + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: "unknown", + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["maciContractAddress must be an Ethereum address"], + }); + }); + + test("should throw an error if maci contract is invalid (ws)", async () => { + const args: IDeploySubgraphArgs = { + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: "unknown", + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }; + + const result = await new Promise<{ contract?: string }>((resolve) => { + socket.emit(ESubgraphEvents.START, args).on(ESubgraphEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ contract: error?.isEthereumAddress }); + }); + }); + + expect(result.contract).toBe("maciContractAddress must be an Ethereum address"); + }); + + test("should throw an error if tag is invalid", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/subgraph/deploy") + .set("Authorization", encryptedHeader) + .send({ + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "unknown", + }) + .expect(400); + + expect(result.body).toStrictEqual({ + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + message: ["tag must match /^v\\d+\\.\\d+\\.\\d+$/ regular expression"], + }); + }); + + test("should throw an error if tag is invalid (ws)", async () => { + const args: IDeploySubgraphArgs = { + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "unknown", + }; + + const result = await new Promise<{ tag?: string }>((resolve) => { + socket.emit(ESubgraphEvents.START, args).on(ESubgraphEvents.ERROR, (errors: ValidationError[]) => { + const error = errors[0]?.constraints; + + resolve({ tag: error?.matches }); + }); + }); + + expect(result.tag).toBe("tag must match /^v\\d+\\.\\d+\\.\\d+$/ regular expression"); + }); + }); + + describe("/v1/subgraph/deploy POST", () => { + test("should throw an error if there is no authorization header", async () => { + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .send({ + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }) + .expect(403); + + expect(result.body).toStrictEqual({ + error: "Forbidden", + message: "Forbidden resource", + statusCode: HttpStatus.FORBIDDEN, + }); + }); + + test("should throw an error if there is no authorization header (ws)", async () => { + const args: IDeploySubgraphArgs = { + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + maciContractAddress: maciAddresses.maciAddress, + startBlock: 0, + name: "subgraph", + tag: "v0.0.1", + }; + + const unauthorizedSocket = io(await app.getUrl()); + + const result = await new Promise((resolve) => { + unauthorizedSocket.emit(ESubgraphEvents.START, args).on(ESubgraphEvents.ERROR, (error: Error) => { + resolve(error); + }); + }).finally(() => unauthorizedSocket.disconnect()); + + expect(result.message).toBe("Forbidden resource"); + }); + }); + + describe("/v1/proof/publicKey GET", () => { + test("should get public key properly", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + + const result = await request(app.getHttpServer() as App) + .get("/v1/proof/publicKey") + .expect(200); + + expect(result.body).toStrictEqual({ publicKey: publicKey.toString() }); + }); + }); + + describe("/v1/proof/generate POST", () => { + beforeAll(async () => { + const user = new Keypair(); + + await signup({ maciAddress: maciAddresses.maciAddress, maciPubKey: user.pubKey.serialize(), signer }); + await publish({ + pubkey: user.pubKey.serialize(), + stateIndex: 1n, + voteOptionIndex: 0n, + nonce: 1n, + pollId: 0n, + newVoteWeight: 9n, + maciAddress: maciAddresses.maciAddress, + salt: 0n, + privateKey: user.privKey.serialize(), + signer, + }); + }); + + test("should throw an error if poll is not over", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.NOT_MERGED_STATE_TREE, + }); + }); + + test("should throw an error if poll is not over (ws)", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.NOT_MERGED_STATE_TREE); + }); + + test("should throw an error if signups are not merged", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.NOT_MERGED_STATE_TREE, + }); + }); + + test("should throw an error if signups are not merged (ws)", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.NOT_MERGED_STATE_TREE); + }); + + test("should throw an error if messages are not merged", async () => { + const pollContract = PollFactory.connect(pollContracts.poll, signer); + const isStateMerged = await pollContract.stateMerged(); + + if (!isStateMerged) { + await timeTravel({ seconds: 30, signer }); + await mergeSignups({ pollId: 0n, signer }); + } + + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.NOT_MERGED_MESSAGE_TREE, + }); + }); + + test("should throw an error if messages are not merged (ws)", async () => { + const pollContract = PollFactory.connect(pollContracts.poll, signer); + const isStateMerged = await pollContract.stateMerged(); + + if (!isStateMerged) { + await timeTravel({ seconds: 30, signer }); + await mergeSignups({ pollId: 0n, signer }); + } + + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey, + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.NOT_MERGED_MESSAGE_TREE); + }); + + test("should throw an error if coordinator key decryption is failed", async () => { + await mergeMessages({ pollId: 0n, signer }); + + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.DECRYPTION, + }); + }); + + test("should throw an error if coordinator key decryption is failed (ws)", async () => { + await mergeMessages({ pollId: 0n, signer }); + + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.DECRYPTION); + }); + + test("should throw an error if there is no such poll", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 9000, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.POLL_NOT_FOUND, + }); + }); + + test("should throw an error if there is no such poll (ws)", async () => { + const args: IGenerateArgs = { + poll: 9000, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.POLL_NOT_FOUND); + }); + + test("should throw an error if there is no authorization header", async () => { + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .send({ + poll: 0, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(403); + + expect(result.body).toStrictEqual({ + error: "Forbidden", + message: "Forbidden resource", + statusCode: HttpStatus.FORBIDDEN, + }); + }); + + test("should throw an error if there is no authorization header (ws)", async () => { + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: coordinatorKeypair.privKey.serialize(), + }; + + const unauthorizedSocket = io(await app.getUrl()); + + const result = await new Promise((resolve) => { + unauthorizedSocket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }).finally(() => unauthorizedSocket.disconnect()); + + expect(result.message).toBe("Forbidden resource"); + }); + + test("should throw error if coordinator key cannot be decrypted", async () => { + const encryptedHeader = await getAuthorizationHeader(); + + const result = await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send({ + poll: 0, + encryptedCoordinatorPrivateKey: "unknown", + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }) + .expect(400); + + expect(result.body).toStrictEqual({ + statusCode: HttpStatus.BAD_REQUEST, + message: ErrorCodes.DECRYPTION, + }); + }); + + test("should throw error if coordinator key cannot be decrypted (ws)", async () => { + const args: IGenerateArgs = { + poll: 0, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: "unknown", + }; + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.ERROR, (error: Error) => { + resolve(error); + }); + }); + + expect(result.message).toBe(ErrorCodes.DECRYPTION); + }); + + test("should generate proofs properly", async () => { + const publicKey = await fs.promises.readFile(process.env.COORDINATOR_PUBLIC_KEY_PATH!); + const encryptedCoordinatorPrivateKey = cryptoService.encrypt(publicKey, coordinatorKeypair.privKey.serialize()); + const encryptedHeader = await getAuthorizationHeader(); + + const args: IGenerateArgs = { + poll: 0, + encryptedCoordinatorPrivateKey, + maciContractAddress: maciAddresses.maciAddress, + tallyContractAddress: pollContracts.tally, + useQuadraticVoting: false, + }; + + await request(app.getHttpServer() as App) + .post("/v1/proof/generate") + .set("Authorization", encryptedHeader) + .send(args) + .expect(201); + + const proofData = await Promise.all([ + fs.promises.readFile("./proofs/process_0.json"), + fs.promises.readFile("./proofs/tally_0.json"), + fs.promises.readFile("./tally.json"), + ]) + .then((files) => files.map((item) => JSON.parse(item.toString()) as Record)) + .then((data) => ({ + processProofs: [data[0]] as unknown as Proof[], + tallyProofs: [data[1]] as unknown as Proof[], + tallyData: data[2] as unknown as TallyData, + })); + + interface TResult { + tallyData?: TallyData; + } + + const result = await new Promise((resolve) => { + socket.emit(EProofGenerationEvents.START, args).on(EProofGenerationEvents.FINISH, ({ tallyData }: TResult) => { + if (tallyData) { + resolve(tallyData); + } + }); + }); + + expect(proofData.processProofs).toHaveLength(1); + expect(proofData.tallyProofs).toHaveLength(1); + expect(proofData.tallyData).toBeDefined(); + expect(result.results.tally).toStrictEqual(proofData.tallyData.results.tally); + }); + }); +}); diff --git a/packages/coordinator/ts/app.module.ts b/packages/coordinator/ts/app.module.ts new file mode 100644 index 00000000..f363afec --- /dev/null +++ b/packages/coordinator/ts/app.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { ThrottlerModule } from "@nestjs/throttler"; + +import { CryptoModule } from "./crypto/crypto.module"; +import { FileModule } from "./file/file.module"; +import { ProofModule } from "./proof/proof.module"; +import { SubgraphModule } from "./subgraph/subgraph.module"; + +@Module({ + imports: [ + ThrottlerModule.forRoot([ + { + ttl: Number(process.env.TTL), + limit: Number(process.env.LIMIT), + }, + ]), + FileModule, + CryptoModule, + SubgraphModule, + ProofModule, + ], +}) +export class AppModule {} diff --git a/packages/coordinator/ts/auth/AccountSignatureGuard.service.ts b/packages/coordinator/ts/auth/AccountSignatureGuard.service.ts new file mode 100644 index 00000000..759458ac --- /dev/null +++ b/packages/coordinator/ts/auth/AccountSignatureGuard.service.ts @@ -0,0 +1,103 @@ +import { + Logger, + CanActivate, + Injectable, + SetMetadata, + type ExecutionContext, + type CustomDecorator, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ethers } from "ethers"; + +import fs from "fs"; +import path from "path"; + +import type { Request as Req } from "express"; +import type { Socket } from "socket.io"; + +import { CryptoService } from "../crypto/crypto.service"; + +/** + * Public metadata key + */ +export const PUBLIC_METADATA_KEY = "isPublic"; + +/** + * Public decorator to by-pass auth checks + * + * @returns public decorator + */ +export const Public = (): CustomDecorator => SetMetadata(PUBLIC_METADATA_KEY, true); + +/** + * AccountSignatureGuard is responsible for protecting calling controller and websocket gateway functions. + * If account address is not added to .env file, you will not be allowed to call any API methods. + * Make sure you send `Authorization: Bearer encrypt({signature}:{digest})` header where: + * 1. encrypt - RSA public encryption. + * 2. signature - eth wallet signature for any message + * 3. digest - hex representation of message digest + * + * ``` + * const signature = await signer.signMessage("message"); + * const digest = Buffer.from(getBytes(hashMessage("message"))).toString("hex"); + * ``` + * See tests for more details about authorization. + */ +@Injectable() +export class AccountSignatureGuard implements CanActivate { + /** + * Logger + */ + private readonly logger: Logger; + + constructor( + private readonly cryptoService: CryptoService, + private readonly reflector: Reflector, + ) { + this.logger = new Logger(AccountSignatureGuard.name); + } + + /** + * This function should return a boolean, indicating whether the request is allowed or not based on message signature and digest. + * + * @param ctx - execution context + * @returns whether the request is allowed or not + */ + async canActivate(ctx: ExecutionContext): Promise { + try { + const isPublic = this.reflector.get(PUBLIC_METADATA_KEY, ctx.getHandler()); + + if (isPublic) { + return true; + } + + const request = ctx.switchToHttp().getRequest>(); + const socket = ctx.switchToWs().getClient>(); + const encryptedHeader = socket.handshake?.headers.authorization || request.headers?.authorization; + + if (!encryptedHeader) { + this.logger.warn("No authorization header"); + return false; + } + + const privateKey = await fs.promises.readFile(path.resolve(process.env.COORDINATOR_PRIVATE_KEY_PATH!)); + const [signature, digest] = this.cryptoService + .decrypt(privateKey, encryptedHeader.replace("Bearer", "").trim()) + .split(":"); + + if (!signature || !digest) { + this.logger.warn("No signature or digest"); + return false; + } + + const address = ethers.recoverAddress(Buffer.from(digest, "hex"), signature).toLowerCase(); + const coordinatorAddress = + process.env.COORDINATOR_ADDRESSES?.split(",").map((value) => value.toLowerCase()) ?? []; + + return coordinatorAddress.includes(address); + } catch (error) { + this.logger.error("Error", error); + return false; + } + } +} diff --git a/packages/coordinator/ts/auth/__tests__/AccountSignatureGuard.test.ts b/packages/coordinator/ts/auth/__tests__/AccountSignatureGuard.test.ts new file mode 100644 index 00000000..270ca3fa --- /dev/null +++ b/packages/coordinator/ts/auth/__tests__/AccountSignatureGuard.test.ts @@ -0,0 +1,163 @@ +import { Reflector } from "@nestjs/core"; +import dotenv from "dotenv"; +import { getBytes, hashMessage } from "ethers"; +import hardhat from "hardhat"; + +import type { ExecutionContext } from "@nestjs/common"; + +import { CryptoService } from "../../crypto/crypto.service"; +import { AccountSignatureGuard, PUBLIC_METADATA_KEY, Public } from "../AccountSignatureGuard.service"; + +dotenv.config(); + +jest.mock("../../crypto/crypto.service", (): unknown => ({ + CryptoService: { + getInstance: jest.fn(), + }, +})); + +describe("AccountSignatureGuard", () => { + const mockRequest = { + headers: { authorization: "data" }, + }; + + const mockContext = { + getHandler: jest.fn(), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn(() => mockRequest), + }), + switchToWs: jest.fn().mockReturnValue({ + getClient: jest.fn(() => ({ handshake: mockRequest })), + }), + } as unknown as ExecutionContext; + + const mockSignature = + "0xc0436b6fbd5ff883fe88367f081d0780706b5c29fbfde8db2c1d607510f9095a73b3bc9b94a1588d561eaf49d195b134e3f92c36449c017ecf92c5e4d84a32131c"; + const mockDigest = "7f6c0e5c497ded52462ec18daeb1c94cefa11cd6949ebdb7074b2a32cac13fba"; + + const mockCryptoService = { + decrypt: jest.fn(), + } as unknown as CryptoService; + + const reflector = { + get: jest.fn(), + } as Reflector & { get: jest.Mock }; + + beforeEach(() => { + mockCryptoService.decrypt = jest.fn(() => `${mockSignature}:${mockDigest}`); + reflector.get.mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should create public decorator properly", () => { + const decorator = Public(); + + expect(decorator.KEY).toBe(PUBLIC_METADATA_KEY); + }); + + test("should return false if there is no Authorization header", async () => { + const ctx = { + getHandler: jest.fn(), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn(() => ({ + headers: { authorization: "" }, + })), + }), + switchToWs: jest.fn().mockReturnValue({ + getClient: jest.fn(() => ({ handshake: { headers: { authorization: "" } } })), + }), + } as unknown as ExecutionContext; + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(ctx); + + expect(result).toBe(false); + }); + + test("should return false if there is no signature", async () => { + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(`:${mockDigest}`); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + test("should return false if there is no digest", async () => { + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(mockSignature); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + test("should return false if signature or digest are invalid", async () => { + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(`signature:digest`); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + test("should return false if signer is different", async () => { + const [, signer] = await hardhat.ethers.getSigners(); + const signature = await signer.signMessage("message"); + const digest = Buffer.from(getBytes(hashMessage("message"))).toString("hex"); + + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(`${signature}:${digest}`); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + test("should return true if authorization is passed properly", async () => { + const [signer] = await hardhat.ethers.getSigners(); + process.env.COORDINATOR_ADDRESSES = await signer.getAddress(); + const signature = await signer.signMessage("message"); + const digest = Buffer.from(getBytes(hashMessage("message"))).toString("hex"); + + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(`${signature}:${digest}`); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(true); + }); + + test("should return false if there is no COORDINATOR_ADDRESSES env", async () => { + const [signer] = await hardhat.ethers.getSigners(); + process.env.COORDINATOR_ADDRESSES = undefined; + const signature = await signer.signMessage("message"); + const digest = Buffer.from(getBytes(hashMessage("message"))).toString("hex"); + + (mockCryptoService.decrypt as jest.Mock).mockReturnValue(`${signature}:${digest}`); + + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + test("should return true if can skip authorization", async () => { + reflector.get.mockReturnValue(true); + const guard = new AccountSignatureGuard(mockCryptoService, reflector); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(true); + }); +}); diff --git a/packages/coordinator/ts/common/errors.ts b/packages/coordinator/ts/common/errors.ts new file mode 100644 index 00000000..87606ae1 --- /dev/null +++ b/packages/coordinator/ts/common/errors.ts @@ -0,0 +1,13 @@ +/** + * Error codes that are used for api responses + */ +export enum ErrorCodes { + NOT_MERGED_STATE_TREE = "0", + NOT_MERGED_MESSAGE_TREE = "1", + PRIVATE_KEY_MISMATCH = "2", + POLL_NOT_FOUND = "3", + DECRYPTION = "4", + ENCRYPTION = "5", + FILE_NOT_FOUND = "6", + SUBGRAPH_DEPLOY = "7", +} diff --git a/packages/coordinator/ts/common/index.ts b/packages/coordinator/ts/common/index.ts new file mode 100644 index 00000000..78bdf495 --- /dev/null +++ b/packages/coordinator/ts/common/index.ts @@ -0,0 +1,2 @@ +export { ErrorCodes } from "./errors"; +export { ESupportedNetworks } from "./networks"; diff --git a/packages/coordinator/ts/common/networks.ts b/packages/coordinator/ts/common/networks.ts new file mode 100644 index 00000000..a74e922e --- /dev/null +++ b/packages/coordinator/ts/common/networks.ts @@ -0,0 +1,64 @@ +export enum ESupportedNetworks { + ETHEREUM = "mainnet", + OPTIMISM = "optimism", + OPTIMISM_SEPOLIA = "optimism-sepolia", + BSC = "bsc", + BSC_CHAPEL = "chapel", + GNOSIS_CHAIN = "gnosis", + FUSE = "fuse", + POLYGON = "matic", + FANTOM_OPERA = "fantom", + ZKSYNC_ERA_TESTNET = "zksync-era-testnet", + BOBA = "boba", + MOONBEAM = "moonbeam", + MOONRIVER = "moonriver", + MOONBASE_ALPHA = "mbase", + FANTOM_TESTNET = "fantom-testnet", + ARBITRUM_ONE = "arbitrum-one", + CELO = "celo", + AVALANCHE_FUJI = "fuji", + AVALANCHE = "avalanche", + CELO_ALFAJORES = "celo-alfajores", + HOLESKY = "holesky", + AURORA = "aurora", + AURORA_TESTNET = "aurora-testnet", + HARMONY = "harmony", + LINEA_SEPOLIA = "linea-sepolia", + GNOSIS_CHIADO = "gnosis-chiado", + MODE_SEPOLIA = "mode-sepolia", + MODE = "mode-mainnet", + BASE_SEPOLIA = "base-sepolia", + ZKSYNC_ERA_SEPOLIA = "zksync-era-sepolia", + POLYGON_ZKEVM = "polygon-zkevm", + ZKSYNC_ERA = "zksync-era", + ETHEREUM_SEPOLIA = "sepolia", + ARBITRUM_SEPOLIA = "arbitrum-sepolia", + LINEA = "linea", + BASE = "base", + SCROLL_SEPOLIA = "scroll-sepolia", + SCROLL = "scroll", + BLAST_MAINNET = "blast-mainnet", + ASTAR_ZKEVM_MAINNET = "astar-zkevm-mainnet", + SEI_TESTNET = "sei-testnet", + BLAST_TESTNET = "blast-testnet", + ETHERLINK_TESTNET = "etherlink-testnet", + XLAYER_SEPOLIA = "xlayer-sepolia", + XLAYER_MAINNET = "xlayer-mainnet", + POLYGON_AMOY = "polygon-amoy", + ZKYOTO_TESTNET = "zkyoto-testnet", + POLYGON_ZKEVM_CARDONA = "polygon-zkevm-cardona", + SEI_MAINNET = "sei-mainnet", + ROOTSTOCK_MAINNET = "rootstock", + IOTEX_MAINNET = "iotex", + NEAR_MAINNET = "near-mainnet", + NEAR_TESTNET = "near-testnet", + COSMOS = "cosmoshub-4", + COSMOS_HUB = "theta-testnet-001", + OSMOSIS = "osmosis-1", + OSMO_TESTNET = "osmo-test-4", + ARWEAVE = "arweave-mainnet", + BITCOIN = "btc", + SOLANA = "solana-mainnet-beta", + INJECTIVE_MAINNET = "injective-mainnet", + INJECTIVE_TESTNET = "injective-testnet", +} diff --git a/packages/coordinator/ts/crypto/__tests__/crypto.service.test.ts b/packages/coordinator/ts/crypto/__tests__/crypto.service.test.ts new file mode 100644 index 00000000..8643cd89 --- /dev/null +++ b/packages/coordinator/ts/crypto/__tests__/crypto.service.test.ts @@ -0,0 +1,41 @@ +import fc from "fast-check"; + +import { generateKeyPairSync } from "crypto"; + +import { ErrorCodes } from "../../common"; +import { CryptoService } from "../crypto.service"; + +describe("CryptoService", () => { + test("should throw encryption error if key is invalid", () => { + const service = new CryptoService(); + + expect(() => service.encrypt("", "")).toThrow(ErrorCodes.ENCRYPTION); + }); + + test("should throw decryption error if key is invalid", () => { + const service = new CryptoService(); + + expect(() => service.decrypt("", "")).toThrow(ErrorCodes.DECRYPTION); + }); + + test("should encrypt and decrypt properly", () => { + fc.assert( + fc.property(fc.string(), (text: string) => { + const service = new CryptoService(); + + const keypair = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + + const encryptedText = service.encrypt(keypair.publicKey.export({ type: "pkcs1", format: "pem" }), text); + + const decryptedText = service.decrypt( + keypair.privateKey.export({ type: "pkcs1", format: "pem" }), + encryptedText, + ); + + return decryptedText === text; + }), + ); + }); +}); diff --git a/packages/coordinator/ts/crypto/crypto.module.ts b/packages/coordinator/ts/crypto/crypto.module.ts new file mode 100644 index 00000000..fcb331b7 --- /dev/null +++ b/packages/coordinator/ts/crypto/crypto.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { CryptoService } from "./crypto.service"; + +@Module({ + exports: [CryptoService], + providers: [CryptoService], +}) +export class CryptoModule {} diff --git a/packages/coordinator/ts/crypto/crypto.service.ts b/packages/coordinator/ts/crypto/crypto.service.ts new file mode 100644 index 00000000..7875c12c --- /dev/null +++ b/packages/coordinator/ts/crypto/crypto.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import { publicEncrypt, privateDecrypt, type KeyLike } from "crypto"; + +import { ErrorCodes } from "../common"; + +/** + * CryptoService is responsible for encrypting and decrypting user sensitive data + */ +@Injectable() +export class CryptoService { + /** + * Logger + */ + private readonly logger: Logger; + + /** + * Initialize service + */ + constructor() { + this.logger = new Logger(CryptoService.name); + } + + /** + * Encrypt plaintext with public key + * + * @param publicKey - public key + * @param value - plaintext + * @returns ciphertext + */ + encrypt(publicKey: KeyLike, value: string): string { + try { + const encrypted = publicEncrypt(publicKey, Buffer.from(value)); + + return encrypted.toString("base64"); + } catch (error) { + this.logger.error(`Error: ${ErrorCodes.ENCRYPTION}`, error); + throw new Error(ErrorCodes.ENCRYPTION); + } + } + + /** + * Decrypt ciphertext with private key + * + * @param privateKey - private key + * @param value - ciphertext + * @returns plaintext + */ + decrypt(privateKey: KeyLike, value: string): string { + try { + const decryptedData = privateDecrypt(privateKey, Buffer.from(value, "base64")); + + return decryptedData.toString(); + } catch (error) { + this.logger.error(`Error: ${ErrorCodes.DECRYPTION}`, error); + throw new Error(ErrorCodes.DECRYPTION); + } + } +} diff --git a/packages/coordinator/ts/file/__tests__/file.service.test.ts b/packages/coordinator/ts/file/__tests__/file.service.test.ts new file mode 100644 index 00000000..d70e14cb --- /dev/null +++ b/packages/coordinator/ts/file/__tests__/file.service.test.ts @@ -0,0 +1,87 @@ +import dotenv from "dotenv"; + +import fs from "fs"; + +import { ErrorCodes } from "../../common"; +import { FileService } from "../file.service"; + +dotenv.config(); + +describe("FileService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should return public key properly", async () => { + const service = new FileService(); + + const { publicKey } = await service.getPublicKey(); + + expect(publicKey).toBeDefined(); + }); + + test("should return private key properly", async () => { + const service = new FileService(); + + const { privateKey } = await service.getPrivateKey(); + + expect(privateKey).toBeDefined(); + }); + + test("should return zkey filepaths for tally qv properly", () => { + const service = new FileService(); + + const { zkey, wasm, witgen } = service.getZkeyFilePaths(process.env.COORDINATOR_TALLY_ZKEY_NAME!, true); + + expect(zkey).toBeDefined(); + expect(wasm).toBeDefined(); + expect(witgen).toBeDefined(); + }); + + test("should return zkey filepaths for tally non-qv properly", () => { + const service = new FileService(); + + const { zkey, wasm, witgen } = service.getZkeyFilePaths(process.env.COORDINATOR_TALLY_ZKEY_NAME!, false); + + expect(zkey).toBeDefined(); + expect(wasm).toBeDefined(); + expect(witgen).toBeDefined(); + }); + + test("should return zkey filepaths for message process qv properly", () => { + const service = new FileService(); + + const { zkey, wasm, witgen } = service.getZkeyFilePaths(process.env.COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME!, true); + + expect(zkey).toBeDefined(); + expect(wasm).toBeDefined(); + expect(witgen).toBeDefined(); + }); + + test("should return zkey filepaths for message process non-qv properly", () => { + const service = new FileService(); + + const { zkey, wasm, witgen } = service.getZkeyFilePaths(process.env.COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME!, false); + + expect(zkey).toBeDefined(); + expect(wasm).toBeDefined(); + expect(witgen).toBeDefined(); + }); + + test("should throw an error if there are no zkey filepaths", () => { + const service = new FileService(); + + expect(() => service.getZkeyFilePaths("unknown", false)).toThrow(ErrorCodes.FILE_NOT_FOUND); + }); + + test("should throw an error if there are no wasm and witgen filepaths", () => { + const spyExistsSync = jest.spyOn(fs, "existsSync"); + spyExistsSync.mockReturnValueOnce(true).mockReturnValue(false); + + const service = new FileService(); + + expect(() => service.getZkeyFilePaths(process.env.COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME!, false)).toThrow( + ErrorCodes.FILE_NOT_FOUND, + ); + }); +}); diff --git a/packages/coordinator/ts/file/file.module.ts b/packages/coordinator/ts/file/file.module.ts new file mode 100644 index 00000000..09a46ce1 --- /dev/null +++ b/packages/coordinator/ts/file/file.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; + +import { FileService } from "./file.service"; + +@Module({ + exports: [FileService], + providers: [FileService], +}) +export class FileModule {} diff --git a/packages/coordinator/ts/file/file.service.ts b/packages/coordinator/ts/file/file.service.ts new file mode 100644 index 00000000..d0bdff26 --- /dev/null +++ b/packages/coordinator/ts/file/file.service.ts @@ -0,0 +1,81 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import fs from "fs"; +import path from "path"; + +import type { IGetPrivateKeyData, IGetPublicKeyData, IGetZkeyFilePathsData } from "./types"; + +import { ErrorCodes } from "../common"; + +/** + * FileService is responsible for working with local files like: + * 1. RSA public/private keys + * 2. Zkey files + */ +@Injectable() +export class FileService { + /** + * Logger + */ + private readonly logger: Logger; + + /** + * Initialize service + */ + constructor() { + this.logger = new Logger(FileService.name); + } + + /** + * Get RSA private key for coordinator service + * + * @returns serialized RSA public key + */ + async getPublicKey(): Promise { + const publicKey = await fs.promises.readFile(path.resolve(process.env.COORDINATOR_PUBLIC_KEY_PATH!)); + + return { publicKey: publicKey.toString() }; + } + + /** + * Get RSA private key for coordinator service + * + * @returns serialized RSA private key + */ + async getPrivateKey(): Promise { + const privateKey = await fs.promises.readFile(path.resolve(process.env.COORDINATOR_PRIVATE_KEY_PATH!)); + + return { privateKey: privateKey.toString() }; + } + + /** + * Get zkey, wasm and witgen filepaths for zkey set + * + * @param name - zkey set name + * @param useQuadraticVoting - whether to use Qv or NonQv + * @returns zkey and wasm filepaths + */ + getZkeyFilePaths(name: string, useQuadraticVoting: boolean): IGetZkeyFilePathsData { + const root = path.resolve(process.env.COORDINATOR_ZKEY_PATH!); + const index = name.indexOf("_"); + const type = name.slice(0, index); + const params = name.slice(index + 1); + const mode = useQuadraticVoting ? "" : "NonQv"; + const filename = `${type}${mode}_${params}`; + + const zkey = path.resolve(root, `${filename}/${filename}.0.zkey`); + const wasm = path.resolve(root, `${filename}/${filename}_js/${filename}.wasm`); + const witgen = path.resolve(root, `${filename}/${filename}_cpp/${filename}`); + + if (!fs.existsSync(zkey) || (!fs.existsSync(wasm) && !fs.existsSync(witgen))) { + this.logger.error(`Error: ${ErrorCodes.FILE_NOT_FOUND}, zkey: ${zkey}, wasm: ${wasm}, witgen: ${witgen}`); + throw new Error(ErrorCodes.FILE_NOT_FOUND); + } + + return { + zkey, + wasm, + witgen, + }; + } +} diff --git a/packages/coordinator/ts/file/types.ts b/packages/coordinator/ts/file/types.ts new file mode 100644 index 00000000..b609a25a --- /dev/null +++ b/packages/coordinator/ts/file/types.ts @@ -0,0 +1,39 @@ +/** + * Interface that represents public key return data + */ +export interface IGetPublicKeyData { + /** + * RSA public key + */ + publicKey: string; +} + +/** + * Interface that represents private key return data + */ +export interface IGetPrivateKeyData { + /** + * RSA private key + */ + privateKey: string; +} + +/** + * Interface that represents zkey file paths return data + */ +export interface IGetZkeyFilePathsData { + /** + * Zkey filepath + */ + zkey: string; + + /** + * Wasm filepath + */ + wasm: string; + + /** + * Witgen filepath + */ + witgen: string; +} diff --git a/packages/coordinator/ts/jest/setup.ts b/packages/coordinator/ts/jest/setup.ts new file mode 100644 index 00000000..bcc226d4 --- /dev/null +++ b/packages/coordinator/ts/jest/setup.ts @@ -0,0 +1,9 @@ +import type { HardhatEthersHelpers } from "@nomicfoundation/hardhat-ethers/types"; +import type { ethers } from "ethers"; + +declare module "hardhat/types/runtime" { + interface HardhatRuntimeEnvironment { + // We omit the ethers field because it is redundant. + ethers: typeof ethers & HardhatEthersHelpers; + } +} diff --git a/packages/coordinator/ts/jest/transform.js b/packages/coordinator/ts/jest/transform.js new file mode 100644 index 00000000..d07dcc1e --- /dev/null +++ b/packages/coordinator/ts/jest/transform.js @@ -0,0 +1,9 @@ +/* eslint-disable */ + +module.exports = { + process(sourceText) { + return { + code: sourceText.replace("#!/usr/bin/env node", ""), + }; + }, +}; diff --git a/packages/coordinator/ts/main.ts b/packages/coordinator/ts/main.ts new file mode 100644 index 00000000..b6aabfc5 --- /dev/null +++ b/packages/coordinator/ts/main.ts @@ -0,0 +1,45 @@ +import { ValidationPipe } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import dotenv from "dotenv"; +import helmet from "helmet"; + +import path from "path"; + +dotenv.config({ path: [path.resolve(__dirname, "../.env"), path.resolve(__dirname, "../.env.example")] }); + +async function bootstrap() { + const { AppModule } = await import("./app.module.js"); + const app = await NestFactory.create(AppModule, { + logger: ["log", "fatal", "error", "warn"], + }); + + app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: [`'self'`], + styleSrc: [`'self'`, `'unsafe-inline'`], + imgSrc: [`'self'`, "data:", "validator.swagger.io"], + scriptSrc: [`'self'`, `https: 'unsafe-inline'`], + }, + }, + }), + ); + app.enableCors({ origin: process.env.COORDINATOR_ALLOWED_ORIGINS?.split(",") }); + + const config = new DocumentBuilder() + .setTitle("Coordinator service") + .setDescription("Coordinator service API methods") + .setVersion("1.0") + .addTag("coordinator") + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup("api", app, document); + + await app.listen(process.env.COORDINATOR_PORT || 3000); +} + +bootstrap(); diff --git a/packages/coordinator/ts/proof/__tests__/proof.controller.test.ts b/packages/coordinator/ts/proof/__tests__/proof.controller.test.ts new file mode 100644 index 00000000..04c04a18 --- /dev/null +++ b/packages/coordinator/ts/proof/__tests__/proof.controller.test.ts @@ -0,0 +1,101 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import type { IGetPublicKeyData } from "../../file/types"; +import type { IGenerateArgs, IGenerateData } from "../types"; +import type { TallyData } from "maci-cli"; + +import { FileService } from "../../file/file.service"; +import { ProofController } from "../proof.controller"; +import { ProofGeneratorService } from "../proof.service"; + +describe("ProofController", () => { + let proofController: ProofController; + + const defaultProofGeneratorArgs: IGenerateArgs = { + poll: 0, + maciContractAddress: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + tallyContractAddress: "0x6F1216D1BFe15c98520CA1434FC1d9D57AC95321", + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: + "siO9W/g7jNVXs9tOUv/pffrcqYdMlgdXw7nSSlqM1q1UvHGSSbhtLJpeT+nJKW7/+xrBTgI0wB866DSkg8Rgr8zD+POUMiKPrGqAO/XhrcmRDL+COURFNDRh9WGeAua6hdiNoufQYvXPl1iWyIYidSDbfmC2wR6F9vVkhg/6KDZyw8Wlr6LUh0RYT+hUHEwwGbz7MeqZJcJQSTpECPF5pnk8NTHL2W/XThaewB4n4HYqjDUbYLmBDLYWsDDMgoPo709a309rTq3uEe0YBgVF8g9aGxucTDhz+/LYYzqaeSxclUwen9Z4BGZjiDSPBZfooOEQEEwIJlViQ2kl1VeOKAmkiWEUVfItivmNbC/PNZchklmfFsGpiu4DT9UU9YVBN2OTcFYHHsslcaqrR7SuesqjluaGjG46oYEmfQlkZ4gXhavdWXw2ant+Tv6HRo4trqjoD1e3jUkN6gJMWomxOeRBTg0czBZlz/IwUtTpBHcKhi3EqGQo8OuQtWww+Ts7ySmeoONuovYUsIAppNuOubfUxvFJoTr2vKbWNAiYetw09kddkjmBe+S8A5PUiFOi262mfc7g5wJwPPP7wpTBY0Fya+2BCPzXqRLMOtNI+1tW3/UQLZYvEY8J0TxmhoAGZaRn8FKaosatRxDZTQS6QUNmKxpmUspkRKzTXN5lznM=", + }; + + const defaultProofGeneratorData: IGenerateData = { + tallyProofs: [], + processProofs: [], + tallyData: {} as TallyData, + }; + + const defaultPublicKeyData: IGetPublicKeyData = { + publicKey: "key", + }; + + const mockGeneratorService = { + generate: jest.fn(), + }; + + const mockFileService = { + getPublicKey: jest.fn(), + }; + + beforeEach(async () => { + const app = await Test.createTestingModule({ + controllers: [ProofController], + }) + .useMocker((token) => { + if (token === ProofGeneratorService) { + mockGeneratorService.generate.mockResolvedValue(defaultProofGeneratorData); + + return mockGeneratorService; + } + + if (token === FileService) { + mockFileService.getPublicKey.mockResolvedValue(defaultPublicKeyData); + + return mockFileService; + } + + return jest.fn(); + }) + .compile(); + + proofController = app.get(ProofController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("v1/proof/generate", () => { + test("should return generated proof data", async () => { + const data = await proofController.generate(defaultProofGeneratorArgs); + expect(data).toStrictEqual(defaultProofGeneratorData); + }); + + test("should throw an error if proof generation is failed", async () => { + const error = new Error("error"); + mockGeneratorService.generate.mockRejectedValue(error); + + await expect(proofController.generate(defaultProofGeneratorArgs)).rejects.toThrow( + new HttpException(error.message, HttpStatus.BAD_REQUEST), + ); + }); + }); + + describe("v1/proof/publicKey", () => { + test("should return public key properly", async () => { + const data = await proofController.getPublicKey(); + expect(data).toStrictEqual(defaultPublicKeyData); + }); + + test("should throw an error if file service throws an error", async () => { + const error = new Error("error"); + mockFileService.getPublicKey.mockRejectedValue(error); + + await expect(proofController.getPublicKey()).rejects.toThrow( + new HttpException(error.message, HttpStatus.BAD_REQUEST), + ); + }); + }); +}); diff --git a/packages/coordinator/ts/proof/__tests__/proof.gateway.test.ts b/packages/coordinator/ts/proof/__tests__/proof.gateway.test.ts new file mode 100644 index 00000000..ca2e26f3 --- /dev/null +++ b/packages/coordinator/ts/proof/__tests__/proof.gateway.test.ts @@ -0,0 +1,84 @@ +import { Test } from "@nestjs/testing"; +import { IGenerateProofsOptions } from "maci-contracts"; +import { Server } from "socket.io"; + +import type { IGenerateArgs, IGenerateData } from "../types"; +import type { TallyData } from "maci-cli"; + +import { ProofGateway } from "../proof.gateway"; +import { ProofGeneratorService } from "../proof.service"; +import { EProofGenerationEvents } from "../types"; + +describe("ProofGateway", () => { + let gateway: ProofGateway; + + const defaultProofGeneratorArgs: IGenerateArgs = { + poll: 0, + maciContractAddress: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + tallyContractAddress: "0x6F1216D1BFe15c98520CA1434FC1d9D57AC95321", + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: + "siO9W/g7jNVXs9tOUv/pffrcqYdMlgdXw7nSSlqM1q1UvHGSSbhtLJpeT+nJKW7/+xrBTgI0wB866DSkg8Rgr8zD+POUMiKPrGqAO/XhrcmRDL+COURFNDRh9WGeAua6hdiNoufQYvXPl1iWyIYidSDbfmC2wR6F9vVkhg/6KDZyw8Wlr6LUh0RYT+hUHEwwGbz7MeqZJcJQSTpECPF5pnk8NTHL2W/XThaewB4n4HYqjDUbYLmBDLYWsDDMgoPo709a309rTq3uEe0YBgVF8g9aGxucTDhz+/LYYzqaeSxclUwen9Z4BGZjiDSPBZfooOEQEEwIJlViQ2kl1VeOKAmkiWEUVfItivmNbC/PNZchklmfFsGpiu4DT9UU9YVBN2OTcFYHHsslcaqrR7SuesqjluaGjG46oYEmfQlkZ4gXhavdWXw2ant+Tv6HRo4trqjoD1e3jUkN6gJMWomxOeRBTg0czBZlz/IwUtTpBHcKhi3EqGQo8OuQtWww+Ts7ySmeoONuovYUsIAppNuOubfUxvFJoTr2vKbWNAiYetw09kddkjmBe+S8A5PUiFOi262mfc7g5wJwPPP7wpTBY0Fya+2BCPzXqRLMOtNI+1tW3/UQLZYvEY8J0TxmhoAGZaRn8FKaosatRxDZTQS6QUNmKxpmUspkRKzTXN5lznM=", + }; + + const defaultProofGeneratorData: IGenerateData = { + tallyProofs: [], + processProofs: [], + tallyData: {} as TallyData, + }; + + const mockGeneratorService = { + generate: jest.fn(), + }; + + const mockEmit = jest.fn(); + + beforeEach(async () => { + const testModule = await Test.createTestingModule({ providers: [ProofGateway] }) + .useMocker((token) => { + if (token === ProofGeneratorService) { + mockGeneratorService.generate.mockImplementation((_, options?: IGenerateProofsOptions) => { + options?.onBatchComplete?.({ current: 1, total: 2, proofs: defaultProofGeneratorData.processProofs }); + options?.onComplete?.( + defaultProofGeneratorData.processProofs.concat(defaultProofGeneratorData.tallyProofs), + defaultProofGeneratorData.tallyData, + ); + options?.onFail?.(new Error("error")); + }); + + return mockGeneratorService; + } + + return jest.fn(); + }) + .compile(); + + gateway = testModule.get(ProofGateway); + + gateway.server = { emit: mockEmit } as unknown as Server; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should be defined", () => { + expect(gateway).toBeDefined(); + }); + + test("should start proof generation properly", async () => { + await gateway.generate(defaultProofGeneratorArgs); + + expect(mockEmit).toHaveBeenCalledTimes(3); + expect(mockEmit).toHaveBeenNthCalledWith(1, EProofGenerationEvents.PROGRESS, { + current: 1, + total: 2, + proofs: defaultProofGeneratorData.processProofs, + }); + expect(mockEmit).toHaveBeenNthCalledWith(2, EProofGenerationEvents.FINISH, { + proofs: defaultProofGeneratorData.processProofs.concat(defaultProofGeneratorData.tallyProofs), + tallyData: defaultProofGeneratorData.tallyData, + }); + expect(mockEmit).toHaveBeenNthCalledWith(3, EProofGenerationEvents.ERROR, { message: "error" }); + }); +}); diff --git a/packages/coordinator/ts/proof/__tests__/proof.service.test.ts b/packages/coordinator/ts/proof/__tests__/proof.service.test.ts new file mode 100644 index 00000000..2a0bd95e --- /dev/null +++ b/packages/coordinator/ts/proof/__tests__/proof.service.test.ts @@ -0,0 +1,180 @@ +import dotenv from "dotenv"; +import { ZeroAddress } from "ethers"; +import { Deployment, ProofGenerator } from "maci-contracts"; +import { Keypair, PrivKey } from "maci-domainobjs"; + +import type { IGenerateArgs } from "../types"; + +import { ErrorCodes } from "../../common"; +import { CryptoService } from "../../crypto/crypto.service"; +import { FileService } from "../../file/file.service"; +import { ProofGeneratorService } from "../proof.service"; + +dotenv.config(); + +jest.mock("hardhat", (): unknown => ({ + network: { + name: "localhost", + config: { + chain: { id: 0x1 }, + }, + }, +})); + +jest.mock("maci-contracts", (): unknown => ({ + ...jest.requireActual("maci-contracts"), + Deployment: { + getInstance: jest.fn(), + }, + ProofGenerator: jest.fn(), +})); + +jest.mock("../../crypto/crypto.service", (): unknown => ({ + CryptoService: { + getInstance: jest.fn(), + }, +})); + +describe("ProofGeneratorService", () => { + const defaultArgs: IGenerateArgs = { + poll: 1, + maciContractAddress: ZeroAddress, + tallyContractAddress: ZeroAddress, + useQuadraticVoting: false, + encryptedCoordinatorPrivateKey: + "siO9W/g7jNVXs9tOUv/pffrcqYdMlgdXw7nSSlqM1q1UvHGSSbhtLJpeT+nJKW7/+xrBTgI0wB866DSkg8Rgr8zD+POUMiKPrGqAO/XhrcmRDL+COURFNDRh9WGeAua6hdiNoufQYvXPl1iWyIYidSDbfmC2wR6F9vVkhg/6KDZyw8Wlr6LUh0RYT+hUHEwwGbz7MeqZJcJQSTpECPF5pnk8NTHL2W/XThaewB4n4HYqjDUbYLmBDLYWsDDMgoPo709a309rTq3uEe0YBgVF8g9aGxucTDhz+/LYYzqaeSxclUwen9Z4BGZjiDSPBZfooOEQEEwIJlViQ2kl1VeOKAmkiWEUVfItivmNbC/PNZchklmfFsGpiu4DT9UU9YVBN2OTcFYHHsslcaqrR7SuesqjluaGjG46oYEmfQlkZ4gXhavdWXw2ant+Tv6HRo4trqjoD1e3jUkN6gJMWomxOeRBTg0czBZlz/IwUtTpBHcKhi3EqGQo8OuQtWww+Ts7ySmeoONuovYUsIAppNuOubfUxvFJoTr2vKbWNAiYetw09kddkjmBe+S8A5PUiFOi262mfc7g5wJwPPP7wpTBY0Fya+2BCPzXqRLMOtNI+1tW3/UQLZYvEY8J0TxmhoAGZaRn8FKaosatRxDZTQS6QUNmKxpmUspkRKzTXN5lznM=", + }; + + let mockContract = { + polls: jest.fn(), + getMainRoot: jest.fn(), + treeDepths: jest.fn(), + extContracts: jest.fn(), + stateMerged: jest.fn(), + coordinatorPubKey: jest.fn(), + }; + + let defaultProofGenerator = { + generateMpProofs: jest.fn(), + generateTallyProofs: jest.fn(), + }; + + const defaultCryptoService = { + decrypt: jest.fn(), + } as unknown as CryptoService; + + const defaultDeploymentService = { + setHre: jest.fn(), + getDeployer: jest.fn(() => Promise.resolve({})), + getContract: jest.fn(() => Promise.resolve(mockContract)), + }; + + const fileService = new FileService(); + + beforeEach(() => { + mockContract = { + polls: jest.fn(() => Promise.resolve(ZeroAddress.replace("0x0", "0x1"))), + getMainRoot: jest.fn(() => Promise.resolve(1n)), + treeDepths: jest.fn(() => Promise.resolve([1, 2, 3])), + extContracts: jest.fn(() => Promise.resolve({ messageAq: ZeroAddress })), + stateMerged: jest.fn(() => Promise.resolve(true)), + coordinatorPubKey: jest.fn(() => + Promise.resolve({ + x: 21424602586933317770306541383681754745261216801634012235464162098738462892814n, + y: 11917647526382221762393892566678210317414189429046519403585863973939713533473n, + }), + ), + }; + + defaultProofGenerator = { + generateMpProofs: jest.fn(() => Promise.resolve([1])), + generateTallyProofs: jest.fn(() => Promise.resolve({ proofs: [1], tallyData: {} })), + }; + + (defaultCryptoService.decrypt as jest.Mock) = jest.fn( + () => "macisk.6d5efa8ebc6f7a6ee3e9bf573346af2df29b007b29ef420c030aa4a7f3410182", + ); + + (Deployment.getInstance as jest.Mock).mockReturnValue(defaultDeploymentService); + + (ProofGenerator as unknown as jest.Mock).mockReturnValue(defaultProofGenerator); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ProofGenerator.prepareState = jest.fn(() => + Promise.resolve({ + polls: new Map([[1n, {}]]), + }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should throw error if state is not merged yet", async () => { + mockContract.stateMerged.mockResolvedValue(false); + + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate(defaultArgs)).rejects.toThrow(ErrorCodes.NOT_MERGED_STATE_TREE); + }); + + test("should throw error if private key is wrong", async () => { + const keypair = new Keypair(new PrivKey(0n)); + mockContract.coordinatorPubKey.mockResolvedValue(keypair.pubKey.asContractParam()); + + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate(defaultArgs)).rejects.toThrow(ErrorCodes.PRIVATE_KEY_MISMATCH); + }); + + test("should throw error if there is no any poll", async () => { + mockContract.getMainRoot.mockResolvedValue(0n); + + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate(defaultArgs)).rejects.toThrow(ErrorCodes.NOT_MERGED_MESSAGE_TREE); + }); + + test("should throw error if poll is not found", async () => { + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate({ ...defaultArgs, poll: 2 })).rejects.toThrow(ErrorCodes.POLL_NOT_FOUND); + }); + + test("should throw error if poll is not found in maci contract", async () => { + mockContract.polls.mockResolvedValue(ZeroAddress); + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate({ ...defaultArgs, poll: 2 })).rejects.toThrow(ErrorCodes.POLL_NOT_FOUND); + }); + + test("should throw error if coordinator key cannot be decrypted", async () => { + (defaultCryptoService.decrypt as jest.Mock).mockReturnValue("unknown"); + + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + await expect(service.generate({ ...defaultArgs, encryptedCoordinatorPrivateKey: "unknown" })).rejects.toThrow( + "Cannot convert 0x to a BigInt", + ); + }); + + test("should generate proofs properly for NonQv", async () => { + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + const data = await service.generate(defaultArgs); + + expect(data.processProofs).toHaveLength(1); + expect(data.tallyProofs).toHaveLength(1); + }); + + test("should generate proofs properly for Qv", async () => { + const service = new ProofGeneratorService(defaultCryptoService, fileService); + + const data = await service.generate({ ...defaultArgs, useQuadraticVoting: true }); + + expect(data.processProofs).toHaveLength(1); + expect(data.tallyProofs).toHaveLength(1); + }); +}); diff --git a/packages/coordinator/ts/proof/dto.ts b/packages/coordinator/ts/proof/dto.ts new file mode 100644 index 00000000..d51c99a7 --- /dev/null +++ b/packages/coordinator/ts/proof/dto.ts @@ -0,0 +1,103 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsEthereumAddress, IsInt, IsOptional, IsString, Length, Max, Min } from "class-validator"; + +/** + * Data transfer object for generate proof + */ +export class GenerateProofDto { + /** + * Poll id + */ + @ApiProperty({ + description: "Poll id", + minimum: 0, + type: Number, + }) + @IsInt() + @Min(0) + poll!: number; + + /** + * Maci contract address + */ + @ApiProperty({ + description: "MACI contract address", + type: String, + }) + @IsEthereumAddress() + maciContractAddress!: string; + + /** + * Tally contract address + */ + @ApiProperty({ + description: "Tally contract address", + type: String, + }) + @IsEthereumAddress() + tallyContractAddress!: string; + + /** + * Whether to use Qv or NonQv + */ + @ApiProperty({ + description: "Whether to use quadratic voting or not", + type: Boolean, + }) + @IsBoolean() + useQuadraticVoting!: boolean; + + /** + * Encrypted coordinator private key with RSA public key (see .env.example) + */ + @ApiProperty({ + description: "Encrypted coordinator private key with RSA public key (see README.md)", + minimum: 1, + maximum: 1024, + type: String, + }) + @IsString() + @Length(1, 1024) + encryptedCoordinatorPrivateKey!: string; + + /** + * Start block for event processing + */ + @ApiProperty({ + description: "Start block for event parsing", + minimum: 0, + type: Number, + }) + @IsInt() + @Min(0) + @IsOptional() + startBlock?: number; + + /** + * End block for event processing + */ + @ApiProperty({ + description: "End block for event parsing", + minimum: 0, + type: Number, + }) + @IsInt() + @Min(0) + @IsOptional() + endBlock?: number; + + /** + * Blocks per batch for event processing + */ + @ApiProperty({ + description: "Blocks per batch for event parsing", + minimum: 1, + maximum: 1000, + type: Number, + }) + @IsInt() + @Min(1) + @Max(1000) + @IsOptional() + blocksPerBatch?: number; +} diff --git a/packages/coordinator/ts/proof/proof.controller.ts b/packages/coordinator/ts/proof/proof.controller.ts new file mode 100644 index 00000000..8fcba0f0 --- /dev/null +++ b/packages/coordinator/ts/proof/proof.controller.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { Body, Controller, Get, HttpException, HttpStatus, Logger, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiBody, ApiResponse, ApiTags } from "@nestjs/swagger"; + +import type { IGenerateData } from "./types"; +import type { IGetPublicKeyData } from "../file/types"; + +import { AccountSignatureGuard, Public } from "../auth/AccountSignatureGuard.service"; +import { FileService } from "../file/file.service"; + +import { GenerateProofDto } from "./dto"; +import { ProofGeneratorService } from "./proof.service"; + +@ApiTags("v1/proof") +@ApiBearerAuth() +@Controller("v1/proof") +@UseGuards(AccountSignatureGuard) +export class ProofController { + /** + * Logger + */ + private readonly logger = new Logger(ProofController.name); + + /** + * Initialize ProofController + * + * @param proofGeneratorService - proof generator service + * @param fileService - file service + */ + constructor( + private readonly proofGeneratorService: ProofGeneratorService, + private readonly fileService: FileService, + ) {} + + /** + * Generate proofs api method + * + * @param args - generate proof dto + * @returns generated proofs and tally data + */ + @ApiBody({ type: GenerateProofDto }) + @ApiResponse({ status: HttpStatus.CREATED, description: "The proofs have been successfully generated" }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: "Forbidden" }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: "BadRequest" }) + @Post("generate") + async generate(@Body() args: GenerateProofDto): Promise { + return this.proofGeneratorService.generate(args).catch((error: Error) => { + this.logger.error(`Error:`, error); + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + }); + } + + /** + * Get RSA public key for authorization setup + * + * @returns RSA public key + */ + @ApiResponse({ status: HttpStatus.OK, description: "Public key was successfully returned" }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: "BadRequest" }) + @Public() + @Get("publicKey") + async getPublicKey(): Promise { + return this.fileService.getPublicKey().catch((error: Error) => { + this.logger.error(`Error:`, error); + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + }); + } +} diff --git a/packages/coordinator/ts/proof/proof.gateway.ts b/packages/coordinator/ts/proof/proof.gateway.ts new file mode 100644 index 00000000..eca88387 --- /dev/null +++ b/packages/coordinator/ts/proof/proof.gateway.ts @@ -0,0 +1,77 @@ +import { Logger, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer, WsException } from "@nestjs/websockets"; +import { IGenerateProofsBatchData, type Proof, type TallyData } from "maci-contracts"; + +import type { Server } from "socket.io"; + +import { AccountSignatureGuard } from "../auth/AccountSignatureGuard.service"; + +import { GenerateProofDto } from "./dto"; +import { ProofGeneratorService } from "./proof.service"; +import { EProofGenerationEvents } from "./types"; + +/** + * ProofGateway is responsible for websockets integration between client and ProofGeneratorService. + */ +@WebSocketGateway({ + cors: { + origin: process.env.COORDINATOR_ALLOWED_ORIGINS?.split(","), + }, +}) +@UseGuards(AccountSignatureGuard) +export class ProofGateway { + /** + * Logger + */ + private readonly logger = new Logger(ProofGateway.name); + + /** + * Websocket server + */ + @WebSocketServer() + server!: Server; + + /** + * Initialize ProofGateway + * + * @param proofGeneratorService - proof generator service + */ + constructor(private readonly proofGeneratorService: ProofGeneratorService) {} + + /** + * Generate proofs api method. + * Events: + * 1. EProofGenerationEvents.START - trigger method call + * 2. EProofGenerationEvents.PROGRESS - returns generated proofs with batch info + * 3. EProofGenerationEvents.FINISH - returns generated proofs and tally data when available + * 4. EProofGenerationEvents.ERROR - triggered when exception is thrown + * + * @param args - generate proof dto + */ + @SubscribeMessage(EProofGenerationEvents.START) + @UsePipes( + new ValidationPipe({ + transform: true, + exceptionFactory(validationErrors) { + return new WsException(validationErrors); + }, + }), + ) + async generate( + @MessageBody() + data: GenerateProofDto, + ): Promise { + await this.proofGeneratorService.generate(data, { + onBatchComplete: (result: IGenerateProofsBatchData) => { + this.server.emit(EProofGenerationEvents.PROGRESS, result); + }, + onComplete: (proofs: Proof[], tallyData?: TallyData) => { + this.server.emit(EProofGenerationEvents.FINISH, { proofs, tallyData }); + }, + onFail: (error: Error) => { + this.logger.error(`Error:`, error); + this.server.emit(EProofGenerationEvents.ERROR, { message: error.message }); + }, + }); + } +} diff --git a/packages/coordinator/ts/proof/proof.module.ts b/packages/coordinator/ts/proof/proof.module.ts new file mode 100644 index 00000000..4f770850 --- /dev/null +++ b/packages/coordinator/ts/proof/proof.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; + +import { CryptoModule } from "../crypto/crypto.module"; +import { FileModule } from "../file/file.module"; + +import { ProofController } from "./proof.controller"; +import { ProofGateway } from "./proof.gateway"; +import { ProofGeneratorService } from "./proof.service"; + +@Module({ + imports: [FileModule, CryptoModule], + controllers: [ProofController], + providers: [ProofGeneratorService, ProofGateway], +}) +export class ProofModule {} diff --git a/packages/coordinator/ts/proof/proof.service.ts b/packages/coordinator/ts/proof/proof.service.ts new file mode 100644 index 00000000..02399a53 --- /dev/null +++ b/packages/coordinator/ts/proof/proof.service.ts @@ -0,0 +1,173 @@ +import { Logger, Injectable } from "@nestjs/common"; +import { ZeroAddress } from "ethers"; +import hre from "hardhat"; +import { + Deployment, + EContracts, + ProofGenerator, + type Poll, + type MACI, + type AccQueue, + type IGenerateProofsOptions, +} from "maci-contracts"; +import { Keypair, PrivKey, PubKey } from "maci-domainobjs"; + +import path from "path"; + +import type { IGenerateArgs, IGenerateData } from "./types"; + +import { ErrorCodes } from "../common"; +import { CryptoService } from "../crypto/crypto.service"; +import { FileService } from "../file/file.service"; + +/** + * ProofGeneratorService is responsible for generating message processing and tally proofs. + */ +@Injectable() +export class ProofGeneratorService { + /** + * Deployment helper + */ + private readonly deployment: Deployment; + + /** + * Logger + */ + private readonly logger: Logger; + + /** + * Proof generator initialization + */ + constructor( + private readonly cryptoService: CryptoService, + private readonly fileService: FileService, + ) { + this.deployment = Deployment.getInstance(hre); + this.deployment.setHre(hre); + this.fileService = fileService; + this.logger = new Logger(ProofGeneratorService.name); + } + + /** + * Generate proofs for message processing and tally + * + * @param args - generate proofs arguments + * @returns - generated proofs for message processing and tally + */ + async generate( + { + poll, + maciContractAddress, + tallyContractAddress, + useQuadraticVoting, + encryptedCoordinatorPrivateKey, + startBlock, + endBlock, + blocksPerBatch, + }: IGenerateArgs, + options?: IGenerateProofsOptions, + ): Promise { + try { + const maciContract = await this.deployment.getContract({ + name: EContracts.MACI, + address: maciContractAddress, + }); + + const [signer, pollAddress] = await Promise.all([this.deployment.getDeployer(), maciContract.polls(poll)]); + + if (pollAddress.toLowerCase() === ZeroAddress.toLowerCase()) { + this.logger.error(`Error: ${ErrorCodes.POLL_NOT_FOUND}, Poll ${poll} not found`); + throw new Error(ErrorCodes.POLL_NOT_FOUND); + } + + const pollContract = await this.deployment.getContract({ name: EContracts.Poll, address: pollAddress }); + const [{ messageAq: messageAqAddress }, coordinatorPublicKey, isStateAqMerged, messageTreeDepth] = + await Promise.all([ + pollContract.extContracts(), + pollContract.coordinatorPubKey(), + pollContract.stateMerged(), + pollContract.treeDepths().then((depths) => Number(depths[2])), + ]); + const messageAq = await this.deployment.getContract({ + name: EContracts.AccQueue, + address: messageAqAddress, + }); + + if (!isStateAqMerged) { + this.logger.error(`Error: ${ErrorCodes.NOT_MERGED_STATE_TREE}, state tree is not merged`); + throw new Error(ErrorCodes.NOT_MERGED_STATE_TREE); + } + + const mainRoot = await messageAq.getMainRoot(messageTreeDepth.toString()); + + if (mainRoot.toString() === "0") { + this.logger.error(`Error: ${ErrorCodes.NOT_MERGED_MESSAGE_TREE}, message tree is not merged`); + throw new Error(ErrorCodes.NOT_MERGED_MESSAGE_TREE); + } + + const { privateKey } = await this.fileService.getPrivateKey(); + const maciPrivateKey = PrivKey.deserialize( + this.cryptoService.decrypt(privateKey, encryptedCoordinatorPrivateKey), + ); + const coordinatorKeypair = new Keypair(maciPrivateKey); + const publicKey = new PubKey([ + BigInt(coordinatorPublicKey.x.toString()), + BigInt(coordinatorPublicKey.y.toString()), + ]); + + if (!coordinatorKeypair.pubKey.equals(publicKey)) { + this.logger.error(`Error: ${ErrorCodes.PRIVATE_KEY_MISMATCH}, wrong private key`); + throw new Error(ErrorCodes.PRIVATE_KEY_MISMATCH); + } + + const outputDir = path.resolve("./proofs"); + + const maciState = await ProofGenerator.prepareState({ + maciContract, + pollContract, + messageAq, + maciPrivateKey, + coordinatorKeypair, + pollId: poll, + signer, + outputDir, + options: { + startBlock, + endBlock, + blocksPerBatch, + }, + }); + + const foundPoll = maciState.polls.get(BigInt(poll)); + + if (!foundPoll) { + this.logger.error(`Error: ${ErrorCodes.POLL_NOT_FOUND}, Poll ${poll} not found in maci state`); + throw new Error(ErrorCodes.POLL_NOT_FOUND); + } + + const proofGenerator = new ProofGenerator({ + poll: foundPoll, + maciContractAddress, + tallyContractAddress, + tally: this.fileService.getZkeyFilePaths(process.env.COORDINATOR_TALLY_ZKEY_NAME!, useQuadraticVoting), + mp: this.fileService.getZkeyFilePaths(process.env.COORDINATOR_MESSAGE_PROCESS_ZKEY_NAME!, useQuadraticVoting), + rapidsnark: process.env.COORDINATOR_RAPIDSNARK_EXE, + outputDir, + tallyOutputFile: path.resolve("./tally.json"), + useQuadraticVoting, + }); + + const processProofs = await proofGenerator.generateMpProofs(options); + const { proofs: tallyProofs, tallyData } = await proofGenerator.generateTallyProofs(hre.network, options); + + return { + processProofs, + tallyProofs, + tallyData, + }; + } catch (error) { + options?.onFail?.(error as Error); + throw error; + } + } +} diff --git a/packages/coordinator/ts/proof/types.ts b/packages/coordinator/ts/proof/types.ts new file mode 100644 index 00000000..159433d3 --- /dev/null +++ b/packages/coordinator/ts/proof/types.ts @@ -0,0 +1,92 @@ +import type { TallyData } from "maci-cli"; +import type { Proof } from "maci-contracts"; + +/** + * WS events for proof generation + */ +export enum EProofGenerationEvents { + START = "start-generation", + PROGRESS = "progress-generation", + FINISH = "finish-generation", + ERROR = "exception", +} + +/** + * Interface that represents generate proofs arguments + */ +export interface IGenerateArgs { + /** + * Poll id + */ + poll: number; + + /** + * Maci contract address + */ + maciContractAddress: string; + + /** + * Tally contract address + */ + tallyContractAddress: string; + + /** + * Whether to use Qv or NonQv + */ + useQuadraticVoting: boolean; + + /** + * Encrypted coordinator private key with RSA public key (see .env.example) + */ + encryptedCoordinatorPrivateKey: string; + + /** + * Start block for event processing + */ + startBlock?: number; + + /** + * End block for event processing + */ + endBlock?: number; + + /** + * Blocks per batch for event processing + */ + blocksPerBatch?: number; +} + +/** + * Interface that represents generated proofs data + */ +export interface IGenerateData { + /** + * Message processing proofs + */ + processProofs: Proof[]; + + /** + * Tally proofs + */ + tallyProofs: Proof[]; + + /** + * TallyData + */ + tallyData: TallyData; +} + +/** + * Interface that represents zkey filepaths + */ +export interface IGetZkeyFilesData { + /** + * Zkey filepath + */ + zkey: string; + + /** + * Wasm filepath + */ + wasm: string; +} diff --git a/packages/coordinator/ts/subgraph/__tests__/subgraph.controller.test.ts b/packages/coordinator/ts/subgraph/__tests__/subgraph.controller.test.ts new file mode 100644 index 00000000..c17909aa --- /dev/null +++ b/packages/coordinator/ts/subgraph/__tests__/subgraph.controller.test.ts @@ -0,0 +1,66 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import type { IDeploySubgraphArgs, IDeploySubgraphReturn } from "../types"; + +import { ESupportedNetworks } from "../../common"; +import { SubgraphController } from "../subgraph.controller"; +import { SubgraphService } from "../subgraph.service"; + +describe("SubgraphController", () => { + let subgraphController: SubgraphController; + + const defaultSubgraphDeployArgs: IDeploySubgraphArgs = { + maciContractAddress: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + startBlock: 0, + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + name: "subgraph", + tag: "v0.0.1", + }; + + const defaultSubgraphDeployData: IDeploySubgraphReturn = { + url: "url", + }; + + const mockSubgraphService = { + deploy: jest.fn(), + }; + + beforeEach(async () => { + const app = await Test.createTestingModule({ + controllers: [SubgraphController], + }) + .useMocker((token) => { + if (token === SubgraphService) { + mockSubgraphService.deploy.mockResolvedValue(defaultSubgraphDeployData); + + return mockSubgraphService; + } + + return jest.fn(); + }) + .compile(); + + subgraphController = app.get(SubgraphController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("v1/subgraph/deploy", () => { + test("should return deployed subgraph url properly", async () => { + const data = await subgraphController.deploy(defaultSubgraphDeployArgs); + expect(data).toStrictEqual(defaultSubgraphDeployData); + }); + + test("should throw an error if proof generation is failed", async () => { + const error = new Error("error"); + mockSubgraphService.deploy.mockRejectedValue(error); + + await expect(subgraphController.deploy(defaultSubgraphDeployArgs)).rejects.toThrow( + new HttpException(error.message, HttpStatus.BAD_REQUEST), + ); + }); + }); +}); diff --git a/packages/coordinator/ts/subgraph/__tests__/subgraph.gateway.test.ts b/packages/coordinator/ts/subgraph/__tests__/subgraph.gateway.test.ts new file mode 100644 index 00000000..884cb7ef --- /dev/null +++ b/packages/coordinator/ts/subgraph/__tests__/subgraph.gateway.test.ts @@ -0,0 +1,106 @@ +import { Test } from "@nestjs/testing"; +import { Server } from "socket.io"; + +import { ESupportedNetworks } from "../../common"; +import { SubgraphGateway } from "../subgraph.gateway"; +import { SubgraphService } from "../subgraph.service"; +import { + EProgressStep, + ESubgraphEvents, + TOTAL_STEPS, + type IDeploySubgraphArgs, + type IDeploySubgraphReturn, + type ISubgraphWsHooks, +} from "../types"; + +describe("SubgraphGateway", () => { + let gateway: SubgraphGateway; + + const defaultSubgraphDeployArgs: IDeploySubgraphArgs = { + maciContractAddress: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + startBlock: 0, + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + name: "subgraph", + tag: "v0.0.1", + }; + + const defaultSubgraphDeployData: IDeploySubgraphReturn = { + url: "https://localhost:3000", + }; + + const mockSubgraphService = { + deploy: jest.fn(), + }; + + const mockEmit = jest.fn(); + + beforeEach(async () => { + const testModule = await Test.createTestingModule({ providers: [SubgraphGateway] }) + .useMocker((token) => { + if (token === SubgraphService) { + mockSubgraphService.deploy.mockImplementation((_, options?: ISubgraphWsHooks) => { + options?.onProgress({ current: EProgressStep.SCHEMA, total: TOTAL_STEPS }); + options?.onProgress({ current: EProgressStep.NETWORK, total: TOTAL_STEPS }); + options?.onProgress({ current: EProgressStep.TEMPLATE, total: TOTAL_STEPS }); + options?.onProgress({ current: EProgressStep.CODEGEN, total: TOTAL_STEPS }); + options?.onProgress({ current: EProgressStep.BUILD, total: TOTAL_STEPS }); + options?.onProgress({ current: EProgressStep.DEPLOY, total: TOTAL_STEPS }); + + options?.onSuccess(defaultSubgraphDeployData.url); + options?.onFail(new Error("error")); + }); + + return mockSubgraphService; + } + + return jest.fn(); + }) + .compile(); + + gateway = testModule.get(SubgraphGateway); + + gateway.server = { emit: mockEmit } as unknown as Server; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should be defined", () => { + expect(gateway).toBeDefined(); + }); + + test("should start subgraph deployment properly", async () => { + await gateway.deploy(defaultSubgraphDeployArgs); + + expect(mockEmit).toHaveBeenCalledTimes(8); + expect(mockEmit).toHaveBeenNthCalledWith(1, ESubgraphEvents.PROGRESS, { + current: EProgressStep.SCHEMA, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(2, ESubgraphEvents.PROGRESS, { + current: EProgressStep.NETWORK, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(3, ESubgraphEvents.PROGRESS, { + current: EProgressStep.TEMPLATE, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(4, ESubgraphEvents.PROGRESS, { + current: EProgressStep.CODEGEN, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(5, ESubgraphEvents.PROGRESS, { + current: EProgressStep.BUILD, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(6, ESubgraphEvents.PROGRESS, { + current: EProgressStep.DEPLOY, + total: TOTAL_STEPS, + }); + expect(mockEmit).toHaveBeenNthCalledWith(7, ESubgraphEvents.FINISH, { + url: defaultSubgraphDeployData.url, + }); + expect(mockEmit).toHaveBeenNthCalledWith(8, ESubgraphEvents.ERROR, { message: "error" }); + }); +}); diff --git a/packages/coordinator/ts/subgraph/__tests__/subgraph.service.test.ts b/packages/coordinator/ts/subgraph/__tests__/subgraph.service.test.ts new file mode 100644 index 00000000..3608784f --- /dev/null +++ b/packages/coordinator/ts/subgraph/__tests__/subgraph.service.test.ts @@ -0,0 +1,90 @@ +import dotenv from "dotenv"; + +import childProcess from "child_process"; +import fs from "fs"; + +import type { IDeploySubgraphArgs } from "../types"; + +import { ErrorCodes, ESupportedNetworks } from "../../common"; +import { transformToString } from "../dto"; +import { SubgraphService } from "../subgraph.service"; + +dotenv.config(); + +jest.mock("child_process", (): unknown => ({ + ...jest.requireActual("child_process"), + execFile: jest.fn(), +})); + +jest.mock("fs", (): unknown => ({ + ...jest.requireActual("fs"), + promises: { + writeFile: jest.fn(), + }, +})); + +jest.mock("util", (): unknown => ({ + promisify: jest.fn((func: jest.Mock) => func), +})); + +describe("SubgraphService", () => { + const defaultArgs: IDeploySubgraphArgs = { + maciContractAddress: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e", + startBlock: 0, + network: ESupportedNetworks.OPTIMISM_SEPOLIA, + name: "subgraph", + tag: "v0.0.1", + }; + + beforeEach(() => { + (childProcess.execFile as unknown as jest.Mock).mockResolvedValue({ + stdout: "https://subgraph.com https://test.com", + }); + + (fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should throw error if deploy is failed", async () => { + (childProcess.execFile as unknown as jest.Mock).mockRejectedValue(new Error()); + + const service = new SubgraphService(); + + await expect(service.deploy(defaultArgs)).rejects.toThrow(ErrorCodes.SUBGRAPH_DEPLOY); + }); + + test("should throw error if network is invalid", async () => { + (childProcess.execFile as unknown as jest.Mock).mockRejectedValue(new Error()); + + const service = new SubgraphService(); + + await expect(service.deploy({ ...defaultArgs, network: "unknown" as ESupportedNetworks })).rejects.toThrow( + ErrorCodes.SUBGRAPH_DEPLOY, + ); + }); + + test("should throw error if there is no subgraph url", async () => { + (childProcess.execFile as unknown as jest.Mock).mockResolvedValue({ stdout: "" }); + + const service = new SubgraphService(); + + await expect(service.deploy(defaultArgs)).rejects.toThrow(ErrorCodes.SUBGRAPH_DEPLOY); + }); + + test("should return deployed subgraph url properly", async () => { + const service = new SubgraphService(); + + const { url } = await service.deploy(defaultArgs); + + expect(url).toBe("https://test.com"); + }); + + test("should transform value to string properly", () => { + const value = transformToString({ value: "Network" }); + + expect(value).toBe("network"); + }); +}); diff --git a/packages/coordinator/ts/subgraph/dto.ts b/packages/coordinator/ts/subgraph/dto.ts new file mode 100644 index 00000000..449048b1 --- /dev/null +++ b/packages/coordinator/ts/subgraph/dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsEnum, IsEthereumAddress, IsInt, IsString, Matches, MaxLength, Min, MinLength } from "class-validator"; + +import { ESupportedNetworks } from "../common"; + +export const transformToString = ({ value }: { value: string }): string => value.toLowerCase(); + +/** + * Data transfer object for deploying subgraph + */ +export class DeploySubgraphDto { + /** + * MACI contract address + */ + @ApiProperty({ + description: "MACI contract address", + type: String, + }) + @IsEthereumAddress() + maciContractAddress!: string; + + /** + * Start block for event processing + */ + @ApiProperty({ + description: "Start block for event parsing", + minimum: 0, + type: Number, + }) + @IsInt() + @Min(0) + startBlock!: number; + + /** + * Network CLI name + */ + @ApiProperty({ + description: "Network CLI name (https://thegraph.com/docs/en/developing/supported-networks/)", + enum: ESupportedNetworks, + }) + @IsEnum(ESupportedNetworks) + @Transform(transformToString) + network!: ESupportedNetworks; + + /** + * Subgraph name + */ + @ApiProperty({ + description: "Subgraph name", + type: String, + }) + @IsString() + @MinLength(3) + @MaxLength(50) + name!: string; + + /** + * Version tag (ex: v0.0.1) + */ + @ApiProperty({ + description: "Version tag (ex: v0.0.1)", + type: String, + }) + @IsString() + @Matches(/^v\d+\.\d+\.\d+$/) + tag!: string; +} diff --git a/packages/coordinator/ts/subgraph/subgraph.controller.ts b/packages/coordinator/ts/subgraph/subgraph.controller.ts new file mode 100644 index 00000000..c329133c --- /dev/null +++ b/packages/coordinator/ts/subgraph/subgraph.controller.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { Body, Controller, HttpException, HttpStatus, Logger, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiBody, ApiResponse, ApiTags } from "@nestjs/swagger"; + +import type { IDeploySubgraphReturn } from "./types"; + +import { DeploySubgraphDto } from "./dto"; +import { SubgraphService } from "./subgraph.service"; + +@ApiTags("v1/subgraph") +@ApiBearerAuth() +@Controller("v1/subgraph") +// @UseGuards(AccountSignatureGuard) +export class SubgraphController { + /** + * Logger + */ + private readonly logger = new Logger(SubgraphController.name); + + /** + * Initialize SubgraphController + * + * @param subgraphService - subgraph service + */ + constructor(private readonly subgraphService: SubgraphService) {} + + /** + * Generate proofs api method + * + * @param args - generate proof dto + * @returns generated proofs and tally data + */ + @ApiBody({ type: DeploySubgraphDto }) + @ApiResponse({ status: HttpStatus.CREATED, description: "The subgraph was successfully deployed" }) + @ApiResponse({ status: HttpStatus.FORBIDDEN, description: "Forbidden" }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: "BadRequest" }) + @Post("deploy") + async deploy(@Body() args: DeploySubgraphDto): Promise { + return this.subgraphService.deploy(args).catch((error: Error) => { + this.logger.error(`Error:`, error); + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + }); + } +} diff --git a/packages/coordinator/ts/subgraph/subgraph.gateway.ts b/packages/coordinator/ts/subgraph/subgraph.gateway.ts new file mode 100644 index 00000000..ab1cbaec --- /dev/null +++ b/packages/coordinator/ts/subgraph/subgraph.gateway.ts @@ -0,0 +1,76 @@ +import { Logger, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer, WsException } from "@nestjs/websockets"; + +import type { Server } from "socket.io"; + +import { AccountSignatureGuard } from "../auth/AccountSignatureGuard.service"; + +import { DeploySubgraphDto } from "./dto"; +import { SubgraphService } from "./subgraph.service"; +import { ESubgraphEvents, IProgressArgs } from "./types"; + +/** + * SubgraphGateway is responsible for websockets integration between client and SubgraphService. + */ +@WebSocketGateway({ + cors: { + origin: process.env.COORDINATOR_ALLOWED_ORIGINS?.split(","), + }, +}) +@UseGuards(AccountSignatureGuard) +export class SubgraphGateway { + /** + * Logger + */ + private readonly logger = new Logger(SubgraphGateway.name); + + /** + * Websocket server + */ + @WebSocketServer() + server!: Server; + + /** + * Initialize SubgraphGateway + * + * @param subgraphService - subgraph service + */ + constructor(private readonly subgraphService: SubgraphService) {} + + /** + * Generate proofs api method. + * Events: + * 1. ESubgraphEvents.START - trigger method call + * 2. ESubgraphEvents.PROGRESS - returns deployed steps info + * 3. ESubgraphEvents.FINISH - returns result of deploy operation + * 4. ESubgraphEvents.ERROR - triggered when exception is thrown + * + * @param args - generate proof dto + */ + @SubscribeMessage(ESubgraphEvents.START) + @UsePipes( + new ValidationPipe({ + transform: true, + exceptionFactory(validationErrors) { + return new WsException(validationErrors); + }, + }), + ) + async deploy( + @MessageBody() + data: DeploySubgraphDto, + ): Promise { + await this.subgraphService.deploy(data, { + onProgress: (result: IProgressArgs) => { + this.server.emit(ESubgraphEvents.PROGRESS, result); + }, + onSuccess: (url: string) => { + this.server.emit(ESubgraphEvents.FINISH, { url }); + }, + onFail: (error: Error) => { + this.logger.error(`Error:`, error); + this.server.emit(ESubgraphEvents.ERROR, { message: error.message }); + }, + }); + } +} diff --git a/packages/coordinator/ts/subgraph/subgraph.module.ts b/packages/coordinator/ts/subgraph/subgraph.module.ts new file mode 100644 index 00000000..a569d98e --- /dev/null +++ b/packages/coordinator/ts/subgraph/subgraph.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; + +import { CryptoModule } from "../crypto/crypto.module"; +import { FileModule } from "../file/file.module"; + +import { SubgraphController } from "./subgraph.controller"; +import { SubgraphGateway } from "./subgraph.gateway"; +import { SubgraphService } from "./subgraph.service"; + +@Module({ + imports: [FileModule, CryptoModule], + controllers: [SubgraphController], + providers: [SubgraphService, SubgraphGateway], +}) +export class SubgraphModule {} diff --git a/packages/coordinator/ts/subgraph/subgraph.service.ts b/packages/coordinator/ts/subgraph/subgraph.service.ts new file mode 100644 index 00000000..3090de4c --- /dev/null +++ b/packages/coordinator/ts/subgraph/subgraph.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import childProcess from "child_process"; +import fs from "fs"; +import path from "path"; +import { promisify } from "util"; + +import { ErrorCodes, ESupportedNetworks } from "../common"; + +import { + EProgressStep, + TOTAL_STEPS, + type IDeploySubgraphArgs, + type IDeploySubgraphReturn, + type ISubgraphWsHooks, +} from "./types"; + +const execFile = promisify(childProcess.execFile); + +const URL_REGEX = /(https?:\/\/[^\s]+)/g; + +/** + * SubgraphService is responsible for deploying subgraph. + */ +@Injectable() +export class SubgraphService { + /** + * Logger + */ + private readonly logger = new Logger(SubgraphService.name); + + /** + * Generate proofs for message processing and tally + * + * @param args - deploy subgraph arguments + * @param options - ws hooks + * @returns - deployed subgraph url + * @throws error if deploy is not successful + */ + async deploy(args: IDeploySubgraphArgs, options?: ISubgraphWsHooks): Promise { + try { + if (!Object.values(ESupportedNetworks).includes(args.network)) { + throw new Error("Invalid network"); + } + + const subgraphManifestPath = path.resolve(process.env.SUBGRAPH_FOLDER!, "subgraph.yaml"); + + await execFile("cp", [ + path.resolve(process.env.SUBGRAPH_FOLDER!, "schemas/schema.v1.graphql"), + path.resolve(process.env.SUBGRAPH_FOLDER!, "schema.graphql"), + ]); + + options?.onProgress({ current: EProgressStep.SCHEMA, total: TOTAL_STEPS }); + + await fs.promises.writeFile( + path.resolve(process.env.SUBGRAPH_FOLDER!, `config/${args.network}.json`), + `${JSON.stringify( + { + network: args.network, + maciContractAddress: args.maciContractAddress, + maciContractStartBlock: args.startBlock, + }, + null, + 2, + )}\n`, + { flag: "w+" }, + ); + + options?.onProgress({ current: EProgressStep.NETWORK, total: TOTAL_STEPS }); + + const mustacheOutput = await execFile("mustache", [ + path.resolve(process.env.SUBGRAPH_FOLDER!, `config/${args.network}.json`), + path.resolve(process.env.SUBGRAPH_FOLDER!, "templates/subgraph.template.yaml"), + ]); + await fs.promises.writeFile(subgraphManifestPath, mustacheOutput.stdout, { flag: "w+" }); + + options?.onProgress({ current: EProgressStep.TEMPLATE, total: TOTAL_STEPS }); + + await execFile("graph", [ + "codegen", + subgraphManifestPath, + "--output-dir", + path.resolve(process.env.SUBGRAPH_FOLDER!, "generated"), + ]); + + options?.onProgress({ current: EProgressStep.CODEGEN, total: TOTAL_STEPS }); + + await execFile("graph", [ + "build", + subgraphManifestPath, + "--output-dir", + path.resolve(process.env.SUBGRAPH_FOLDER!, "build"), + ]); + + options?.onProgress({ current: EProgressStep.BUILD, total: TOTAL_STEPS }); + + const deployOutput = await execFile("graph", [ + "deploy", + process.env.SUBGRAPH_NAME!, + subgraphManifestPath, + "--node", + process.env.SUBGRAPH_PROVIDER_URL!, + "--deploy-key", + process.env.SUBGRAPH_DEPLOY_KEY!, + "--version-label", + args.tag, + ]); + options?.onProgress({ current: EProgressStep.DEPLOY, total: TOTAL_STEPS }); + this.logger.log(deployOutput.stdout); + + const url = deployOutput.stdout.match(URL_REGEX)?.[1]?.trim().replace("\u001b[0m", ""); + + if (!url) { + throw new Error(ErrorCodes.SUBGRAPH_DEPLOY); + } + + options?.onSuccess(url); + + return { url }; + } catch (error) { + this.logger.error("Error: ", error); + options?.onFail(error as Error); + throw new Error(ErrorCodes.SUBGRAPH_DEPLOY); + } + } +} diff --git a/packages/coordinator/ts/subgraph/types.ts b/packages/coordinator/ts/subgraph/types.ts new file mode 100644 index 00000000..83ff1cb1 --- /dev/null +++ b/packages/coordinator/ts/subgraph/types.ts @@ -0,0 +1,106 @@ +import type { ESupportedNetworks } from "../common"; + +/** + * WS events for subgraph + */ +export enum ESubgraphEvents { + START = "start-deploy", + PROGRESS = "progress-deploy", + FINISH = "finish-deploy", + ERROR = "exception", +} + +/** + * Interface that represents deploy subgraph args + */ +export interface IDeploySubgraphArgs { + /** + * MACI contract address + */ + maciContractAddress: string; + + /** + * Start block + */ + startBlock: number; + + /** + * Network + */ + network: ESupportedNetworks; + + /** + * Subgraph name + */ + name: string; + + /** + * Version tag + */ + tag: string; +} + +/** + * Interface that represents deploy subgraph return data + */ +export interface IDeploySubgraphReturn { + /** + * Deployed subgraph url + */ + url: string; +} + +/** + * Interface that represents progress data + */ +export interface IProgressArgs { + /** + * Current step + */ + current: EProgressStep; + + /** + * Total steps + */ + total: number; +} + +/** + * Progress step + */ +export enum EProgressStep { + SCHEMA, + NETWORK, + TEMPLATE, + CODEGEN, + BUILD, + DEPLOY, +} + +export const TOTAL_STEPS = Object.keys(EProgressStep).length / 2; + +/** + * Interface that represents websocket hooks for subgraph service + */ +export interface ISubgraphWsHooks { + /** + * Websockets progress hook + * + * @param params - progress params + */ + onProgress: ({ current, total }: IProgressArgs) => void; + + /** + * Websockets error hook + * + * @param error - error + */ + onFail: (error: Error) => void; + + /** + * Websockets success hook + * + * @param url - subgraph url + */ + onSuccess: (url: string) => void; +} diff --git a/packages/coordinator/tsconfig.build.json b/packages/coordinator/tsconfig.build.json new file mode 100644 index 00000000..792baa42 --- /dev/null +++ b/packages/coordinator/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowJs": true + }, + "include": ["./ts", "./scripts", "./tests"], + "files": ["./hardhat.config.js"] +} diff --git a/packages/coordinator/tsconfig.json b/packages/coordinator/tsconfig.json new file mode 100644 index 00000000..994b405e --- /dev/null +++ b/packages/coordinator/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "include": ["./ts", "./scripts", "./tests"], + "files": ["hardhat.config.js"] +} diff --git a/.env.example b/packages/interface/.env.example similarity index 100% rename from .env.example rename to packages/interface/.env.example diff --git a/packages/interface/.eslintignore b/packages/interface/.eslintignore new file mode 100644 index 00000000..8e8d3bd3 --- /dev/null +++ b/packages/interface/.eslintignore @@ -0,0 +1,15 @@ +node_modules +dist +.next +coverage +build +typechain-types +.eslintrc.js +commitlint.config.js +subgraph/generated +public/mockServiceWorker.js +zkeys +playwright-report +test-results +next.config.js +playwright.config.ts diff --git a/packages/interface/.eslintrc.js b/packages/interface/.eslintrc.js new file mode 100644 index 00000000..ed9e8f2e --- /dev/null +++ b/packages/interface/.eslintrc.js @@ -0,0 +1,185 @@ +const fs = require("fs"); +const path = require("path"); + +const prettierConfig = fs.readFileSync(path.resolve(__dirname, "../../.prettierrc"), "utf8"); +const prettierOptions = JSON.parse(prettierConfig); +const isProduction = process.env.NODE_ENV === "production"; + +module.exports = { + root: true, + extends: ["../../.eslintrc.js", "plugin:react/recommended", "plugin:playwright/playwright-test"], + plugins: ["json", "prettier", "unused-imports", "import", "@typescript-eslint", "react-hooks"], + parser: "@typescript-eslint/parser", + env: { + browser: true, + node: true, + jest: true, + es2022: true, + }, + settings: { + react: { + version: "18", + }, + "import/resolver": { + typescript: { + project: path.resolve(__dirname, "./tsconfig.json"), + }, + node: { + extensions: [".ts", ".js", ".tsx", ".jsx"], + moduleDirectory: ["node_modules", "src", "playwright"], + }, + }, + }, + parserOptions: { + project: path.resolve(__dirname, "./tsconfig.json"), + sourceType: "module", + typescript: true, + ecmaVersion: 2022, + experimentalDecorators: true, + requireConfigFile: false, + ecmaFeatures: { + classes: true, + impliedStrict: true, + }, + warnOnUnsupportedTypeScriptVersion: true, + }, + reportUnusedDisableDirectives: isProduction, + rules: { + "import/no-cycle": ["error"], + "unused-imports/no-unused-imports": "error", + "import/no-extraneous-dependencies": [ + "error", + { + devDependencies: [ + "**/*.test.ts", + "./src/test-msw.ts", + "./src/test-setup.ts", + "./src/lib/eas/*.ts", + "./playwright/**/*.ts", + "./playwright.config.ts", + "./vitest.config.ts", + ], + }, + ], + "no-debugger": isProduction ? "error" : "off", + "no-console": "error", + "no-underscore-dangle": "error", + "no-redeclare": ["error", { builtinGlobals: true }], + "import/order": [ + "error", + { + groups: ["external", "builtin", "internal", "type", "parent", "sibling", "index", "object"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + warnOnUnassignedImports: true, + "newlines-between": "always", + }, + ], + "prettier/prettier": ["error", prettierOptions], + "import/prefer-default-export": "off", + "import/extensions": ["error", { json: "always" }], + "class-methods-use-this": "off", + "prefer-promise-reject-errors": "off", + "max-classes-per-file": "off", + "no-use-before-define": ["off"], + "no-shadow": "off", + curly: ["error", "all"], + + "@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }], + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/prefer-nullish-coalescing": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/use-unknown-in-catch-callback-variable": "off", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/no-use-before-define": ["error", { functions: false, classes: false }], + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }], + "@typescript-eslint/no-shadow": [ + "error", + { + builtinGlobals: true, + allow: [ + "alert", + "location", + "event", + "history", + "name", + "status", + "Option", + "Image", + "Lock", + "test", + "expect", + "describe", + "beforeAll", + "afterAll", + ], + }, + ], + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], + + "react/jsx-filename-extension": [ + "error", + { + extensions: [".tsx", ".jsx", ".js"], + }, + ], + "react/no-unknown-property": ["error", { ignore: ["tw", "global", "jsx"] }], + "react/jsx-sort-props": [ + "error", + { + callbacksLast: true, + shorthandFirst: true, + ignoreCase: true, + reservedFirst: true, + }, + ], + "react/sort-prop-types": [ + "error", + { + callbacksLast: true, + }, + ], + "react/react-in-jsx-scope": "off", + "react/jsx-boolean-value": "error", + "react/jsx-handler-names": "error", + "react/prop-types": "error", + "react/jsx-no-bind": "error", + "react-hooks/rules-of-hooks": "error", + "react/no-array-index-key": "warn", + "jsx-a11y/no-static-element-interactions": "warn", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/anchor-is-valid": "warn", + "react/jsx-props-no-spreading": "off", + "react/forbid-prop-types": "off", + "react/state-in-constructor": "off", + "react/jsx-fragments": "off", + "react/static-property-placement": ["off"], + "react/jsx-newline": ["error", { prevent: false }], + "jsx-a11y/label-has-associated-control": "off", + "jsx-a11y/label-has-for": "off", + "react/require-default-props": [ + "warn", + { + functions: "defaultArguments", + }, + ], + "react/no-unused-prop-types": "error", + "react/function-component-definition": ["error", { namedComponents: ["arrow-function"] }], + + "playwright/prefer-lowercase-title": "error", + "playwright/prefer-to-be": "error", + "playwright/prefer-to-have-length": "error", + "playwright/prefer-strict-equal": "error", + "playwright/max-nested-describe": ["error", { max: 1 }], + "playwright/no-restricted-matchers": [ + "error", + { + toBeFalsy: "Use `toBe(false)` instead.", + not: null, + }, + ], + }, +}; diff --git a/.npmrc b/packages/interface/.npmrc similarity index 100% rename from .npmrc rename to packages/interface/.npmrc diff --git a/LICENSE b/packages/interface/LICENSE similarity index 100% rename from LICENSE rename to packages/interface/LICENSE diff --git a/packages/interface/README.md b/packages/interface/README.md new file mode 100644 index 00000000..c79fc3fd --- /dev/null +++ b/packages/interface/README.md @@ -0,0 +1,41 @@ +# MACI-RPGF + + + +## Supported Networks + +All networks EAS is deployed to are supported. If a network is not supported, you can follow the EAS documentation to deploy the contracts to the network. + +- https://docs.attest.sh/docs/quick--start/contracts + +#### Mainnets + +- Ethereum +- Optimism +- Base +- Arbitrum One & Nova +- Polygon +- Scroll +- Celo +- Linea + +#### Testnets + +- Sepolia +- Optimism Sepolia +- Base Sepolia +- Polygon Mumbai +- Scroll Sepolia + +### Technical details + +- **EAS** - Projects, profiles, etc are all stored on-chain in Ethereum Attestation Service +- **Batched requests with tRPC** - Multiple requests are batched into one (for example when the frontend requests the metadata for 24 projects they are batched into 1 request) +- **Server-side caching of requests to EAS and IPFS** - Immediately returns the data without calling EAS and locally serving ipfs cids. +- **MACI** - Minimal Anti-Collusion Infrastructure (MACI) is an open-source public good that serves as infrastructure for private on-chain voting, handles the rounds and private voting of the badgeholders. diff --git a/e2e/connectWallet.test.ts b/packages/interface/e2e/connectWallet.test.ts similarity index 96% rename from e2e/connectWallet.test.ts rename to packages/interface/e2e/connectWallet.test.ts index daa501ba..304855ab 100644 --- a/e2e/connectWallet.test.ts +++ b/packages/interface/e2e/connectWallet.test.ts @@ -1,5 +1,7 @@ import { test, expect } from "../playwright/fixtures"; +test.setTimeout(900000); + test.describe("connect wallet", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); diff --git a/next.config.js b/packages/interface/next.config.js similarity index 100% rename from next.config.js rename to packages/interface/next.config.js diff --git a/packages/interface/package.json b/packages/interface/package.json new file mode 100644 index 00000000..69c66a8b --- /dev/null +++ b/packages/interface/package.json @@ -0,0 +1,152 @@ +{ + "name": "maci-platform-interface", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build", + "dev": "next dev", + "lint": "next lint", + "lint:fix": "next lint --fix", + "start": "next start", + "prettier": "prettier -c .", + "prettier:fix": "prettier -w .", + "types": "tsc -p tsconfig.json --noEmit", + "eas:registerSchemas": "npx tsx src/lib/eas/registerSchemas", + "install:chromium": "playwright install chromium", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@ethereum-attestation-service/eas-sdk": "^1.5.0", + "@hookform/resolvers": "^3.3.4", + "@nivo/boxplot": "^0.84.0", + "@nivo/line": "^0.84.0", + "@pinata/sdk": "^2.1.0", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@rainbow-me/rainbowkit": "^2.0.1", + "@rainbow-me/rainbowkit-siwe-next-auth": "^0.4.0", + "@semaphore-protocol/core": "4.0.0-beta.16", + "@semaphore-protocol/data": "4.0.0-beta.16", + "@t3-oss/env-nextjs": "^0.8.0", + "@tailwindcss/forms": "^0.5.7", + "@tanstack/react-query": "^5.24.1", + "@testing-library/react": "^14.1.2", + "@trpc/client": "11.0.0-next-beta.294", + "@trpc/next": "11.0.0-next-beta.294", + "@trpc/react-query": "11.0.0-next-beta.294", + "@trpc/server": "11.0.0-next-beta.294", + "@vercel/blob": "^0.19.0", + "clsx": "^2.1.0", + "cmdk": "^0.2.0", + "date-fns": "^3.6.0", + "dotenv": "^16.4.1", + "ethers": "^6.13.1", + "formidable": "^3.5.1", + "graphql-request": "^6.1.0", + "lowdb": "^1.0.0", + "lucide-react": "^0.316.0", + "maci-cli": "0.0.0-ci.4d2d340", + "maci-domainobjs": "0.0.0-ci.4d2d340", + "next": "^14.1.0", + "next-auth": "^4.24.5", + "next-themes": "^0.2.1", + "node-fetch-cache": "^4.1.0", + "nuqs": "^1.17.1", + "p-limit": "^5.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.49.3", + "react-icons": "^5.0.1", + "react-markdown": "^9.0.1", + "react-number-format": "^5.3.1", + "react-use": "^17.5.0", + "siwe": "^2.1.4", + "sonner": "^1.4.0", + "superjson": "^2.2.1", + "tailwindcss": "^3.4.1", + "tailwind-merge": "^2.2.1", + "tailwind-variants": "^0.1.20", + "viem": "^2.7.15", + "wagmi": "^2.9.8", + "zod": "3.22.4" + }, + "devDependencies": { + "@next/eslint-plugin-next": "^14.2.3", + "@playwright/test": "^1.45.0", + "@synthetixio/synpress": "^3.7.3", + "@tailwindcss/typography": "^0.5.10", + "@testing-library/jest-dom": "^6.4.5", + "@types/eslint": "^8.56.2", + "@types/formidable": "^3.4.5", + "@types/lowdb": "^1.0.15", + "@types/node": "^20.11.10", + "@types/node-fetch-cache": "^3.0.5", + "@types/papaparse": "^5.3.14", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-next": "14.2.4", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-json": "^4.0.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-playwright": "^1.6.2", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-unused-imports": "^4.0.0", + "happy-dom": "^13.3.2", + "hardhat": "^2.22.3", + "jsdom": "^24.0.0", + "ky": "^1.2.0", + "lint-staged": "^15.2.7", + "msw": "^2.1.5", + "msw-trpc": "2.0.0-beta.0", + "mws": "^2.0.11", + "next-router-mock": "^0.9.11", + "postcss": "^8.4.33", + "prettier": "^3.3.2", + "prettier-plugin-tailwindcss": "^0.5.11", + "typescript": "^5.3.3" + }, + "ct3aMetadata": { + "initVersion": "7.24.1" + }, + "msw": { + "workerDirectory": "public" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "roots": [ + "/src" + ], + "testRegex": ".*\\.test\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s", + "!/ts/main.ts" + ], + "coverageDirectory": "/coverage", + "testEnvironment": "node" + }, + "browser": { + "child_process": false + }, + "engines": { + "pnpm": "9" + } +} diff --git a/playwright.config.ts b/packages/interface/playwright.config.ts similarity index 100% rename from playwright.config.ts rename to packages/interface/playwright.config.ts diff --git a/playwright/constants.ts b/packages/interface/playwright/constants.ts similarity index 100% rename from playwright/constants.ts rename to packages/interface/playwright/constants.ts diff --git a/playwright/fixtures.ts b/packages/interface/playwright/fixtures.ts similarity index 100% rename from playwright/fixtures.ts rename to packages/interface/playwright/fixtures.ts diff --git a/playwright/types.d.ts b/packages/interface/playwright/types.d.ts similarity index 100% rename from playwright/types.d.ts rename to packages/interface/playwright/types.d.ts diff --git a/postcss.config.cjs b/packages/interface/postcss.config.cjs similarity index 100% rename from postcss.config.cjs rename to packages/interface/postcss.config.cjs diff --git a/public/Logo.svg b/packages/interface/public/Logo.svg similarity index 100% rename from public/Logo.svg rename to packages/interface/public/Logo.svg diff --git a/public/arrow-down.svg b/packages/interface/public/arrow-down.svg similarity index 100% rename from public/arrow-down.svg rename to packages/interface/public/arrow-down.svg diff --git a/public/arrow-go-to.svg b/packages/interface/public/arrow-go-to.svg similarity index 100% rename from public/arrow-go-to.svg rename to packages/interface/public/arrow-go-to.svg diff --git a/public/arrow-up.svg b/packages/interface/public/arrow-up.svg similarity index 100% rename from public/arrow-up.svg rename to packages/interface/public/arrow-up.svg diff --git a/public/check-black.svg b/packages/interface/public/check-black.svg similarity index 100% rename from public/check-black.svg rename to packages/interface/public/check-black.svg diff --git a/public/check-white.svg b/packages/interface/public/check-white.svg similarity index 100% rename from public/check-white.svg rename to packages/interface/public/check-white.svg diff --git a/public/circle-check-blue-filled.svg b/packages/interface/public/circle-check-blue-filled.svg similarity index 100% rename from public/circle-check-blue-filled.svg rename to packages/interface/public/circle-check-blue-filled.svg diff --git a/public/circle-check-blue.svg b/packages/interface/public/circle-check-blue.svg similarity index 100% rename from public/circle-check-blue.svg rename to packages/interface/public/circle-check-blue.svg diff --git a/public/dropdown.svg b/packages/interface/public/dropdown.svg similarity index 100% rename from public/dropdown.svg rename to packages/interface/public/dropdown.svg diff --git a/public/favicon.svg b/packages/interface/public/favicon.svg similarity index 100% rename from public/favicon.svg rename to packages/interface/public/favicon.svg diff --git a/public/fonts/DM_Sans.woff2 b/packages/interface/public/fonts/DM_Sans.woff2 similarity index 100% rename from public/fonts/DM_Sans.woff2 rename to packages/interface/public/fonts/DM_Sans.woff2 diff --git a/public/fonts/Share_Tech_Mono.woff2 b/packages/interface/public/fonts/Share_Tech_Mono.woff2 similarity index 100% rename from public/fonts/Share_Tech_Mono.woff2 rename to packages/interface/public/fonts/Share_Tech_Mono.woff2 diff --git a/public/mockServiceWorker.js b/packages/interface/public/mockServiceWorker.js similarity index 63% rename from public/mockServiceWorker.js rename to packages/interface/public/mockServiceWorker.js index 24fe3a25..bb3144e9 100644 --- a/public/mockServiceWorker.js +++ b/packages/interface/public/mockServiceWorker.js @@ -8,128 +8,128 @@ * - Please do NOT serve this file on production. */ -const PACKAGE_VERSION = '2.3.1' -const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() +const PACKAGE_VERSION = "2.3.1"; +const INTEGRITY_CHECKSUM = "26357c79639bfa20d64c0efca2a87423"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); -self.addEventListener('install', function () { - self.skipWaiting() -}) +self.addEventListener("install", function () { + self.skipWaiting(); +}); -self.addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) +self.addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); -self.addEventListener('message', async function (event) { - const clientId = event.source.id +self.addEventListener("message", async function (event) { + const clientId = event.source.id; if (!clientId || !self.clients) { - return + return; } - const client = await self.clients.get(clientId) + const client = await self.clients.get(clientId); if (!client) { - return + return; } const allClients = await self.clients.matchAll({ - type: 'window', - }) + type: "window", + }); switch (event.data) { - case 'KEEPALIVE_REQUEST': { + case "KEEPALIVE_REQUEST": { sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break + type: "KEEPALIVE_RESPONSE", + }); + break; } - case 'INTEGRITY_CHECK_REQUEST': { + case "INTEGRITY_CHECK_REQUEST": { sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', + type: "INTEGRITY_CHECK_RESPONSE", payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }) - break + }); + break; } - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); sendToClient(client, { - type: 'MOCKING_ENABLED', + type: "MOCKING_ENABLED", payload: true, - }) - break + }); + break; } - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break + case "MOCK_DEACTIVATE": { + activeClientIds.delete(clientId); + break; } - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) + return client.id !== clientId; + }); // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister() + self.registration.unregister(); } - break + break; } } -}) +}); -self.addEventListener('fetch', function (event) { - const { request } = event +self.addEventListener("fetch", function (event) { + const { request } = event; // Bypass navigation requests. - if (request.mode === 'navigate') { - return + if (request.mode === "navigate") { + return; } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return + if (request.cache === "only-if-cached" && request.mode !== "same-origin") { + return; } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been deleted (still remains active until the next reload). if (activeClientIds.size === 0) { - return + return; } // Generate unique request ID. - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) -}) + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); async function handleRequest(event, requestId) { - const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - ;(async function () { - const responseClone = response.clone() + (async function () { + const responseClone = response.clone(); sendToClient( client, { - type: 'RESPONSE', + type: "RESPONSE", payload: { requestId, isMockedResponse: IS_MOCKED_RESPONSE in response, @@ -141,11 +141,11 @@ async function handleRequest(event, requestId) { }, }, [responseClone.body], - ) - })() + ); + })(); } - return response + return response; } // Resolve the main client for the given event. @@ -153,49 +153,49 @@ async function handleRequest(event, requestId) { // that registered the worker. It's with the latter the worker should // communicate with during the response resolving phase. async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) + const client = await self.clients.get(event.clientId); - if (client?.frameType === 'top-level') { - return client + if (client?.frameType === "top-level") { + return client; } const allClients = await self.clients.matchAll({ - type: 'window', - }) + type: "window", + }); return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === 'visible' + return client.visibilityState === "visible"; }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) + return activeClientIds.has(client.id); + }); } async function getResponse(event, client, requestId) { - const { request } = event + const { request } = event; // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() + const requestClone = request.clone(); function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) + const headers = Object.fromEntries(requestClone.headers.entries()); // Remove internal MSW request header so the passthrough request // complies with any potential CORS preflight checks on the server. // Some servers forbid unknown request headers. - delete headers['x-msw-intention'] + delete headers["x-msw-intention"]; - return fetch(requestClone, { headers }) + return fetch(requestClone, { headers }); } // Bypass mocking when the client is not active. if (!client) { - return passthrough() + return passthrough(); } // Bypass initial page load requests (i.e. static assets). @@ -203,15 +203,15 @@ async function getResponse(event, client, requestId) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough() + return passthrough(); } // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() + const requestBuffer = await request.arrayBuffer(); const clientMessage = await sendToClient( client, { - type: 'REQUEST', + type: "REQUEST", payload: { id: requestId, url: request.url, @@ -230,38 +230,35 @@ async function getResponse(event, client, requestId) { }, }, [requestBuffer], - ) + ); switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); } - case 'PASSTHROUGH': { - return passthrough() + case "PASSTHROUGH": { + return passthrough(); } } - return passthrough() + return passthrough(); } function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel() + const channel = new MessageChannel(); channel.port1.onmessage = (event) => { if (event.data && event.data.error) { - return reject(event.data.error) + return reject(event.data.error); } - resolve(event.data) - } + resolve(event.data); + }; - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) - }) + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); } async function respondWithMock(response) { @@ -270,15 +267,15 @@ async function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error() + return Response.error(); } - const mockedResponse = new Response(response.body, response) + const mockedResponse = new Response(response.body, response); Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }) + }); - return mockedResponse + return mockedResponse; } diff --git a/public/round-logo.svg b/packages/interface/public/round-logo.svg similarity index 100% rename from public/round-logo.svg rename to packages/interface/public/round-logo.svg diff --git a/src/components/AddedProjects.tsx b/packages/interface/src/components/AddedProjects.tsx similarity index 100% rename from src/components/AddedProjects.tsx rename to packages/interface/src/components/AddedProjects.tsx diff --git a/src/components/BallotOverview.tsx b/packages/interface/src/components/BallotOverview.tsx similarity index 84% rename from src/components/BallotOverview.tsx rename to packages/interface/src/components/BallotOverview.tsx index 4ccb95cc..02b75c9f 100644 --- a/src/components/BallotOverview.tsx +++ b/packages/interface/src/components/BallotOverview.tsx @@ -20,7 +20,7 @@ export const BallotOverview = (): JSX.Element => { : "/ballot" } > -
+

My Ballot

diff --git a/src/components/ConnectButton.tsx b/packages/interface/src/components/ConnectButton.tsx similarity index 100% rename from src/components/ConnectButton.tsx rename to packages/interface/src/components/ConnectButton.tsx diff --git a/src/components/ENS.tsx b/packages/interface/src/components/ENS.tsx similarity index 100% rename from src/components/ENS.tsx rename to packages/interface/src/components/ENS.tsx diff --git a/src/components/EligibilityDialog.tsx b/packages/interface/src/components/EligibilityDialog.tsx similarity index 100% rename from src/components/EligibilityDialog.tsx rename to packages/interface/src/components/EligibilityDialog.tsx diff --git a/src/components/EmptyState.tsx b/packages/interface/src/components/EmptyState.tsx similarity index 100% rename from src/components/EmptyState.tsx rename to packages/interface/src/components/EmptyState.tsx diff --git a/src/components/Footer.tsx b/packages/interface/src/components/Footer.tsx similarity index 92% rename from src/components/Footer.tsx rename to packages/interface/src/components/Footer.tsx index c56c9e84..24f2622f 100644 --- a/src/components/Footer.tsx +++ b/packages/interface/src/components/Footer.tsx @@ -5,7 +5,7 @@ import { FaXTwitter } from "react-icons/fa6"; import { Logo } from "./ui/Logo"; export const Footer = (): JSX.Element => ( -