diff --git a/.eslintignore b/.eslintignore index 240d8094..4075804b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,9 +6,11 @@ build typechain-types .eslintrc.js commitlint.config.js -subgraph/generated -public/mockServiceWorker.js zkeys -playwright-report -test-results -next.config.js +subgraph/generated +packages/interface/playwright/fixtures.ts +packages/interface/playwright.config.ts +packages/interface/playwright-report +packages/interface/test-results +packages/interface/next.config.js +packages/interface/public/mockServiceWorker.js diff --git a/.eslintrc.js b/.eslintrc.js index 9594edad..88432698 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, 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"], }, ], "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 7ce43807..5b3d0de4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -22,13 +22,9 @@ env: 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_EAS_CONTRACT_ADDRESS: ${{ vars.NEXT_PUBLIC_EAS_CONTRACT_ADDRESS }} - NEXT_PUBLIC_EAS_SCHEMA_REGISTRY_ADDRESS: ${{ vars.NEXT_PUBLIC_EAS_SCHEMA_REGISTRY_ADDRESS }} - NEXT_PUBLIC_SIGN_STATEMENT: ${{ vars.NEXT_PUBLIC_SIGN_STATEMENT }} 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 }} - NEXT_PUBLIC_EASSCAN_URL: ${{ vars.NEXT_PUBLIC_EASSCAN_URL }} 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 }} @@ -58,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 @@ -82,16 +78,6 @@ jobs: id: playwright-version run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV - - name: Cache playwright binaries - uses: actions/cache@v4 - id: playwright-cache - continue-on-error: true - with: - path: | - ~/.cache/ms-playwright - ms-playwright - key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - - name: Install dependencies run: |- pnpm install --frozen-lockfile --prefer-offline @@ -100,14 +86,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/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/docs/01_setup.md b/docs/01_setup.md index ef5ed0b8..df1ad3f2 100644 --- a/docs/01_setup.md +++ b/docs/01_setup.md @@ -1,6 +1,6 @@ # Setup -Follow these instructions to deploy your own instance of MACI-RPGF. +Follow these instructions to deploy your own instance of MACI-PLATFORM. ## Video Tutorial @@ -10,7 +10,7 @@ A complete installation tutorial can be seen here: ## 1. Fork Repo -[Fork MACI-RPGF](https://github.com/privacy-scaling-explorations/maci-rpgf/tree/main) +[Fork MACI-PLATFORM](https://github.com/privacy-scaling-explorations/maci-platform/tree/main) 1. Click to view the `.env.example` file in your newly created repo 2. Copy its contents and paste into a text editor diff --git a/docs/02_adding_projects.md b/docs/02_adding_projects.md index b9f08ab8..9a826406 100644 --- a/docs/02_adding_projects.md +++ b/docs/02_adding_projects.md @@ -9,7 +9,6 @@ - **contributionDescription** - describe your contribution - **impactDescription** - describe your impact - **contributionLinks** - links to contributions - - **impactMetrics** - links to your impact - **fundingSources** - list your funding sources This will create an Attestation with the Metadata schema and populate the fields: 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 725cedd3..2ea8a22a 100644 --- a/package.json +++ b/package.json @@ -1,158 +1,62 @@ { - "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-platform", + "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", - "@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.3.1", - "ethers": "^6.11.2", - "formidable": "^3.5.1", - "graphql-request": "^6.1.0", - "lowdb": "^1.0.0", - "lucide-react": "^0.316.0", - "maci-cli": "1.2.4", - "maci-domainobjs": "1.2.4", - "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", - "papaparse": "^5.4.1", - "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", - "tailwind-merge": "^2.2.1", - "tailwind-variants": "^0.1.20", - "viem": "^2.7.15", - "wagmi": "^2.9.8", - "zod": "3.22.4" + "lint": "eslint './**/**/*.ts' './**/**/*.tsx'", + "lint:fix": "pnpm run lint --fix", + "types": "lerna run types", + "docs": "lerna run docs", + "prepare": "is-ci || husky", + "start:interface": "lerna run start --scope=maci-platform-interface", + "start:coordinator": "lerna run start --scope=maci-coordinator", + "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", + "lerna": "^8.1.7", "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", - "tailwindcss": "^3.4.1", - "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" + "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..d58b2d7a --- /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": "^2.0.0", + "maci-cli": "^2.0.0", + "maci-contracts": "^2.0.0", + "maci-domainobjs": "^2.0.0", + "maci-subgraph": "^2.0.0", + "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..e355bb5e --- /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/v2.0.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..9547153f --- /dev/null +++ b/packages/coordinator/ts/proof/__tests__/proof.service.test.ts @@ -0,0 +1,182 @@ +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({ poll: ZeroAddress.replace("0x0", "0x1"), messageProcessor: ZeroAddress, tally: ZeroAddress }), + ), + 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({ poll: 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..dc4794d0 --- /dev/null +++ b/packages/coordinator/ts/proof/proof.service.ts @@ -0,0 +1,176 @@ +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, pollContracts] = await Promise.all([this.deployment.getDeployer(), maciContract.polls(poll)]); + + if (pollContracts.poll.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: pollContracts.poll, + }); + 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 70% rename from .env.example rename to packages/interface/.env.example index de9f4306..626b7668 100644 --- a/.env.example +++ b/packages/interface/.env.example @@ -6,16 +6,10 @@ BLOB_READ_WRITE_TOKEN= # NETWORK CONFIGURATION # --------------------- -# Must be one of: ethereum, optimism, optimismSepolia, arbitrum, linea, sepolia, baseGoerli +# Can be 1 of: ethereum, optimism, optimismSepolia, arbitrum, linea, sepolia, baseGoerli # Supported networks found here: https://docs.attest.sh/docs/quick--start/contracts -# NEXT_PUBLIC_CHAIN_NAME=optimism NEXT_PUBLIC_CHAIN_NAME=optimismSepolia -# EAS GraphQL API URL -# Find the URLs here: https://docs.attest.sh/docs/developer-tools/api -NEXT_PUBLIC_EASSCAN_URL=https://optimism-sepolia.easscan.org/graphql -# NEXT_PUBLIC_EASSCAN_URL=https://optimism.easscan.org/graphql - # Optional but highly recommended # Get your key at: https://dashboard.alchemy.com # https://docs.alchemy.com/docs/alchemy-quickstart-guide#1key-create-an-alchemy-key @@ -24,15 +18,13 @@ NEXT_PUBLIC_ALCHEMY_ID= # WalletConnect (optional to support more wallets) # Get your projectId at https://cloud.walletconnect.com NEXT_PUBLIC_WALLETCONNECT_ID= -# NEXT_PUBLIC_WALLETCONNECT_ID="21fef48091f12692cad574a6f7753643" # https://github.com/rainbow-me/rainbowkit/blob/d68813501e40363f76856f7471552c83c08f7606/packages/rainbowkit/src/wallets/getWalletConnectConnector.ts#L73 - # ----------------- # APP CONFIGURATION # ----------------- -# What the message will say when you sign in with the wallet -NEXT_PUBLIC_SIGN_STATEMENT="Sign in to MACI-RPGF" +# Event title for the round, just for display +NEXT_PUBLIC_EVENT_NAME="ETH GLOBAL" # Unique identifier for your applications and lists - your app will group attestations by this id NEXT_PUBLIC_ROUND_ID="open-rpgf-1" @@ -46,11 +38,10 @@ NEXT_PUBLIC_TOKEN_NAME="Votes" # Determine when users can register applications, admins review them, voters vote, and results are published NEXT_PUBLIC_START_DATE=2024-01-01T00:00:00.000Z NEXT_PUBLIC_REGISTRATION_END_DATE=2024-01-01T00:00:00.000Z -NEXT_PUBLIC_REVIEW_END_DATE=2024-01-01T00:00:00.000Z NEXT_PUBLIC_RESULTS_DATE=2024-01-01T00:00:00.000Z # Collect user feedback. Is shown as a link when user has voted -NEXT_PUBLIC_FEEDBACK_URL=https://github.com/privacy-scaling-explorations/maci-rpgf/issues/new?title=Feedback +NEXT_PUBLIC_FEEDBACK_URL=https://github.com/privacy-scaling-explorations/maci-platform/issues/new?title=Feedback # address that will approve applications and voters # (leaving empty means anyone can do this) @@ -71,12 +62,6 @@ NEXT_PUBLIC_APPROVAL_SCHEMA=0x858e0bc94997c072d762d90440966759b57c8bca892d4c9447 # (optional) NEXT_PUBLIC_METADATA_SCHEMA=0xd00c966351896bd3dc37d22017bf1ef23165f859d7546a2aba12a01623dec912 -# Used when creating attestations - change these if you're not on Optimism -# More info here: https://docs.attest.sh/docs/quick--start/contracts -NEXT_PUBLIC_EAS_CONTRACT_ADDRESS=0x4200000000000000000000000000000000000021 -NEXT_PUBLIC_EAS_SCHEMA_REGISTRY_ADDRESS=0x4200000000000000000000000000000000000020 - - # ---------------------- # Advanced Configuration # ---------------------- @@ -96,3 +81,5 @@ NEXT_PUBLIC_TALLY_URL=https://upblxu2duoxmkobt.public.blob.vercel-storage.com # Whether the poll is in qv or non qv mode NEXT_PUBLIC_POLL_MODE="non-qv" + +NEXT_PUBLIC_ROUND_LOGO="round-logo.png" 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..8cff9f9f --- /dev/null +++ b/packages/interface/README.md @@ -0,0 +1,41 @@ +# MACI-PLATFORM + + + +## 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 86% rename from e2e/connectWallet.test.ts rename to packages/interface/e2e/connectWallet.test.ts index ec451a40..da1a9fc1 100644 --- a/e2e/connectWallet.test.ts +++ b/packages/interface/e2e/connectWallet.test.ts @@ -1,12 +1,14 @@ import { test, expect } from "../playwright/fixtures"; +test.setTimeout(100000); + test.describe("connect wallet", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); }); test("should connect wallet using default metamask account", async ({ page }) => { - await page.getByText(/Connect wallet/).click(); + await page.getByRole("button", { name: "Connect wallet" }).first().click(); await page.getByTestId("rk-wallet-option-io.metamask").click(); const metamask = await page.context().waitForEvent("page"); 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..261aa497 --- /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.1032f7a", + "maci-domainobjs": "0.0.0-ci.1032f7a", + "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/packages/interface/public/Logo.svg b/packages/interface/public/Logo.svg new file mode 100644 index 00000000..f8348474 --- /dev/null +++ b/packages/interface/public/Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/interface/public/arrow-down.svg b/packages/interface/public/arrow-down.svg new file mode 100644 index 00000000..f87d1768 --- /dev/null +++ b/packages/interface/public/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/arrow-go-to.svg b/packages/interface/public/arrow-go-to.svg new file mode 100644 index 00000000..05fffe11 --- /dev/null +++ b/packages/interface/public/arrow-go-to.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/arrow-up.svg b/packages/interface/public/arrow-up.svg new file mode 100644 index 00000000..4ff36f2b --- /dev/null +++ b/packages/interface/public/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/check-black.svg b/packages/interface/public/check-black.svg new file mode 100644 index 00000000..79c1d55c --- /dev/null +++ b/packages/interface/public/check-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/check-white.svg b/packages/interface/public/check-white.svg new file mode 100644 index 00000000..51145219 --- /dev/null +++ b/packages/interface/public/check-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/circle-check-blue-filled.svg b/packages/interface/public/circle-check-blue-filled.svg new file mode 100644 index 00000000..a3f650a7 --- /dev/null +++ b/packages/interface/public/circle-check-blue-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/circle-check-blue.svg b/packages/interface/public/circle-check-blue.svg new file mode 100644 index 00000000..6392b913 --- /dev/null +++ b/packages/interface/public/circle-check-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/interface/public/dropdown.svg b/packages/interface/public/dropdown.svg new file mode 100644 index 00000000..4562ea6d --- /dev/null +++ b/packages/interface/public/dropdown.svg @@ -0,0 +1,3 @@ + + + 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/packages/interface/public/fonts/DM_Sans.woff2 b/packages/interface/public/fonts/DM_Sans.woff2 new file mode 100644 index 00000000..469840ce Binary files /dev/null and b/packages/interface/public/fonts/DM_Sans.woff2 differ diff --git a/packages/interface/public/fonts/Share_Tech_Mono.woff2 b/packages/interface/public/fonts/Share_Tech_Mono.woff2 new file mode 100644 index 00000000..50371e42 Binary files /dev/null and b/packages/interface/public/fonts/Share_Tech_Mono.woff2 differ 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 bdfdb11d..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.0' -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/packages/interface/public/round-logo.svg b/packages/interface/public/round-logo.svg new file mode 100644 index 00000000..d8bb29c7 --- /dev/null +++ b/packages/interface/public/round-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/interface/src/components/AddedProjects.tsx b/packages/interface/src/components/AddedProjects.tsx new file mode 100644 index 00000000..bb1c2ac8 --- /dev/null +++ b/packages/interface/src/components/AddedProjects.tsx @@ -0,0 +1,28 @@ +import { useBallot } from "~/contexts/Ballot"; +import { useProjectCount } from "~/features/projects/hooks/useProjects"; + +export const AddedProjects = (): JSX.Element => { + const { ballot } = useBallot(); + const allocations = ballot.votes; + const { data: projectCount } = useProjectCount(); + + return ( +
+

Projects Added

+ +
+ + {allocations.length} + + + + of + + + + {projectCount?.count} + +
+
+ ); +}; diff --git a/packages/interface/src/components/BallotOverview.tsx b/packages/interface/src/components/BallotOverview.tsx new file mode 100644 index 00000000..d4659a1f --- /dev/null +++ b/packages/interface/src/components/BallotOverview.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +import { Heading } from "~/components/ui/Heading"; +import { useBallot } from "~/contexts/Ballot"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +import { AddedProjects } from "./AddedProjects"; +import { VotingUsage } from "./VotingUsage"; + +export const BallotOverview = (): JSX.Element => { + const { ballot } = useBallot(); + + const appState = useAppState(); + + return ( + +
+ + My Ballot + + + + + +
+ + ); +}; diff --git a/packages/interface/src/components/ConnectButton.tsx b/packages/interface/src/components/ConnectButton.tsx new file mode 100644 index 00000000..466bb8fe --- /dev/null +++ b/packages/interface/src/components/ConnectButton.tsx @@ -0,0 +1,76 @@ +import { ConnectButton as RainbowConnectButton } from "@rainbow-me/rainbowkit"; +import Image from "next/image"; +import { createBreakpoint } from "react-use"; + +import { config } from "~/config"; + +import { Button } from "./ui/Button"; +import { Chip } from "./ui/Chip"; + +const useBreakpoint = createBreakpoint({ XL: 1280, L: 768, S: 350 }); + +interface IConnectedDetailsProps { + account: { address: string; displayName: string }; + openAccountModal: () => void; + isMobile: boolean; +} + +const ConnectedDetails = ({ openAccountModal, account, isMobile }: IConnectedDetailsProps) => ( +
+
+ + {isMobile ? null : account.displayName} + + dropdown + +
+
+); + +export const ConnectButton = (): JSX.Element => { + const breakpoint = useBreakpoint(); + const isMobile = breakpoint === "S"; + + return ( + + {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted, authenticationStatus }) => { + const ready = mounted && authenticationStatus !== "loading"; + const connected = + ready && account && chain && (!authenticationStatus || authenticationStatus === "authenticated"); + + return ( +
+ {(() => { + if (!connected) { + return ( + + ); + } + + if (chain.unsupported ?? ![Number(config.network.id)].includes(chain.id)) { + return ( + + Wrong network + + ); + } + + return ; + })()} +
+ ); + }} +
+ ); +}; diff --git a/src/components/ENS.tsx b/packages/interface/src/components/ENS.tsx similarity index 78% rename from src/components/ENS.tsx rename to packages/interface/src/components/ENS.tsx index 6714c2e1..28e3f5ae 100644 --- a/src/components/ENS.tsx +++ b/packages/interface/src/components/ENS.tsx @@ -5,7 +5,11 @@ import { truncate } from "~/utils/truncate"; import type { Address } from "viem"; -export const AvatarENS = ({ address }: { address: Address }): JSX.Element => { +interface IENSProps { + address?: Address; +} + +export const AvatarENS = ({ address = "0x" }: IENSProps): JSX.Element => { const { data: name } = useEnsName({ address, chainId: 1, @@ -27,9 +31,9 @@ export const AvatarENS = ({ address }: { address: Address }): JSX.Element => { ); }; -export const NameENS = ({ address = "" }: { address?: string }): JSX.Element => { +export const NameENS = ({ address = "0x" }: IENSProps): JSX.Element => { const { data: name } = useEnsName({ - address: address as Address, + address, chainId: 1, query: { enabled: Boolean(address), diff --git a/packages/interface/src/components/EligibilityDialog.tsx b/packages/interface/src/components/EligibilityDialog.tsx new file mode 100644 index 00000000..57f952e9 --- /dev/null +++ b/packages/interface/src/components/EligibilityDialog.tsx @@ -0,0 +1,151 @@ +import { useRouter } from "next/router"; +import { useState, useCallback, useEffect } from "react"; +import { toast } from "sonner"; +import { useAccount, useDisconnect } from "wagmi"; + +import { useMaci } from "~/contexts/Maci"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +import { Dialog } from "./ui/Dialog"; + +export const EligibilityDialog = (): JSX.Element | null => { + const { address } = useAccount(); + const { disconnect } = useDisconnect(); + + const [openDialog, setOpenDialog] = useState(!!address); + const { onSignup, isEligibleToVote, isRegistered } = useMaci(); + const router = useRouter(); + + const appState = useAppState(); + + const onError = useCallback(() => toast.error("Signup error"), []); + + const handleSignup = useCallback(async () => { + await onSignup(onError); + setOpenDialog(false); + }, [onSignup, onError, setOpenDialog]); + + useEffect(() => { + setOpenDialog(!!address); + }, [address, setOpenDialog]); + + const handleCloseDialog = useCallback(() => { + setOpenDialog(false); + }, [setOpenDialog]); + + const handleDisconnect = useCallback(() => { + disconnect(); + }, [disconnect]); + + const handleGoToProjects = useCallback(() => { + router.push("/projects"); + }, [router]); + + const handleGoToCreateApp = useCallback(() => { + router.push("/applications/new"); + }, [router]); + + if (appState === EAppState.APPLICATION) { + return ( + +

Start creating your own application now!

+ + } + isOpen={openDialog} + size="sm" + title="You're all set to apply" + onOpenChange={handleCloseDialog} + /> + ); + } + + if (appState === EAppState.VOTING && isRegistered) { + return ( + +

You have X voice credits to vote with.

+ +

+ Get started by adding projects to your ballot, then adding the amount of votes you want to allocate to + each one. +

+ +

Please submit your ballot by X date!

+ + } + isOpen={openDialog} + size="sm" + title="You're all set to vote" + onOpenChange={handleCloseDialog} + /> + ); + } + + if (appState === EAppState.VOTING && !isRegistered && isEligibleToVote) { + return ( + +

Next, you will need to join the voting round.

+ + + Learn more about this process + + + here + + + . + + + } + isOpen={openDialog} + size="sm" + title="Account verified!" + onOpenChange={handleCloseDialog} + /> + ); + } + + if (appState === EAppState.VOTING && !isEligibleToVote) { + return ( + + ); + } + + if (appState === EAppState.TALLYING) { + return ( + + ); + } + + return
; +}; diff --git a/src/components/EmptyState.tsx b/packages/interface/src/components/EmptyState.tsx similarity index 90% rename from src/components/EmptyState.tsx rename to packages/interface/src/components/EmptyState.tsx index 427f009d..1d1d242b 100644 --- a/src/components/EmptyState.tsx +++ b/packages/interface/src/components/EmptyState.tsx @@ -3,7 +3,7 @@ import { type PropsWithChildren } from "react"; import { Heading } from "./ui/Heading"; export const EmptyState = ({ title, children = null }: PropsWithChildren<{ title: string }>): JSX.Element => ( -
+
{title} diff --git a/packages/interface/src/components/FetchInView.tsx b/packages/interface/src/components/FetchInView.tsx new file mode 100644 index 00000000..938929a5 --- /dev/null +++ b/packages/interface/src/components/FetchInView.tsx @@ -0,0 +1,32 @@ +import { useRef, useEffect } from "react"; +import { useIntersection } from "react-use"; + +import { Spinner } from "./ui/Spinner"; + +interface IFetchInViewProps { + hasMore?: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; +} + +export const FetchInView = ({ hasMore = false, isFetchingNextPage, fetchNextPage }: IFetchInViewProps): JSX.Element => { + const ref = useRef(null); + const intersection = useIntersection(ref, { + root: null, + rootMargin: "0px", + threshold: 0.5, + }); + + useEffect(() => { + if (intersection?.isIntersecting && !isFetchingNextPage && hasMore) { + // eslint-disable-next-line no-console + fetchNextPage().catch(console.error); + } + }, [intersection?.isIntersecting, isFetchingNextPage, hasMore, fetchNextPage]); + + return ( +
+ {isFetchingNextPage && } +
+ ); +}; diff --git a/packages/interface/src/components/Footer.tsx b/packages/interface/src/components/Footer.tsx new file mode 100644 index 00000000..a298d75a --- /dev/null +++ b/packages/interface/src/components/Footer.tsx @@ -0,0 +1,48 @@ +import Image from "next/image"; +import { FaTelegramPlane, FaGithub, FaDiscord } from "react-icons/fa"; +import { FaXTwitter } from "react-icons/fa6"; + +import { Logo } from "./ui/Logo"; + +export const Footer = (): JSX.Element => ( + +); diff --git a/packages/interface/src/components/Header.tsx b/packages/interface/src/components/Header.tsx new file mode 100644 index 00000000..4895d962 --- /dev/null +++ b/packages/interface/src/components/Header.tsx @@ -0,0 +1,122 @@ +import clsx from "clsx"; +import { Menu, X, SunIcon, MoonIcon } from "lucide-react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useTheme } from "next-themes"; +import { type ComponentPropsWithRef, useState, useCallback } from "react"; + +import { useBallot } from "~/contexts/Ballot"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +import { ConnectButton } from "./ConnectButton"; +import { IconButton } from "./ui/Button"; +import { Logo } from "./ui/Logo"; + +interface INavLinkProps extends ComponentPropsWithRef { + isActive: boolean; +} + +const NavLink = ({ isActive, ...props }: INavLinkProps) => ( + +); + +interface IMobileMenuProps { + isOpen?: boolean; + navLinks: INavLink[]; +} + +const MobileMenu = ({ isOpen = false, navLinks }: IMobileMenuProps) => ( +
+ {navLinks.map((link) => ( + + ))} +
+); + +interface INavLink { + href: string; + children: string; +} + +interface IHeaderProps { + navLinks: INavLink[]; +} + +const Header = ({ navLinks }: IHeaderProps) => { + const { asPath } = useRouter(); + const [isOpen, setOpen] = useState(false); + const { ballot } = useBallot(); + const appState = useAppState(); + const { theme, setTheme } = useTheme(); + + const handleChangeTheme = useCallback(() => { + setTheme(theme === "light" ? "dark" : "light"); + }, [theme, setTheme]); + + return ( +
+
+
+ { + setOpen(!isOpen); + }} + /> + + + + +
+ +
+ {navLinks.map((link) => { + const pageName = `/${link.href.split("/")[1]}`; + return ( + + {link.children} + + {appState === EAppState.VOTING && pageName === "/ballot" && ballot.votes.length > 0 && ( +
+ {ballot.votes.length} +
+ )} +
+ ); + })} +
+ +
+ +
+ + + +
+ + +
+
+ ); +}; + +export default dynamic(async () => Promise.resolve(Header), { ssr: false }); diff --git a/src/components/ImageUpload.tsx b/packages/interface/src/components/ImageUpload.tsx similarity index 76% rename from src/components/ImageUpload.tsx rename to packages/interface/src/components/ImageUpload.tsx index d632cc91..a914a0fb 100644 --- a/src/components/ImageUpload.tsx +++ b/packages/interface/src/components/ImageUpload.tsx @@ -6,8 +6,6 @@ import { Controller, useFormContext } from "react-hook-form"; import { toast } from "sonner"; import { IconButton } from "~/components/ui/Button"; -import { Spinner } from "~/components/ui/Spinner"; -import { useUploadMetadata } from "~/hooks/useMetadata"; export interface IImageUploadProps extends ComponentProps<"img"> { name?: string; @@ -22,7 +20,6 @@ export const ImageUpload = ({ const ref = useRef(null); const { control } = useFormContext(); - const upload = useUploadMetadata(); const select = useMutation({ mutationFn: async (file: File) => { if (file.size >= maxSize) { @@ -46,16 +43,10 @@ export const ImageUpload = ({ className={clsx("relative cursor-pointer overflow-hidden", className)} onClick={() => ref.current?.click()} > - +
{ - upload.mutate(file, { - onSuccess: (data) => { - onChange(data.url); - }, - }); + onSuccess: (objectUrl) => { + onChange(objectUrl); }, }); } diff --git a/src/components/InfiniteLoading.tsx b/packages/interface/src/components/InfiniteLoading.tsx similarity index 63% rename from src/components/InfiniteLoading.tsx rename to packages/interface/src/components/InfiniteLoading.tsx index a214a277..ffc285b7 100644 --- a/src/components/InfiniteLoading.tsx +++ b/packages/interface/src/components/InfiniteLoading.tsx @@ -1,11 +1,10 @@ import { type UseTRPCInfiniteQueryResult } from "@trpc/react-query/shared"; -import { useMemo, type ReactNode, useRef, useEffect } from "react"; -import { useIntersection } from "react-use"; +import { useMemo, type ReactNode } from "react"; import { config } from "~/config"; import { EmptyState } from "./EmptyState"; -import { Spinner } from "./ui/Spinner"; +import { FetchInView } from "./FetchInView"; const columnMap = { 2: "grid-cols-1 md:grid-cols-2", @@ -17,36 +16,6 @@ type Props = UseTRPCInfiniteQueryResult & { columns?: keyof typeof columnMap; }; -const FetchInView = ({ - hasMore = false, - isFetchingNextPage, - fetchNextPage, -}: { - hasMore?: boolean; - isFetchingNextPage: boolean; - fetchNextPage: () => Promise; -}) => { - const ref = useRef(null); - const intersection = useIntersection(ref, { - root: null, - rootMargin: "0px", - threshold: 0.5, - }); - - useEffect(() => { - if (intersection?.isIntersecting && !isFetchingNextPage && hasMore) { - // eslint-disable-next-line no-console - fetchNextPage().catch(console.error); - } - }, [intersection?.isIntersecting, isFetchingNextPage, hasMore, fetchNextPage]); - - return ( -
- {isFetchingNextPage && } -
- ); -}; - export const InfiniteLoading = ({ data, columns = 3, diff --git a/packages/interface/src/components/Info.tsx b/packages/interface/src/components/Info.tsx new file mode 100644 index 00000000..942daee2 --- /dev/null +++ b/packages/interface/src/components/Info.tsx @@ -0,0 +1,105 @@ +import { tv } from "tailwind-variants"; + +import { createComponent } from "~/components/ui"; +import { config } from "~/config"; +import { useMaci } from "~/contexts/Maci"; +import { useAppState } from "~/utils/state"; +import { EInfoCardState, EAppState } from "~/utils/types"; + +import { InfoCard } from "./InfoCard"; +import { RoundInfo } from "./RoundInfo"; +import { VotingInfo } from "./VotingInfo"; + +const InfoContainer = createComponent( + "div", + tv({ + base: "flex items-center justify-center gap-2 rounded-lg bg-white p-5 shadow-lg dark:bg-lightBlack", + variants: { + size: { + sm: "flex-col", + default: "flex-col max-lg:w-full lg:flex-row", + }, + }, + }), +); + +interface InfoProps { + size: string; + showVotingInfo?: boolean; +} + +export const Info = ({ size, showVotingInfo = false }: InfoProps): JSX.Element => { + const { votingEndsAt } = useMaci(); + const appState = useAppState(); + + const steps = [ + { + label: "application", + state: EAppState.APPLICATION, + start: config.startsAt, + end: config.registrationEndsAt, + }, + { + label: "voting", + state: EAppState.VOTING, + start: config.registrationEndsAt, + end: votingEndsAt, + }, + { + label: "tallying", + state: EAppState.TALLYING, + start: votingEndsAt, + end: config.resultsAt, + }, + { + label: "results", + state: EAppState.RESULTS, + start: config.resultsAt, + end: config.resultsAt, + }, + ]; + + return ( +
+ + {showVotingInfo && ( +
+ + + {appState === EAppState.VOTING && } +
+ )} + + {steps.map( + (step) => + step.start && + step.end && ( + + ), + )} +
+
+ ); +}; + +function defineState({ state, appState }: { state: EAppState; appState: EAppState }): EInfoCardState { + const statesOrder = [EAppState.APPLICATION, EAppState.VOTING, EAppState.TALLYING, EAppState.RESULTS]; + const currentStateOrder = statesOrder.indexOf(state); + const appStateOrder = statesOrder.indexOf(appState); + + if (currentStateOrder < appStateOrder) { + return EInfoCardState.PASSED; + } + + if (currentStateOrder === appStateOrder) { + return EInfoCardState.ONGOING; + } + + return EInfoCardState.UPCOMING; +} diff --git a/packages/interface/src/components/InfoCard.tsx b/packages/interface/src/components/InfoCard.tsx new file mode 100644 index 00000000..a5adb23b --- /dev/null +++ b/packages/interface/src/components/InfoCard.tsx @@ -0,0 +1,64 @@ +import { format } from "date-fns"; +import Image from "next/image"; +import { tv } from "tailwind-variants"; + +import { createComponent } from "~/components/ui"; +import { EInfoCardState } from "~/utils/types"; + +const InfoCardContainer = createComponent( + "div", + tv({ + base: "rounded-md p-2 max-lg:w-full lg:w-64", + variants: { + state: { + [EInfoCardState.PASSED]: "border border-blue-500 bg-blue-50 text-blue-500 dark:bg-darkBlue dark:text-blue-800", + [EInfoCardState.ONGOING]: "border border-blue-500 bg-blue-500 text-white", + [EInfoCardState.UPCOMING]: + "border border-gray-200 bg-transparent text-gray-200 dark:border-lighterBlack dark:text-gray-800", + }, + }, + }), +); + +interface InfoCardProps { + state: EInfoCardState; + title: string; + start: Date; + end: Date; +} + +export const InfoCard = ({ state, title, start, end }: InfoCardProps): JSX.Element => ( + +
+

+ {title} +

+ + {state === EInfoCardState.PASSED && ( + circle-check-blue + )} + + {state === EInfoCardState.ONGOING &&
} + + {state === EInfoCardState.UPCOMING && ( +
+ )} +
+ +

{formatDateString({ start, end })}

+ +); + +function formatDateString({ start, end }: { start: Date; end: Date }): string { + const fullFormat = "d MMM yyyy"; + + if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) { + return `${start.getDate()} - ${format(end, fullFormat)}`; + } + + if (start.getFullYear() === end.getFullYear()) { + return `${format(start, "d MMM")} - ${format(end, fullFormat)}`; + } + + return `${format(start, fullFormat)} - ${format(end, fullFormat)}`; +} diff --git a/packages/interface/src/components/JoinButton.tsx b/packages/interface/src/components/JoinButton.tsx new file mode 100644 index 00000000..cddb616c --- /dev/null +++ b/packages/interface/src/components/JoinButton.tsx @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { useMaci } from "~/contexts/Maci"; +import { useAppState } from "~/utils/state"; +import { EAppState } from "~/utils/types"; + +import { Button } from "./ui/Button"; + +export const JoinButton = (): JSX.Element => { + const { isLoading, isRegistered, isEligibleToVote, onSignup } = useMaci(); + const appState = useAppState(); + + const onError = useCallback(() => toast.error("Signup error"), []); + const handleSignup = useCallback(() => onSignup(onError), [onSignup, onError]); + + return ( +
+ {appState === EAppState.VOTING && !isEligibleToVote && ( + + )} + + {appState === EAppState.VOTING && isEligibleToVote && !isRegistered && ( + + )} + + {appState === EAppState.TALLYING && ( + + )} + + {appState === EAppState.RESULTS && } +
+ ); +}; diff --git a/packages/interface/src/components/RoundInfo.tsx b/packages/interface/src/components/RoundInfo.tsx new file mode 100644 index 00000000..75c9f06f --- /dev/null +++ b/packages/interface/src/components/RoundInfo.tsx @@ -0,0 +1,18 @@ +import Image from "next/image"; + +import { Heading } from "~/components/ui/Heading"; +import { config } from "~/config"; + +export const RoundInfo = (): JSX.Element => ( +
+

Round

+ +
+ {config.roundLogo && round logo} + + + {config.roundId} + +
+
+); diff --git a/packages/interface/src/components/SortByDropdown.tsx b/packages/interface/src/components/SortByDropdown.tsx new file mode 100644 index 00000000..0e279b10 --- /dev/null +++ b/packages/interface/src/components/SortByDropdown.tsx @@ -0,0 +1,67 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { ArrowUpDown, Check } from "lucide-react"; +import { useCallback } from "react"; + +import { type SortType, sortLabels } from "~/features/filter/hooks/useFilter"; + +import { IconButton } from "./ui/Button"; + +interface ISortByDropdownProps { + value: SortType; + onChange: (value: string) => void; + options?: SortType[]; +} + +interface IRadioItemProps { + label?: string; + value?: string; +} + +const RadioItem = ({ value = "", label = "" }: IRadioItemProps): JSX.Element => ( + + + + + + {label} + +); + +export const SortByDropdown = ({ value, onChange, options = [] }: ISortByDropdownProps): JSX.Element => { + const handleOnChange = useCallback( + (v: string) => { + onChange(v); + }, + [onChange], + ); + + return ( + + + + Sort by: {sortLabels[value]} + + + + + + + Sort By + + + + {options.map((option) => ( + + ))} + + + + + ); +}; diff --git a/src/components/SortFilter.tsx b/packages/interface/src/components/SortFilter.tsx similarity index 86% rename from src/components/SortFilter.tsx rename to packages/interface/src/components/SortFilter.tsx index 46cf4919..7847e88a 100644 --- a/src/components/SortFilter.tsx +++ b/packages/interface/src/components/SortFilter.tsx @@ -23,12 +23,7 @@ export const SortFilter = (): JSX.Element => { return (
- + ( + {content} +); diff --git a/packages/interface/src/components/TimeSlot.tsx b/packages/interface/src/components/TimeSlot.tsx new file mode 100644 index 00000000..64501c5e --- /dev/null +++ b/packages/interface/src/components/TimeSlot.tsx @@ -0,0 +1,14 @@ +interface TimeSlotProps { + num: number; + unit: string; +} + +export const TimeSlot = ({ num, unit }: TimeSlotProps): JSX.Element => ( +
+

+ {num} +

+ +

{unit}

+
+); diff --git a/src/components/Toaster.tsx b/packages/interface/src/components/Toaster.tsx similarity index 69% rename from src/components/Toaster.tsx rename to packages/interface/src/components/Toaster.tsx index 918a87cc..7954c20a 100644 --- a/src/components/Toaster.tsx +++ b/packages/interface/src/components/Toaster.tsx @@ -6,12 +6,14 @@ export const Toaster = (): JSX.Element => { return ( { + const { isLoading, votingEndsAt } = useMaci(); + const [timeLeft, setTimeLeft] = useState<[number, number, number, number]>([0, 0, 0, 0]); + + useHarmonicIntervalFn(() => { + setTimeLeft(calculateTimeLeft(votingEndsAt)); + }, 1000); + + return ( +
+

Voting Ends In

+ + {isLoading &&

Loading...

} + + {!isLoading && ( +
+ + + + + + + +
+ )} +
+ ); +}; diff --git a/packages/interface/src/components/VotingUsage.tsx b/packages/interface/src/components/VotingUsage.tsx new file mode 100644 index 00000000..a5868fb9 --- /dev/null +++ b/packages/interface/src/components/VotingUsage.tsx @@ -0,0 +1,34 @@ +import clsx from "clsx"; +import { useMemo } from "react"; + +import { useBallot } from "~/contexts/Ballot"; +import { useMaci } from "~/contexts/Maci"; + +export const VotingUsage = (): JSX.Element => { + const { initialVoiceCredits } = useMaci(); + const { ballot, sumBallot } = useBallot(); + + const sum = useMemo(() => sumBallot(ballot.votes), [sumBallot, ballot]); + + return ( +
+

Voting Power

+ +
+

+ {initialVoiceCredits} +

+ +

Votes Left

+
+ +
+

initialVoiceCredits && "text-red")}> + {sum} +

+ +

Votes Used

+
+
+ ); +}; diff --git a/src/components/ui/Alert.tsx b/packages/interface/src/components/ui/Alert.tsx similarity index 92% rename from src/components/ui/Alert.tsx rename to packages/interface/src/components/ui/Alert.tsx index 18e1c4d9..f8e41e98 100644 --- a/src/components/ui/Alert.tsx +++ b/packages/interface/src/components/ui/Alert.tsx @@ -9,7 +9,7 @@ const alert = tv({ variants: { variant: { warning: "bg-red-200 text-red-800", - info: "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-300", + info: "bg-gray-100 text-gray-900", success: "bg-green-200 text-green-800", }, }, diff --git a/src/components/ui/Avatar.tsx b/packages/interface/src/components/ui/Avatar.tsx similarity index 83% rename from src/components/ui/Avatar.tsx rename to packages/interface/src/components/ui/Avatar.tsx index b6a42f90..ddad7f49 100644 --- a/src/components/ui/Avatar.tsx +++ b/packages/interface/src/components/ui/Avatar.tsx @@ -7,7 +7,7 @@ import { createComponent } from "."; export const Avatar = createComponent( BackgroundImage, tv({ - base: "bg-gray-200 dark:bg-gray-800", + base: "bg-gray-200 border-2 border-white", variants: { size: { xs: "w-5 h-5 rounded-xs", @@ -19,7 +19,7 @@ export const Avatar = createComponent( full: "rounded-full", }, bordered: { - true: "outline outline-white dark:outline-gray-900", + true: "outline outline-white", }, }, defaultVariants: { diff --git a/src/components/ui/BackgroundImage.tsx b/packages/interface/src/components/ui/BackgroundImage.tsx similarity index 100% rename from src/components/ui/BackgroundImage.tsx rename to packages/interface/src/components/ui/BackgroundImage.tsx diff --git a/src/components/ui/Badge.tsx b/packages/interface/src/components/ui/Badge.tsx similarity index 51% rename from src/components/ui/Badge.tsx rename to packages/interface/src/components/ui/Badge.tsx index eb09e883..7baca831 100644 --- a/src/components/ui/Badge.tsx +++ b/packages/interface/src/components/ui/Badge.tsx @@ -5,14 +5,15 @@ import { createComponent } from "."; export const Badge = createComponent( "div", tv({ - base: "inline-flex items-center rounded font-semibold text-gray-500 text-sm", + base: "inline-flex items-center rounded font-semibold text-sm", variants: { variant: { - default: "bg-gray-100 dark:bg-gray-800", - success: "dark:bg-green-300 dark:text-green-900", + default: "bg-gray-100", + success: "bg-[#BBF7D0] text-[#14532D] dark:bg-[#031E0C] dark:text-[#4ADE80]", + pending: "bg-[#FFEDD5] text-[#4E1D0D] dark:bg-[#4E1D0D] dark:text-[#F1B37A]", }, size: { - md: "px-1 ", + md: "px-3 py-1.5", lg: "px-2 py-1 text-base", }, }, diff --git a/src/components/ui/Banner.tsx b/packages/interface/src/components/ui/Banner.tsx similarity index 66% rename from src/components/ui/Banner.tsx rename to packages/interface/src/components/ui/Banner.tsx index 20f7bd83..dff89f54 100644 --- a/src/components/ui/Banner.tsx +++ b/packages/interface/src/components/ui/Banner.tsx @@ -7,14 +7,11 @@ import { BackgroundImage } from "./BackgroundImage"; export const Banner = createComponent( BackgroundImage, tv({ - base: "bg-gray-200 dark:bg-gray-800", + base: "bg-gray-200", variants: { size: { - md: "h-24 rounded-2xl", - lg: "h-80 rounded-3xl", - }, - rounded: { - full: "rounded-full", + md: "h-24 rounded-t-xl", + lg: "h-80 rounded-t-xl", }, }, defaultVariants: { diff --git a/src/components/ui/Button.tsx b/packages/interface/src/components/ui/Button.tsx similarity index 52% rename from src/components/ui/Button.tsx rename to packages/interface/src/components/ui/Button.tsx index cd17c494..6a5f93e6 100644 --- a/src/components/ui/Button.tsx +++ b/packages/interface/src/components/ui/Button.tsx @@ -5,20 +5,23 @@ import { tv } from "tailwind-variants"; import { createComponent } from "."; const button = tv({ - base: "inline-flex items-center justify-center font-semibold text-center transition-colors rounded-full duration-150 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:ring-offset-gray-800", + base: "inline-flex items-center justify-center font-semibold uppercase rounded-lg text-center transition-colors duration-150 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", variants: { variant: { - primary: - "bg-primary-600 hover:bg-primary-700 dark:bg-white dark:hover:bg-primary-500 dark:text-gray-900 text-white dark:disabled:bg-gray-500", - ghost: "hover:bg-gray-100 dark:hover:bg-gray-800", - default: "bg-gray-100 dark:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700", - inverted: "bg-white text-black hover:bg-white/90", - link: "bg-none hover:underline", - outline: "border-2 hover:bg-white/5", + primary: "bg-black text-white hover:bg-blue-950 dark:bg-white dark:text-black dark:hover:bg-blue-100", + inverted: + "text-black border border-black hover:text-blue-500 hover:border-blue-500 dark:border-white dark:text-white", + tertiary: "bg-blue-50 text-blue-500 border border-blue-500 hover:bg-blue-100", + secondary: "bg-blue-500 text-white hover:bg-blue-600", + ghost: "hover:bg-gray-100 dark:invert", + outline: "border border-gray-200 hover:border-gray-300 dark:text-white dark:border-white", + disabled: "border border-gray-200 bg-gray-50 text-gray-200 cursor-not-allowed", + none: "", }, size: { - sm: "px-3 py-2 h-10 min-w-[40px]", - default: "px-4 py-2 h-12", + sm: "px-3 py-2 h-8 text-xs rounded-md", + default: "px-4 py-2 h-10 w-full", + auto: "px-4 py-2 h-10 w-auto", icon: "h-12 w-12", }, disabled: { @@ -26,7 +29,7 @@ const button = tv({ }, }, defaultVariants: { - variant: "default", + variant: "none", size: "default", }, }); diff --git a/src/components/ui/Card.tsx b/packages/interface/src/components/ui/Card.tsx similarity index 100% rename from src/components/ui/Card.tsx rename to packages/interface/src/components/ui/Card.tsx diff --git a/packages/interface/src/components/ui/Chip.tsx b/packages/interface/src/components/ui/Chip.tsx new file mode 100644 index 00000000..04fbddd8 --- /dev/null +++ b/packages/interface/src/components/ui/Chip.tsx @@ -0,0 +1,17 @@ +import { tv } from "tailwind-variants"; + +import { createComponent } from "."; + +const chip = tv({ + base: "rounded-md min-w-[42px] px-2 md:px-3 py-2 cursor-pointer inline-flex justify-center items-center whitespace-nowrap uppercase", + variants: { + color: { + primary: "text-white bg-black border-none", + secondary: "text-black bg-white border border-black", + neutral: "text-blue-500 bg-blue-50 border border-blue-500", + disabled: "cursor-not-allowed text-gray-500 bg-gray-50 border border-gray-500", + }, + }, +}); + +export const Chip = createComponent("button", chip); diff --git a/packages/interface/src/components/ui/Dialog.tsx b/packages/interface/src/components/ui/Dialog.tsx new file mode 100644 index 00000000..cddddc9a --- /dev/null +++ b/packages/interface/src/components/ui/Dialog.tsx @@ -0,0 +1,87 @@ +import * as RadixDialog from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { useTheme } from "next-themes"; +import { tv } from "tailwind-variants"; + +import type { ReactNode, PropsWithChildren, ComponentProps } from "react"; + +import { IconButton, Button } from "./Button"; +import { Spinner } from "./Spinner"; + +import { createComponent } from "."; + +const Content = createComponent( + RadixDialog.Content, + tv({ + base: "z-20 fixed bottom-0 rounded-md p-12 flex flex-col justify-center gap-4 items-center text-center w-full font-sans sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 dark:bg-lightBlack bg-white", + variants: { + size: { + sm: "sm:w-[456px] md:w-[456px]", + md: "sm:w-[456px] md:w-[800px]", + }, + }, + defaultVariants: { + size: "md", + }, + }), +); + +interface IDialogProps { + title: ReactNode; + description: ReactNode; + size: "sm" | "md"; + isOpen: boolean; + isLoading?: boolean; + button?: "primary" | "secondary"; + buttonName?: string; + buttonAction?: () => void; + onOpenChange: ComponentProps["onOpenChange"]; +} + +export const Dialog = ({ + title, + description, + size, + isOpen, + isLoading = false, + button = undefined, + buttonName = undefined, + buttonAction = undefined, + children = undefined, + onOpenChange, +}: PropsWithChildren): JSX.Element => { + const { theme } = useTheme(); + + return ( + + + + + {/* Because of Portal we need to set the theme here */} +
+ + {title} + + {description} + + {children} + + {isLoading && } + + {!isLoading && button && buttonName && buttonAction && ( + + )} + + {onOpenChange ? ( + + + + ) : null} + +
+
+
+ ); +}; diff --git a/src/components/ui/Form.tsx b/packages/interface/src/components/ui/Form.tsx similarity index 70% rename from src/components/ui/Form.tsx rename to packages/interface/src/components/ui/Form.tsx index 4969aa07..133f72eb 100644 --- a/src/components/ui/Form.tsx +++ b/packages/interface/src/components/ui/Form.tsx @@ -24,57 +24,12 @@ import { type z } from "zod"; import { cn } from "~/utils/classNames"; import { IconButton } from "./Button"; +import { Heading } from "./Heading"; +import { inputBase, Input, InputWrapper, InputIcon } from "./Input"; +import { Tooltip } from "./Tooltip"; import { createComponent } from "."; -const inputBase = [ - "dark:bg-gray-900", - "dark:text-gray-300", - "dark:border-gray-700", - "rounded", - "disabled:opacity-30", - "checked:bg-gray-800", -]; - -export const Input = createComponent( - "input", - tv({ - base: ["w-full", ...inputBase], - variants: { - error: { - true: "!border-red-900", - }, - }, - }), -); - -export const InputWrapper = createComponent( - "div", - tv({ - base: "flex w-full relative", - variants: {}, - }), -); - -export const InputAddon = createComponent( - "div", - tv({ - base: "absolute right-0 text-gray-900 dark:text-gray-300 inline-flex items-center justify-center h-full border-gray-300 dark:border-gray-800 border-l px-4 font-semibold", - variants: { - disabled: { - true: "text-gray-500 dark:text-gray-500", - }, - }, - }), -); - -export const InputIcon = createComponent( - "div", - tv({ - base: "absolute text-gray-600 left-0 inline-flex items-center justify-center h-full px-4", - }), -); - export const Select = createComponent( "select", tv({ @@ -90,15 +45,20 @@ export const Select = createComponent( export const Checkbox = createComponent( "input", tv({ - base: [...inputBase, "checked:focus:dark:bg-gray-700 checked:hover:dark:bg-gray-700"], + base: [...inputBase, "rounded-none checked:focus:dark:bg-gray-700 checked:hover:dark:bg-gray-700"], }), ); export const Label = createComponent( "label", tv({ - base: "block tracking-wider dark:text-gray-300 font-semibold", - variants: { required: { true: "after:content-['*']" } }, + base: "block tracking-wider font-semibold dark:text-white", + variants: { + required: { + true: "after:content-['*'] after:text-blue-400", + false: "after:content-['(optional)'] after:text-gray-300 after:text-sm after:font-semibold after:ml-1", + }, + }, }), ); @@ -107,12 +67,12 @@ export const ErrorMessage = createComponent("div", tv({ base: "pt-1 text-xs text export const Textarea = createComponent("textarea", tv({ base: [...inputBase, "w-full"] })); export const SearchInput = forwardRef(({ ...props }: ComponentPropsWithRef, ref) => ( - + - + )); @@ -145,12 +105,16 @@ export const FormControl = ({ const error = index && errors[index]; return ( -
- {label && ( - - )} +
+
+ {label && ( + + )} + + {hint && } +
{cloneElement(children as ReactElement, { id: name, @@ -158,17 +122,19 @@ export const FormControl = ({ ...register(name, { valueAsNumber }), })} - {hint &&
{hint}
} - {error && {error.message}}
); }; export const FieldArray = ({ + title, + description, name, renderField, }: { + title: string; + description: string; name: string; renderField: (field: z.infer, index: number) => ReactNode; }): JSX.Element => { @@ -182,7 +148,11 @@ export const FieldArray = ({ return (
- {error &&
{String(error)}
} +

{title}

+ +

{description}

+ + {error &&
{String(error)}
} {fields.map((field, i) => (
@@ -202,11 +172,12 @@ export const FieldArray = ({
))} -
+
{ append({}); }} @@ -214,6 +185,8 @@ export const FieldArray = ({ Add row
+ +
); }; @@ -222,13 +195,14 @@ export const FormSection = ({ title, description, children, + ...props }: { title: string; description: string } & ComponentProps<"section">): JSX.Element => ( -
-

{title}

+
+ {title} -

{description}

+

{description}

- {children} +
{children}
); diff --git a/src/components/ui/Heading.tsx b/packages/interface/src/components/ui/Heading.tsx similarity index 70% rename from src/components/ui/Heading.tsx rename to packages/interface/src/components/ui/Heading.tsx index 97f77d93..3854cf49 100644 --- a/src/components/ui/Heading.tsx +++ b/packages/interface/src/components/ui/Heading.tsx @@ -5,14 +5,16 @@ import { createComponent } from "."; export const Heading = createComponent( "div", tv({ - base: "font-bold", + base: "font-bold dark:text-white font-mono uppercase", variants: { size: { md: "text-base", lg: "text-lg mt-2 mb-1 ", xl: "text-xl ", "2xl": "text-2xl mt-8 mb-4 ", - "3xl": "text-3xl mt-8 mb-4 ", + "3xl": "text-[32px]", + "4xl": "text-[40px]", + "6xl": "text-6xl mb-8", }, }, defaultVariants: { diff --git a/packages/interface/src/components/ui/Input.tsx b/packages/interface/src/components/ui/Input.tsx new file mode 100644 index 00000000..b239c378 --- /dev/null +++ b/packages/interface/src/components/ui/Input.tsx @@ -0,0 +1,57 @@ +import { tv } from "tailwind-variants"; + +import { createComponent } from "."; + +export const inputBase = [ + "disabled:opacity-30", + "checked:bg-gray-800", + "outline-none", + "border-gray-200", + "rounded-lg", + "border", + "py-2", + "px-1", + "placeholder:text-gray-300", + "dark:border-gray-800", + "dark:bg-black", + "dark:text-white", +]; + +export const Input = createComponent( + "input", + tv({ + base: ["w-full", ...inputBase], + variants: { + error: { + true: "!border-red-900", + }, + }, + }), +); + +export const InputWrapper = createComponent( + "div", + tv({ + base: "flex w-full relative", + variants: {}, + }), +); + +export const InputAddon = createComponent( + "div", + tv({ + base: "absolute right-0 text-gray-900 inline-flex items-center justify-center h-full border-gray-300 border-l px-4 font-semibold", + variants: { + disabled: { + true: "text-gray-500", + }, + }, + }), +); + +export const InputIcon = createComponent( + "div", + tv({ + base: "absolute text-gray-600 left-0 inline-flex items-center justify-center h-full px-4", + }), +); diff --git a/src/components/ui/Link.tsx b/packages/interface/src/components/ui/Link.tsx similarity index 88% rename from src/components/ui/Link.tsx rename to packages/interface/src/components/ui/Link.tsx index aef90b5b..5b8bbba8 100644 --- a/src/components/ui/Link.tsx +++ b/packages/interface/src/components/ui/Link.tsx @@ -9,7 +9,7 @@ import { createComponent } from "."; export const Link = createComponent( NextLink, tv({ - base: "font-semibold underline-offset-2 hover:underline text-secondary-600", + base: "flex items-center gap-1 text-blue-400 hover:underline", }), ); diff --git a/packages/interface/src/components/ui/Logo.tsx b/packages/interface/src/components/ui/Logo.tsx new file mode 100644 index 00000000..017eeb8f --- /dev/null +++ b/packages/interface/src/components/ui/Logo.tsx @@ -0,0 +1,15 @@ +import Image from "next/image"; + +import { config, metadata } from "~/config"; + +export const Logo = (): JSX.Element => ( +
+ {config.logoUrl ? ( + logo + ) : ( +
+ {metadata.title} +
+ )} +
+); diff --git a/src/components/ui/Markdown.tsx b/packages/interface/src/components/ui/Markdown.tsx similarity index 71% rename from src/components/ui/Markdown.tsx rename to packages/interface/src/components/ui/Markdown.tsx index 278e2cc8..1700e827 100644 --- a/src/components/ui/Markdown.tsx +++ b/packages/interface/src/components/ui/Markdown.tsx @@ -8,8 +8,8 @@ export interface IMarkdownProps extends ComponentProps { export const Markdown = ({ isLoading = false, ...props }: IMarkdownProps): JSX.Element => (
diff --git a/packages/interface/src/components/ui/Navigation.tsx b/packages/interface/src/components/ui/Navigation.tsx new file mode 100644 index 00000000..6f50c170 --- /dev/null +++ b/packages/interface/src/components/ui/Navigation.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +interface INavigationProps { + projectName: string; +} + +export const Navigation = ({ projectName }: INavigationProps): JSX.Element => ( +
+ + Projects + + + {">"} + + + {projectName} + +
+); diff --git a/packages/interface/src/components/ui/Notice.tsx b/packages/interface/src/components/ui/Notice.tsx new file mode 100644 index 00000000..03fa575f --- /dev/null +++ b/packages/interface/src/components/ui/Notice.tsx @@ -0,0 +1,46 @@ +import clsx from "clsx"; +import { RiErrorWarningLine } from "react-icons/ri"; +import { tv } from "tailwind-variants"; + +import { createComponent } from "."; + +const notice = tv({ + base: "w-full flex items-start text-sm justify-center gap-1 text-base", + variants: { + variant: { + default: "text-blue-400", + block: "text-blue-700 bg-blue-400 border border-blue-700 rounded-lg p-4", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +const NoticeContainer = createComponent("div", notice); + +interface INoticeProps { + content: string; + variant?: string; + italic?: boolean; + title?: string; +} + +export const Notice = ({ + content, + variant = undefined, + italic = false, + title = undefined, +}: INoticeProps): JSX.Element => ( + + + + + +
+ {title ?? null} + +

{content}

+
+
+); diff --git a/src/components/ui/Progress.tsx b/packages/interface/src/components/ui/Progress.tsx similarity index 88% rename from src/components/ui/Progress.tsx rename to packages/interface/src/components/ui/Progress.tsx index 0cb75f9c..365b7c23 100644 --- a/src/components/ui/Progress.tsx +++ b/packages/interface/src/components/ui/Progress.tsx @@ -17,7 +17,7 @@ export interface IProgressProps { export const Progress = ({ value = 0, max = 100 }: IProgressProps): JSX.Element => (
diff --git a/src/components/ui/Skeleton.tsx b/packages/interface/src/components/ui/Skeleton.tsx similarity index 67% rename from src/components/ui/Skeleton.tsx rename to packages/interface/src/components/ui/Skeleton.tsx index 134c5f53..0a6199bc 100644 --- a/src/components/ui/Skeleton.tsx +++ b/packages/interface/src/components/ui/Skeleton.tsx @@ -8,9 +8,7 @@ export const Skeleton = ({ children, }: ComponentProps<"span"> & { isLoading?: boolean }): JSX.Element => isLoading ? ( - + ) : (
{children}
); diff --git a/src/components/ui/Spinner.tsx b/packages/interface/src/components/ui/Spinner.tsx similarity index 100% rename from src/components/ui/Spinner.tsx rename to packages/interface/src/components/ui/Spinner.tsx diff --git a/packages/interface/src/components/ui/Table.tsx b/packages/interface/src/components/ui/Table.tsx new file mode 100644 index 00000000..7be40608 --- /dev/null +++ b/packages/interface/src/components/ui/Table.tsx @@ -0,0 +1,25 @@ +import { tv } from "tailwind-variants"; + +import { createComponent } from "."; + +export const Table = createComponent( + "table", + tv({ + base: "w-full border-separate border-spacing-y-4 border-spacing-x-0", + }), +); +export const Thead = createComponent("thead", tv({ base: "" })); +export const Tbody = createComponent("tbody", tv({ base: "" })); +export const Tr = createComponent("tr", tv({ base: "" })); +export const Td = createComponent( + "td", + tv({ + base: "p-4 border-y border-gray-200", + variants: { + variant: { + first: "border-l rounded-l-lg", + last: "border-r rounded-r-lg", + }, + }, + }), +); diff --git a/src/components/ui/Tag.tsx b/packages/interface/src/components/ui/Tag.tsx similarity index 66% rename from src/components/ui/Tag.tsx rename to packages/interface/src/components/ui/Tag.tsx index ead931dd..953545da 100644 --- a/src/components/ui/Tag.tsx +++ b/packages/interface/src/components/ui/Tag.tsx @@ -5,7 +5,7 @@ import { createComponent } from "."; export const Tag = createComponent( "div", tv({ - base: "cursor-pointer inline-flex items-center border border-gray-200 justify-center gap-2 bg-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 text-gray-700 whitespace-nowrap transition", + base: "cursor-pointer inline-flex items-center border border-blue-400 justify-center gap-2 text-blue-400 whitespace-nowrap transition hover:bg-blue-50", variants: { size: { sm: "rounded py-1 px-2 text-xs", @@ -13,10 +13,10 @@ export const Tag = createComponent( lg: "rounded-xl py-2 px-4 text-lg", }, selected: { - true: "border-gray-900 dark:border-gray-300", + true: "bg-blue-400 text-white", }, disabled: { - true: "opacity-50 cursor-not-allowed", + true: "border-gray-200 text-gray-200 cursor-not-allowed", }, }, defaultVariants: { diff --git a/packages/interface/src/components/ui/Tooltip.tsx b/packages/interface/src/components/ui/Tooltip.tsx new file mode 100644 index 00000000..b87a0c1c --- /dev/null +++ b/packages/interface/src/components/ui/Tooltip.tsx @@ -0,0 +1,30 @@ +import { useState, useCallback } from "react"; +import { CiCircleQuestion } from "react-icons/ci"; + +interface ITooltipProps { + description: string; +} + +export const Tooltip = ({ description }: ITooltipProps): JSX.Element => { + const [showBlock, setShowBlock] = useState(false); + + const handleShowBlock = useCallback(() => { + setShowBlock(true); + }, [setShowBlock]); + + const handleHideBlock = useCallback(() => { + setShowBlock(false); + }, [setShowBlock]); + + return ( +
+ + + {showBlock && ( +
+ {description} +
+ )} +
+ ); +}; diff --git a/src/components/ui/index.tsx b/packages/interface/src/components/ui/index.tsx similarity index 100% rename from src/components/ui/index.tsx rename to packages/interface/src/components/ui/index.tsx diff --git a/packages/interface/src/config.ts b/packages/interface/src/config.ts new file mode 100644 index 00000000..f31341d5 --- /dev/null +++ b/packages/interface/src/config.ts @@ -0,0 +1,124 @@ +import * as wagmiChains from "wagmi/chains"; + +export const metadata = { + title: "MACI PLATFORM", + description: "Open-source Retro Public Goods Funding platform with MACI for private on chain voting/", + url: "https://maci-rpgf.vercel.app", + image: "/api/og", +}; + +const parseDate = (env?: string) => (env ? new Date(env) : undefined); + +// URLs for the EAS GraphQL endpoint for each chain +const easScanUrl = { + ethereum: "https://easscan.org/graphql", + optimism: "https://optimism.easscan.org/graphql", + optimismSepolia: "https://optimism-sepolia.easscan.org/graphql", + arbitrum: " https://arbitrum.easscan.org/graphql", + linea: "https://linea.easscan.org/graphql", + sepolia: "https://sepolia.easscan.org/graphql", + base: "https://base.easscan.org/graphql", +}; + +// EAS contract addresses for each chain +const easContractAddresses = { + ethereum: "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587", + optimism: "0x4200000000000000000000000000000000000021", + optimismSepolia: "0x4200000000000000000000000000000000000021", + arbitrum: "0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458", + linea: "0xaEF4103A04090071165F78D45D83A0C0782c2B2a", + sepolia: "0xC2679fBD37d54388Ce493F1DB75320D236e1815e", + base: "0x4200000000000000000000000000000000000021", +}; + +// EAS Schema Registry contract addresses for each chain +const easSchemaRegistryContractAddresses = { + ethereum: "0xA7b39296258348C78294F95B872b282326A97BDF", + optimism: "0x4200000000000000000000000000000000000020", + optimismSepolia: "0x4200000000000000000000000000000000000020", + arbitrum: "0xA310da9c5B885E7fb3fbA9D66E9Ba6Df512b78eB", + linea: "0x55D26f9ae0203EF95494AE4C170eD35f4Cf77797", + sepolia: "0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0", + base: "0x4200000000000000000000000000000000000020", +}; + +/** + * Convert the chain name for the semaphore ethers library + * @returns the chain name for the semaphore ethers library + */ +export const semaphoreEthersChain = (): string => { + switch (process.env.NEXT_PUBLIC_CHAIN_NAME) { + case "optimismSepolia": + return "optimism-sepolia"; + default: + return process.env.NEXT_PUBLIC_CHAIN_NAME!; + } +}; + +/** + * Get the RPC URL based on the network we are connected to + * @returns the alchemy RPC URL + */ +export const getRPCURL = (): string | undefined => { + switch (process.env.NEXT_PUBLIC_CHAIN_NAME) { + case "optimismSepolia": + return `https://opt-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_ID!}`; + default: + return undefined; + } +}; + +export const config = { + logoUrl: "/Logo.svg", + pageSize: 3 * 4, + // TODO: temp solution until we come up with solid one + // https://github.com/privacy-scaling-explorations/maci-platform/issues/31 + voteLimit: 50, + startsAt: parseDate(process.env.NEXT_PUBLIC_START_DATE), + registrationEndsAt: parseDate(process.env.NEXT_PUBLIC_REGISTRATION_END_DATE), + resultsAt: parseDate(process.env.NEXT_PUBLIC_RESULTS_DATE), + skipApprovedVoterCheck: ["true", "1"].includes(process.env.NEXT_PUBLIC_SKIP_APPROVED_VOTER_CHECK!), + tokenName: process.env.NEXT_PUBLIC_TOKEN_NAME!, + eventName: process.env.NEXT_PUBLIC_EVENT_NAME ?? "MACI-RPGF", + roundId: process.env.NEXT_PUBLIC_ROUND_ID!, + admin: (process.env.NEXT_PUBLIC_ADMIN_ADDRESS ?? "") as `0x${string}`, + network: wagmiChains[process.env.NEXT_PUBLIC_CHAIN_NAME as keyof typeof wagmiChains], + maciAddress: process.env.NEXT_PUBLIC_MACI_ADDRESS, + maciStartBlock: Number(process.env.NEXT_PUBLIC_MACI_START_BLOCK ?? 0), + maciSubgraphUrl: process.env.NEXT_PUBLIC_MACI_SUBGRAPH_URL ?? "", + tallyUrl: process.env.NEXT_PUBLIC_TALLY_URL, + roundOrganizer: process.env.NEXT_PUBLIC_ROUND_ORGANIZER ?? "PSE", + pollMode: process.env.NEXT_PUBLIC_POLL_MODE ?? "non-qv", + roundLogo: process.env.NEXT_PUBLIC_ROUND_LOGO, +}; + +export const theme = { + colorMode: "light", +}; + +export const eas = { + url: easScanUrl[process.env.NEXT_PUBLIC_CHAIN_NAME as keyof typeof easScanUrl], + attesterAddress: process.env.NEXT_PUBLIC_APPROVED_APPLICATIONS_ATTESTER ?? "", + + contracts: { + eas: easContractAddresses[process.env.NEXT_PUBLIC_CHAIN_NAME as keyof typeof easContractAddresses], + schemaRegistry: + easSchemaRegistryContractAddresses[ + process.env.NEXT_PUBLIC_CHAIN_NAME as keyof typeof easSchemaRegistryContractAddresses + ], + }, + schemas: { + metadata: process.env.NEXT_PUBLIC_METADATA_SCHEMA!, + approval: process.env.NEXT_PUBLIC_APPROVAL_SCHEMA!, + }, +}; + +export const impactCategories = { + ETHEREUM_INFRASTRUCTURE: { label: "Ethereum Infrastructure" }, + OPEN_SOURCE: { label: "Web3 Open Source Software" }, + COMMUNITY_EDUCATION: { label: "Web3 Community & Education" }, + COLLECTIVE_GOVERNANCE: { label: "Collective Governance" }, + OP_STACK: { label: "OP Stack" }, + DEVELOPER_ECOSYSTEM: { label: "Developer Ecosystem" }, + END_USER_EXPERIENCE_AND_ADOPTION: { label: "End user UX" }, +} as const; diff --git a/src/contexts/Ballot.tsx b/packages/interface/src/contexts/Ballot.tsx similarity index 87% rename from src/contexts/Ballot.tsx rename to packages/interface/src/contexts/Ballot.tsx index 06c59ded..137b23c4 100644 --- a/src/contexts/Ballot.tsx +++ b/packages/interface/src/contexts/Ballot.tsx @@ -8,10 +8,11 @@ import type { Ballot, Vote } from "~/features/ballot/types"; export const BallotContext = createContext(undefined); -const defaultBallot = { votes: [], published: false }; +const defaultBallot = { votes: [], published: false, edited: false }; export const BallotProvider: React.FC = ({ children }: BallotProviderProps) => { const [ballot, setBallot] = useState(defaultBallot); + const [isLoading, setLoading] = useState(true); const { isDisconnected } = useAccount(); @@ -37,6 +38,7 @@ export const BallotProvider: React.FC = ({ children }: Ball (addedVotes: Vote[], pollId: string) => ({ ...ballot, pollId, + edited: true, votes: Object.values({ ...toObject("projectId", ballot.votes), ...toObject("projectId", addedVotes), @@ -55,7 +57,7 @@ export const BallotProvider: React.FC = ({ children }: Ball (projectId: string) => { const votes = ballot.votes.filter((v) => v.projectId !== projectId); - setBallot({ ...ballot, votes, published: false }); + setBallot({ ...ballot, votes }); }, [ballot, setBallot], ); @@ -80,7 +82,7 @@ export const BallotProvider: React.FC = ({ children }: Ball // set published to true const publishBallot = useCallback(() => { - setBallot({ ...ballot, published: true }); + setBallot({ ...ballot, published: true, edited: false }); }, [ballot, setBallot]); /// Read existing ballot in localStorage @@ -90,6 +92,7 @@ export const BallotProvider: React.FC = ({ children }: Ball ) as typeof defaultBallot; setBallot(savedBallot); + setLoading(false); }, [setBallot]); /// store ballot to localStorage once it changes @@ -108,6 +111,7 @@ export const BallotProvider: React.FC = ({ children }: Ball const value = useMemo( () => ({ ballot, + isLoading, addToBallot, removeFromBallot, deleteBallot, @@ -115,18 +119,18 @@ export const BallotProvider: React.FC = ({ children }: Ball sumBallot, publishBallot, }), - [ballot, addToBallot, removeFromBallot, deleteBallot, ballotContains, sumBallot, publishBallot], + [ballot, isLoading, addToBallot, removeFromBallot, deleteBallot, ballotContains, sumBallot, publishBallot], ); return {children}; }; export const useBallot = (): BallotContextType => { - const context = useContext(BallotContext); + const ballotContext = useContext(BallotContext); - if (!context) { + if (!ballotContext) { throw new Error("Should use context inside provider."); } - return context; + return ballotContext; }; diff --git a/src/contexts/Maci.tsx b/packages/interface/src/contexts/Maci.tsx similarity index 67% rename from src/contexts/Maci.tsx rename to packages/interface/src/contexts/Maci.tsx index 27157cbe..a5d45e4e 100644 --- a/src/contexts/Maci.tsx +++ b/packages/interface/src/contexts/Maci.tsx @@ -1,5 +1,7 @@ /* eslint-disable no-console */ +import { Identity } from "@semaphore-protocol/core"; import { isAfter } from "date-fns"; +import { type Signer, BrowserProvider } from "ethers"; import { signup, isRegisteredUser, @@ -8,6 +10,8 @@ import { type IGetPollData, getPoll, genKeyPair, + GatekeeperTrait, + getGatekeeperTrait, } from "maci-cli/sdk"; import React, { createContext, useContext, useCallback, useEffect, useMemo, useState } from "react"; import { useAccount, useSignMessage } from "wagmi"; @@ -15,12 +19,19 @@ import { useAccount, useSignMessage } from "wagmi"; import { config } from "~/config"; import { useEthersSigner } from "~/hooks/useEthersSigner"; import { api } from "~/utils/api"; +import { getSemaphoreProof } from "~/utils/semaphore"; import type { IVoteArgs, MaciContextType, MaciProviderProps } from "./types"; -import type { Attestation } from "~/utils/fetchAttestations"; +import type { EIP1193Provider } from "viem"; +import type { Attestation } from "~/utils/types"; export const MaciContext = createContext(undefined); +/** + * All MACI's related functionality is handled here + * @param MaciProviderProps - the args to be passed to the provider + * @returns The Context data (variables and functions) + */ export const MaciProvider: React.FC = ({ children }: MaciProviderProps) => { const signer = useEthersSigner(); const { address, isConnected, isDisconnected } = useAccount(); @@ -33,11 +44,15 @@ export const MaciProvider: React.FC = ({ children }: MaciProv const [pollData, setPollData] = useState(); const [tallyData, setTallyData] = useState(); + const [semaphoreIdentity, setSemaphoreIdentity] = useState(); const [maciPrivKey, setMaciPrivKey] = useState(); const [maciPubKey, setMaciPubKey] = useState(); const [signatureMessage, setSignatureMessage] = useState(""); + const [gatekeeperTrait, setGatekeeperTrait] = useState(); + const [sgData, setSgData] = useState(); + const { signMessageAsync } = useSignMessage(); const user = api.maci.user.useQuery( { publicKey: maciPubKey ?? "" }, @@ -46,10 +61,33 @@ export const MaciProvider: React.FC = ({ children }: MaciProv const poll = api.maci.poll.useQuery(undefined, { enabled: Boolean(config.maciSubgraphUrl) }); - const attestations = api.voters.approvedAttestations.useQuery({ - address, - }); + // fetch the gatekeeper trait + useEffect(() => { + if (!signer) { + return; + } + + const fetchGatekeeperType = async () => { + const gatekeeperType = await getGatekeeperTrait({ + maciAddress: config.maciAddress!, + signer: signer as Signer, + }); + + setGatekeeperTrait(gatekeeperType); + }; + fetchGatekeeperType(); + }, [signer]); + + // only fetch the attestations if the gatekeeper trait is EAS + const attestations = api.voters.approvedAttestations.useQuery( + { + address, + }, + { enabled: gatekeeperTrait === GatekeeperTrait.EAS }, + ); + + // fetch the voting attestation (only if gatekeeper is EAS) const attestationId = useMemo(() => { const values = attestations.data?.valueOf() as Attestation[] | undefined; @@ -58,19 +96,60 @@ export const MaciProvider: React.FC = ({ children }: MaciProv return attestation?.id; }, [attestations]); - const isEligibleToVote = useMemo(() => Boolean(attestationId) && Boolean(address), [attestationId, address]); + // fetch setup sgData for MACI signup + // the signup gatekeeper data will change based on the gatekeeper in use + // for EAS it's the attestationId + // for Semaphore it will be a proof being part of the group + useEffect(() => { + setIsLoading(true); + + // add custom logic for other gatekeepers here + switch (gatekeeperTrait) { + case GatekeeperTrait.Semaphore: + if (!signer) { + return; + } + getSemaphoreProof(signer, semaphoreIdentity!) + .then((proof) => { + setSgData(proof); + }) + .catch(console.error) + .finally(() => { + setIsLoading(false); + }); + break; + case GatekeeperTrait.EAS: + setSgData(attestationId); + setIsLoading(false); + break; + default: + break; + } + }, [gatekeeperTrait, attestationId, semaphoreIdentity, signer]); + + // a user is eligible to vote if they pass certain conditions + // with gatekeepers like EAS it is possible to determine whether you are allowed + // just by fetching the attestation. On the other hand, with other + // gatekeepers it might be more difficult to determine it + // for instance with semaphore + const isEligibleToVote = useMemo(() => Boolean(sgData) && Boolean(address), [sgData, address]); // on load get the key pair from local storage and set the signature message useEffect(() => { - setSignatureMessage(`Generate MACI Key Pair at ${window.location.origin}`); + setSignatureMessage(`Generate your EdDSA Key Pair at ${window.location.origin}`); const storedMaciPrivKey = localStorage.getItem("maciPrivKey"); const storedMaciPubKey = localStorage.getItem("maciPubKey"); + const storedSemaphoreIdentity = localStorage.getItem("semaphoreIdentity"); if (storedMaciPrivKey && storedMaciPubKey) { setMaciPrivKey(storedMaciPrivKey); setMaciPubKey(storedMaciPubKey); } - }, [setMaciPrivKey, setMaciPubKey]); + + if (storedSemaphoreIdentity) { + setSemaphoreIdentity(new Identity(storedSemaphoreIdentity)); + } + }, [setMaciPrivKey, setMaciPubKey, setSemaphoreIdentity]); // on load we fetch the data from the poll useEffect(() => { @@ -81,6 +160,7 @@ export const MaciProvider: React.FC = ({ children }: MaciProv poll.refetch().catch(console.error); }, [poll]); + // generate the maci keypair using a ECDSA signature const generateKeypair = useCallback(async () => { // if we are not connected then do not generate the key pair if (!address) { @@ -88,21 +168,26 @@ export const MaciProvider: React.FC = ({ children }: MaciProv } const signature = await signMessageAsync({ message: signatureMessage }); + const newSemaphoreIdentity = new Identity(signature); const userKeyPair = genKeyPair({ seed: BigInt(signature) }); localStorage.setItem("maciPrivKey", userKeyPair.privateKey); localStorage.setItem("maciPubKey", userKeyPair.publicKey); + localStorage.setItem("semaphoreIdentity", newSemaphoreIdentity.privateKey.toString()); setMaciPrivKey(userKeyPair.privateKey); setMaciPubKey(userKeyPair.publicKey); - }, [address, signatureMessage, signMessageAsync, setMaciPrivKey, setMaciPubKey]); + setSemaphoreIdentity(newSemaphoreIdentity); + }, [address, signatureMessage, signMessageAsync, setMaciPrivKey, setMaciPubKey, setSemaphoreIdentity]); + // memo to calculate the voting end date const votingEndsAt = useMemo( () => (pollData ? new Date(Number(pollData.deployTime) * 1000 + Number(pollData.duration) * 1000) : new Date()), [pollData?.deployTime, pollData?.duration], ); + // function to be used to signup to MACI const onSignup = useCallback( async (onError: () => void) => { - if (!signer || !maciPubKey || !attestationId) { + if (!signer || !maciPubKey || !sgData) { return; } @@ -112,7 +197,7 @@ export const MaciProvider: React.FC = ({ children }: MaciProv const { stateIndex: index } = await signup({ maciPubKey, maciAddress: config.maciAddress!, - sgDataArg: attestationId, + sgDataArg: sgData, signer, }); @@ -122,14 +207,15 @@ export const MaciProvider: React.FC = ({ children }: MaciProv } } catch (e) { onError(); - console.error("error happened:", e); + console.error("signup error:", e); } finally { setIsLoading(false); } }, - [attestationId, maciPubKey, signer, setIsRegistered, setStateIndex, setIsLoading], + [maciPubKey, signer, setIsRegistered, setStateIndex, setIsLoading, sgData], ); + // function to be used to vote on a poll const onVote = useCallback( async (votes: IVoteArgs[], onError: () => Promise, onSuccess: () => Promise) => { if (!signer || !stateIndex || !pollData) { @@ -137,7 +223,7 @@ export const MaciProvider: React.FC = ({ children }: MaciProv } if (!votes.length) { - await onError(); + onError(); setError("No votes provided"); return; } @@ -176,8 +262,10 @@ export const MaciProvider: React.FC = ({ children }: MaciProv if (isDisconnected) { setMaciPrivKey(undefined); setMaciPubKey(undefined); + setSemaphoreIdentity(undefined); localStorage.removeItem("maciPrivKey"); localStorage.removeItem("maciPubKey"); + localStorage.removeItem("semaphoreIdentity"); } }, [isDisconnected]); @@ -193,7 +281,7 @@ export const MaciProvider: React.FC = ({ children }: MaciProv user.refetch().catch(console.error); }, [maciPubKey, user]); - /// check if the user already registered + // check if the user already registered useEffect(() => { if (!isConnected || !signer || !maciPubKey || !address || isLoading) { return; @@ -237,10 +325,6 @@ export const MaciProvider: React.FC = ({ children }: MaciProv /// check the poll data and tally data useEffect(() => { - if (!signer) { - return; - } - setIsLoading(true); // if we have the subgraph url then it means we can get the poll data through there @@ -250,11 +334,11 @@ export const MaciProvider: React.FC = ({ children }: MaciProv return; } - const { isStateAqMerged, id } = poll.data; + const { isMerged, id } = poll.data; setPollData(poll.data); - if (isStateAqMerged) { + if (isMerged) { fetch(`${config.tallyUrl}/tally-${id}.json`) .then((res) => res.json() as Promise) .then((res) => { @@ -265,17 +349,25 @@ export const MaciProvider: React.FC = ({ children }: MaciProv setIsLoading(false); } else { + if (!window.ethereum) { + return; + } + + const provider = new BrowserProvider(window.ethereum as unknown as EIP1193Provider, { + chainId: config.network.id, + name: config.network.name, + }); + getPoll({ maciAddress: config.maciAddress!, - signer, - provider: signer.provider, + provider, }) .then((data) => { setPollData(data); return data; }) .then(async (data) => { - if (!data.isStateAqMerged || isAfter(votingEndsAt, new Date())) { + if (!data.isMerged || isAfter(votingEndsAt, new Date())) { return undefined; } @@ -329,11 +421,11 @@ export const MaciProvider: React.FC = ({ children }: MaciProv }; export const useMaci = (): MaciContextType => { - const context = useContext(MaciContext); + const maciContext = useContext(MaciContext); - if (!context) { + if (!maciContext) { throw new Error("Should use context inside provider."); } - return context; + return maciContext; }; diff --git a/src/contexts/types.ts b/packages/interface/src/contexts/types.ts similarity index 91% rename from src/contexts/types.ts rename to packages/interface/src/contexts/types.ts index 02a8f8c5..6e56d6d7 100644 --- a/src/contexts/types.ts +++ b/packages/interface/src/contexts/types.ts @@ -33,8 +33,9 @@ export interface MaciProviderProps { } export interface BallotContextType { - ballot?: Ballot; - addToBallot: (votes: Vote[], pollId: string) => void; + ballot: Ballot; + isLoading: boolean; + addToBallot: (votes: Vote[], pollId?: string) => void; removeFromBallot: (projectId: string) => void; deleteBallot: () => void; ballotContains: (id: string) => Vote | undefined; diff --git a/src/env.js b/packages/interface/src/env.js similarity index 90% rename from src/env.js rename to packages/interface/src/env.js index c13b0d32..1c098597 100644 --- a/src/env.js +++ b/packages/interface/src/env.js @@ -31,7 +31,6 @@ module.exports = createEnv({ "baseSepolia", "localhost", ]), - NEXT_PUBLIC_SIGN_STATEMENT: z.string().optional(), NEXT_PUBLIC_FEEDBACK_URL: z.string().default("#"), @@ -51,14 +50,11 @@ module.exports = createEnv({ .string() .default("0xac4c92fc5c7babed88f78a917cdbcdc1c496a8f4ab2d5b2ec29402736b2cf929"), - NEXT_PUBLIC_EAS_CONTRACT_ADDRESS: z.string().default("0x4200000000000000000000000000000000000021"), - - NEXT_PUBLIC_EASSCAN_URL: z.string().default("https://optimism.easscan.org/graphql"), - NEXT_PUBLIC_ADMIN_ADDRESS: z.string().startsWith("0x"), NEXT_PUBLIC_APPROVAL_SCHEMA: z.string().startsWith("0x"), NEXT_PUBLIC_METADATA_SCHEMA: z.string().startsWith("0x"), + NEXT_PUBLIC_EVENT_NAME: z.string().optional(), NEXT_PUBLIC_ROUND_ID: z.string(), NEXT_PUBLIC_WALLETCONNECT_ID: z.string().optional(), NEXT_PUBLIC_ALCHEMY_ID: z.string().optional(), @@ -72,6 +68,7 @@ module.exports = createEnv({ NEXT_PUBLIC_TALLY_URL: z.string().url(), NEXT_PUBLIC_POLL_MODE: z.enum(["qv", "non-qv"]).default("non-qv"), + NEXT_PUBLIC_ROUND_LOGO: z.string().optional(), }, /** @@ -82,7 +79,6 @@ module.exports = createEnv({ NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_CHAIN_NAME: process.env.NEXT_PUBLIC_CHAIN_NAME, - NEXT_PUBLIC_SIGN_STATEMENT: process.env.NEXT_PUBLIC_SIGN_STATEMENT, NEXT_PUBLIC_FEEDBACK_URL: process.env.NEXT_PUBLIC_FEEDBACK_URL, @@ -93,8 +89,6 @@ module.exports = createEnv({ NEXT_PUBLIC_BADGEHOLDER_ATTESTER: process.env.NEXT_PUBLIC_BADGEHOLDER_ATTESTER, NEXT_PUBLIC_PROFILE_SCHEMA: process.env.NEXT_PUBLIC_PROFILE_SCHEMA, - NEXT_PUBLIC_EAS_CONTRACT_ADDRESS: process.env.NEXT_PUBLIC_EAS_CONTRACT_ADDRESS, - NEXT_PUBLIC_EASSCAN_URL: process.env.NEXT_PUBLIC_EASSCAN_URL, NEXT_PUBLIC_WALLETCONNECT_ID: process.env.NEXT_PUBLIC_WALLETCONNECT_ID, NEXT_PUBLIC_ALCHEMY_ID: process.env.NEXT_PUBLIC_ALCHEMY_ID, NEXT_PUBLIC_SKIP_APPROVED_VOTER_CHECK: process.env.NEXT_PUBLIC_SKIP_APPROVED_VOTER_CHECK, @@ -103,6 +97,7 @@ module.exports = createEnv({ NEXT_PUBLIC_APPROVAL_SCHEMA: process.env.NEXT_PUBLIC_APPROVAL_SCHEMA, NEXT_PUBLIC_METADATA_SCHEMA: process.env.NEXT_PUBLIC_METADATA_SCHEMA, + NEXT_PUBLIC_EVENT_NAME: process.env.NEXT_PUBLIC_EVENT_NAME, NEXT_PUBLIC_ROUND_ID: process.env.NEXT_PUBLIC_ROUND_ID, NEXT_PUBLIC_MACI_ADDRESS: process.env.NEXT_PUBLIC_MACI_ADDRESS, @@ -112,6 +107,7 @@ module.exports = createEnv({ NEXT_PUBLIC_TALLY_URL: process.env.NEXT_PUBLIC_TALLY_URL, NEXT_PUBLIC_POLL_MODE: process.env.NEXT_PUBLIC_POLL_MODE, + NEXT_PUBLIC_ROUND_LOGO: process.env.NEXT_PUBLIC_ROUND_LOGO, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/features/admin/components/InvalidAdmin.tsx b/packages/interface/src/features/admin/components/InvalidAdmin.tsx similarity index 100% rename from src/features/admin/components/InvalidAdmin.tsx rename to packages/interface/src/features/admin/components/InvalidAdmin.tsx diff --git a/packages/interface/src/features/applications/components/ApplicationButtons.tsx b/packages/interface/src/features/applications/components/ApplicationButtons.tsx new file mode 100644 index 00000000..bfff33d3 --- /dev/null +++ b/packages/interface/src/features/applications/components/ApplicationButtons.tsx @@ -0,0 +1,177 @@ +import { useMemo, useCallback, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useAccount } from "wagmi"; + +import { Button, IconButton } from "~/components/ui/Button"; +import { Dialog } from "~/components/ui/Dialog"; +import { Spinner } from "~/components/ui/Spinner"; +import { useIsCorrectNetwork } from "~/hooks/useIsCorrectNetwork"; + +import type { Application } from "../types"; +import type { ImpactMetrix, ContributionLink, FundingSource } from "~/features/projects/types"; + +export enum EApplicationStep { + PROFILE, + ADVANCED, + REVIEW, +} + +interface IApplicationButtonsProps { + step: EApplicationStep; + isUploading: boolean; + isPending: boolean; + onNextStep: () => void; + onBackStep: () => void; +} + +export const ApplicationButtons = ({ + step, + isUploading, + isPending, + onNextStep, + onBackStep, +}: IApplicationButtonsProps): JSX.Element => { + const { isCorrectNetwork } = useIsCorrectNetwork(); + + const { address } = useAccount(); + + const [showDialog, setShowDialog] = useState(false); + + const form = useFormContext(); + + const [ + name, + bio, + payoutAddress, + websiteUrl, + profileImageUrl, + bannerImageUrl, + contributionDescription, + impactDescription, + impactCategory, + contributionLinks, + fundingSources, + ] = useMemo( + () => + form.watch([ + "name", + "bio", + "payoutAddress", + "websiteUrl", + "profileImageUrl", + "bannerImageUrl", + "contributionDescription", + "impactDescription", + "impactCategory", + "contributionLinks", + "fundingSources", + ]), + [form], + ); + + const checkLinks = ( + links: Pick[] | undefined, + ): boolean => + links === undefined || links.every((link) => link.description !== undefined && link.description.length > 0); + + const stepComplete = useMemo((): boolean => { + if (step === EApplicationStep.PROFILE) { + return ( + bannerImageUrl !== undefined && + profileImageUrl !== undefined && + bio.length > 0 && + name.length > 0 && + payoutAddress.length > 0 && + websiteUrl.length > 0 + ); + } + + if (step === EApplicationStep.ADVANCED) { + return ( + impactCategory !== undefined && + impactCategory.length > 0 && + contributionDescription.length > 0 && + impactDescription.length > 0 && + checkLinks(contributionLinks) && + checkLinks(fundingSources) + ); + } + + return true; + }, [ + step, + bannerImageUrl, + profileImageUrl, + bio, + name, + payoutAddress, + websiteUrl, + impactCategory, + contributionDescription, + impactDescription, + contributionLinks, + fundingSources, + ]); + + const handleOnClickNextStep = useCallback( + (event: UIEvent) => { + event.preventDefault(); + + if (stepComplete) { + onNextStep(); + } else { + setShowDialog(true); + } + }, + [onNextStep, setShowDialog, stepComplete], + ); + + const handleOnClickBackStep = useCallback( + (event: UIEvent) => { + event.preventDefault(); + onBackStep(); + }, + [onBackStep], + ); + + const handleOnOpenChange = useCallback(() => { + setShowDialog(false); + }, [setShowDialog]); + + return ( +
+ + + {step !== EApplicationStep.PROFILE && ( + + )} + + {step !== EApplicationStep.REVIEW && ( + + )} + + {step === EApplicationStep.REVIEW && ( + + {isUploading ? "Uploading metadata" : "Submit"} + + )} +
+ ); +}; diff --git a/packages/interface/src/features/applications/components/ApplicationForm.tsx b/packages/interface/src/features/applications/components/ApplicationForm.tsx new file mode 100644 index 00000000..70b6bcff --- /dev/null +++ b/packages/interface/src/features/applications/components/ApplicationForm.tsx @@ -0,0 +1,241 @@ +import { Transaction } from "@ethereum-attestation-service/eas-sdk"; +import { useRouter } from "next/router"; +import { useState, useCallback } from "react"; +import { useLocalStorage } from "react-use"; +import { toast } from "sonner"; +import { useAccount } from "wagmi"; + +import { ImageUpload } from "~/components/ImageUpload"; +import { FieldArray, Form, FormControl, FormSection, Select, Textarea } from "~/components/ui/Form"; +import { Input } from "~/components/ui/Input"; +import { useIsCorrectNetwork } from "~/hooks/useIsCorrectNetwork"; + +import { useCreateApplication } from "../hooks/useCreateApplication"; +import { ApplicationSchema, contributionTypes, fundingSourceTypes } from "../types"; + +import { ApplicationButtons, EApplicationStep } from "./ApplicationButtons"; +import { ApplicationSteps } from "./ApplicationSteps"; +import { ImpactTags } from "./ImpactTags"; +import { ReviewApplicationDetails } from "./ReviewApplicationDetails"; + +export const ApplicationForm = (): JSX.Element => { + const clearDraft = useLocalStorage("application-draft")[2]; + + const { isCorrectNetwork, correctNetwork } = useIsCorrectNetwork(); + + const { address } = useAccount(); + + const router = useRouter(); + + /** + * There are 3 steps for creating an application. + * The first step is to set the project introduction (profile); + * the second step is to set the contributions, impacts, and funding sources (advanced); + * the last step is to review the input values, allow editing by going back to previous steps (review). + */ + const [step, setStep] = useState(EApplicationStep.PROFILE); + + const handleNextStep = useCallback(() => { + if (step === EApplicationStep.PROFILE) { + setStep(EApplicationStep.ADVANCED); + } else if (step === EApplicationStep.ADVANCED) { + setStep(EApplicationStep.REVIEW); + } + }, [step, setStep]); + + const handleBackStep = useCallback(() => { + if (step === EApplicationStep.REVIEW) { + setStep(EApplicationStep.ADVANCED); + } else if (step === EApplicationStep.ADVANCED) { + setStep(EApplicationStep.PROFILE); + } + }, [step, setStep]); + + const create = useCreateApplication({ + onSuccess: (data: Transaction) => { + clearDraft(); + router.push(`/applications/confirmation?txHash=${data.tx.hash}`); + }, + onError: (err: { reason?: string; data?: { message: string } }) => + toast.error("Application create error", { + description: err.reason ?? err.data?.message, + }), + }); + + const { error: createError } = create; + + return ( +
+ + +
{ + create.mutate(application); + }} + > + + + + + + +