From 601df8d4b2fd25cc3effc62e5d7c902deec0fb09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matev=C5=BE=20Jekovec?= Date: Thu, 25 Apr 2024 15:17:15 +0200 Subject: [PATCH] intial commit forked from demo-starter --- .github/workflows/ci-test.yaml | 116 +++++++ LICENSE | 202 +++++++++++ README.md | 161 +++++++++ backend/.gitignore | 7 + backend/.solhint.json | 8 + backend/contracts/Quiz.sol | 218 ++++++++++++ backend/hardhat.config.ts | 328 ++++++++++++++++++ backend/package.json | 65 ++++ backend/src/index.ts | 3 + backend/test-coupons.txt | 50 +++ backend/test-questions.json | 88 +++++ backend/test/Quiz.ts | 189 ++++++++++ backend/tsconfig.json | 8 + backend/tsconfig/base.json | 12 + backend/tsconfig/cjs.json | 7 + backend/tsconfig/esm.json | 7 + frontend/.env.development | 7 + frontend/.env.production | 7 + frontend/.eslintrc.cjs | 15 + frontend/.gitignore | 30 ++ frontend/env.d.ts | 1 + frontend/favicon.ico | Bin 0 -> 4286 bytes frontend/index.html | 13 + frontend/package.json | 56 +++ frontend/postcss.config.js | 8 + frontend/src/App.vue | 17 + frontend/src/assets/images/logo-rtk.svg | 42 +++ frontend/src/assets/images/logo-white.svg | 15 + frontend/src/assets/images/logo.svg | 82 +++++ frontend/src/assets/main.css | 37 ++ frontend/src/components/AppButton.vue | 80 +++++ frontend/src/components/AppFooter.vue | 12 + frontend/src/components/AppHeader.vue | 20 ++ frontend/src/components/CheckIcon.vue | 19 + frontend/src/components/CheckedIcon.vue | 19 + frontend/src/components/JazzIcon.vue | 23 ++ frontend/src/components/MessageLoader.vue | 13 + frontend/src/components/QuizDetailsLoader.vue | 14 + frontend/src/components/SuccessIcon.vue | 19 + frontend/src/components/SuccessInfo.vue | 17 + frontend/src/components/UncheckedIcon.vue | 16 + frontend/src/contracts.ts | 22 ++ frontend/src/main.ts | 13 + frontend/src/router.ts | 26 ++ frontend/src/stores/ethereum.ts | 88 +++++ frontend/src/utils/errors.ts | 5 + frontend/src/utils/promise.ts | 24 ++ frontend/src/utils/useMediaQuery.ts | 27 ++ frontend/src/utils/utils.ts | 5 + frontend/src/views/404View.vue | 6 + frontend/src/views/HomeView.vue | 81 +++++ frontend/src/views/QuizView.vue | 259 ++++++++++++++ frontend/tailwind.config.js | 16 + frontend/tsconfig.config.json | 8 + frontend/tsconfig.json | 16 + frontend/vite.config.ts | 26 ++ pnpm-workspace.yaml | 1 + 57 files changed, 2674 insertions(+) create mode 100644 .github/workflows/ci-test.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/.gitignore create mode 100644 backend/.solhint.json create mode 100644 backend/contracts/Quiz.sol create mode 100644 backend/hardhat.config.ts create mode 100644 backend/package.json create mode 100644 backend/src/index.ts create mode 100644 backend/test-coupons.txt create mode 100644 backend/test-questions.json create mode 100644 backend/test/Quiz.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/tsconfig/base.json create mode 100644 backend/tsconfig/cjs.json create mode 100644 backend/tsconfig/esm.json create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.gitignore create mode 100644 frontend/env.d.ts create mode 100644 frontend/favicon.ico create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/images/logo-rtk.svg create mode 100644 frontend/src/assets/images/logo-white.svg create mode 100644 frontend/src/assets/images/logo.svg create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/AppButton.vue create mode 100644 frontend/src/components/AppFooter.vue create mode 100644 frontend/src/components/AppHeader.vue create mode 100644 frontend/src/components/CheckIcon.vue create mode 100644 frontend/src/components/CheckedIcon.vue create mode 100644 frontend/src/components/JazzIcon.vue create mode 100644 frontend/src/components/MessageLoader.vue create mode 100644 frontend/src/components/QuizDetailsLoader.vue create mode 100644 frontend/src/components/SuccessIcon.vue create mode 100644 frontend/src/components/SuccessInfo.vue create mode 100644 frontend/src/components/UncheckedIcon.vue create mode 100644 frontend/src/contracts.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/stores/ethereum.ts create mode 100644 frontend/src/utils/errors.ts create mode 100644 frontend/src/utils/promise.ts create mode 100644 frontend/src/utils/useMediaQuery.ts create mode 100644 frontend/src/utils/utils.ts create mode 100644 frontend/src/views/404View.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/QuizView.vue create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.config.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml new file mode 100644 index 0000000..26d87db --- /dev/null +++ b/.github/workflows/ci-test.yaml @@ -0,0 +1,116 @@ +name: ci-test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test-backend-hardhatnode-frontend: + name: test-backend-hardhatnode-frontend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - uses: pnpm/action-setup@v2.4.0 + name: Install pnpm + id: pnpm-install + with: + version: 8 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + working-directory: backend + run: pnpm install + + - name: Build backend + working-directory: backend + run: pnpm build + + - name: Test backend + working-directory: backend + run: pnpm test + + - name: Build frontend + working-directory: frontend + run: pnpm build + + test-backend-sapphire: + name: test-backend-sapphire + runs-on: ubuntu-latest + services: + sapphire-localnet-ci: + image: ghcr.io/oasisprotocol/sapphire-localnet:latest + ports: + - 8545:8545 + - 8546:8546 + env: + OASIS_DEPOSIT: /oasis-deposit -test-mnemonic -n 5 + options: >- + --rm + --health-cmd="/oasis-node debug control wait-ready -a unix:/serverdir/node/net-runner/network/client-0/internal.sock" + --health-start-period=180s + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - uses: pnpm/action-setup@v2.4.0 + name: Install pnpm + id: pnpm-install + with: + version: 8 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + working-directory: backend + run: pnpm install + + - name: Build backend + working-directory: backend + run: pnpm build + + - name: Test backend + working-directory: backend + run: pnpm test -- --network sapphire-localnet diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e43b6a --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Oasis Demo Quiz dApp + +A confidential quiz dApp exposing the RNG to generate a random order of +questions per coupon with the reward payout for the ones who solve it. +Runs on Oasis Sapphire. + +- `backend` contains the solidity contract, deployment and testing utils. +- `frontend` contains a Vue-based web application communicating with the + backend smart contract. + +This monorepo is set up for `pnpm`. Install dependencies by running: + +```sh +pnpm install +``` + +## Backend + +Move to the `backend` folder and build smart contracts: + +```sh +pnpm build +``` + +Next, deploy the contract. + +### Basic Local Hardhat Deployment + +Start the hardhat node: + +```sh +npx hardhat node +``` + +Deploy smart contracts to that local network: + +```sh +npx hardhat deploy --network localhost +``` + +The deployed Quiz address will be reported. Remember it and store it +inside the `frontend` folder's `.env.development`, for example: + +``` +VITE_QUIZ_ADDR=0x385cAE1F3afFC50097Ca33f639184f00856928Ff +``` + +### Deploying to Sapphire Localnet, Testnet and Mainnet + +Prepare your hex-encoded private key and store it as an environment variable: + +```shell +export PRIVATE_KEY=0x... +``` + +To deploy the contracts to the [Sapphire Localnet], Testnet or Mainnet, use the +following commands respectively: + +```shell +npx hardhat deploy --network sapphire-localnet +npx hardhat deploy --network sapphire-testnet +npx hardhat deploy --network sapphire +``` + +[Sapphire Localnet]: https://github.com/oasisprotocol/oasis-web3-gateway/pkgs/container/sapphire-dev + +### Once deployed + +Checklist after deploying a production-ready quiz: + +1. Push questions. Example: + + ```shell + npx hardhat pushQuestions 0x385cAE1F3afFC50097Ca33f639184f00856928Ff test-questions.json --network sapphire-testnet + ``` + +2. Add coupons. Example: + + ```shell + hardhat addCoupons 0x385cAE1F3afFC50097Ca33f639184f00856928Ff test-coupons.txt --network sapphire-testnet + ``` + +3. Set payout reward. Example: + + ```shell + npx hardhat setReward 0x385cAE1F3afFC50097Ca33f639184f00856928Ff 2.0 --network sapphire-testnet + ``` + +4. Set gasless kaypair. The current account nonce will be fetched and stored to + the contract. Because of that, the provided account **must be used solely for + gasless transactions by the deployed quiz contract**. Example: + + ```shell + npx hardhat setGaslessKeyPair 0x385cAE1F3afFC50097Ca33f639184f00856928Ff 0xd8cA6E05FC1a466992D98f5f4FFC621ca95b7229 0xbf63c1e7982a80f424b5e8c355b7f11a0968bf44b1407c473aadb364b8c291d3 --network sapphire-testnet + ``` + +5. Fund the contract and the gasless account. Example: + + ```shell + npx hardhat fund 0x385cAE1F3afFC50097Ca33f639184f00856928Ff 100 --network sapphire-testnet # contract + npx hardhat fund 0xd8cA6E05FC1a466992D98f5f4FFC621ca95b7229 10 --network sapphire-testnet # gasless account + ``` + +6. Check the quiz contract status, to make sure if everything is set. Example: + + ```shell + npx hardhat status 0x385cAE1F3afFC50097Ca33f639184f00856928Ff --network sapphire-testnet + ``` + + You can also obtain details on spent coupons as follows: + + ```shell + npx hardhat getCoupons 0x385cAE1F3afFC50097Ca33f639184f00856928Ff --network sapphire-testnet + ``` + +## Frontend + +After you compiled the backend, updated `.env.development` with the +corresponding address and a chain ID, move to the `frontend` folder, compile +and Hot-Reload frontend for Development: + +```sh +pnpm dev +``` + +Navigate to http://localhost:5173 with your browser to view your dApp. Some +browsers (e.g. Brave) may require https connection and a CA-signed certificate +to access the wallet. In this case, read the section below on how to properly +deploy your dApp. + +You can use one of the deployed test accounts and associated private key with +MetaMask. If you use the same MetaMask accounts on fresh local networks such as +Hardhat Node, Foundry Anvil or sapphire-dev docker image, don't forget to +*clear your account's activity* each time or manually specify the correct +account nonce. + +### Frontend Deployment + +You can build assets for deployment by running: + +```sh +pnpm build +``` + +`dist` folder will contain the generated HTML files that can be hosted. + +#### Different Website Base + +If you are running dApp on a non-root base dir, add + +``` +BASE_DIR=/my/public/path +``` + +to `.env.production` and bundle the app with + +``` +pnpm build-only --base=/my/public/path/ +``` + +Then copy the `dist` folder to a place of your `/my/public/path` location. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..00220ef --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +.openzeppelin/ +artifacts/ +cache/ +coverage* +lib/ +typechain-types/ +abis/ diff --git a/backend/.solhint.json b/backend/.solhint.json new file mode 100644 index 0000000..1f1f911 --- /dev/null +++ b/backend/.solhint.json @@ -0,0 +1,8 @@ +{ + "extends": "solhint:recommended", + "rules": { + "compiler-version": ["error", "^0.8.0"], + "func-visibility": ["warn", { "ignoreConstructors": true }], + "not-rely-on-time": "off" + } +} diff --git a/backend/contracts/Quiz.sol b/backend/contracts/Quiz.sol new file mode 100644 index 0000000..5f0c350 --- /dev/null +++ b/backend/contracts/Quiz.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@oasisprotocol/sapphire-contracts/contracts/Sapphire.sol"; +import {EIP155Signer} from "@oasisprotocol/sapphire-contracts/contracts/EIP155Signer.sol"; + +contract Quiz { + string constant errInvalidCoupon = "Invalid coupon"; + string constant errCouponExists = "Coupon already exists"; + string constant errWrongAnswer = "Wrong answer"; + string constant errWrongNumberOfAnswers = "Wrong number of answers"; + string constant errForbidden = "Access forbidden by contract policy"; + string constant errPayoutFailed = "Payout failed"; + + uint256 public constant COUPON_VALID = type(uint256).max-1; + uint256 public constant COUPON_REMOVED = type(uint256).max-2; + + struct QuizQuestion { + string question; + string[] choices; + } + + // This struct is encrypted on-chain and provided as a proof for claiming the reward. + // Since it's encrypted, it also hides the coupon and the address, useful for sending over plain gasless transaction. + struct PayoutCertificate { + string coupon; + address addr; + } + + // Keypair for gasless transactions. + struct EthereumKeypair { + address addr; + bytes32 secret; + uint64 nonce; + } + + // Owner of the contract. + address _owner; + // Encryption key for encrypting payout certificates. + bytes32 _key; + // List of questions. + QuizQuestion[] _questions; + // List of correct answer choices. + uint8[] _questionsCorrectChoices; + // Status of coupons. COUPON_VALID for valid coupon, COUPON_REMOVED for removed, block number for spent. + mapping(string => uint256) _coupons; + // Stores all coupons that ever existed. Used for traversing the mapping. + string[] _allCoupons; + // Reward amount in wei. + uint _reward; + // Optioanl keypair used for gasless transactions. + EthereumKeypair _kp; + + modifier onlyOwner { + require(msg.sender == _owner); + _; + } + + modifier validCoupon(string memory coupon) { + require(msg.sender == _owner || _coupons[coupon] == COUPON_VALID, errInvalidCoupon); + _; + } + + constructor() payable { + _owner = msg.sender; + _key = bytes32(Sapphire.randomBytes(32, "")); + } + + // Adds an new question with given choices and the correct one. + function pushQuestion(string memory question, string[] memory choices, uint8 correctChoice) external onlyOwner { + _questions.push(QuizQuestion(question, choices)); + _questionsCorrectChoices.push(correctChoice); + } + + // Removes all questions. + function clearQuestions() external onlyOwner { + delete _questions; + } + + // Updates the existing question. + function setQuestion(uint questionIndex, string memory question, string[] memory choices, uint8 correctChoice) external onlyOwner { + _questions[questionIndex] = QuizQuestion(question, choices); + _questionsCorrectChoices[questionIndex] = correctChoice; + } + + // Sets the payout reward for correctly solving the quiz. + function setReward(uint reward) external onlyOwner { + _reward = reward; + } + + // Sets the payout reward for correctly solving the quiz. + function getReward() external view onlyOwner returns (uint){ + return _reward; + } + + // Registers coupons eligible for solving the quiz and claiming the reward. + function addCoupons(string[] calldata coupons) external onlyOwner { + for (uint i=0; i { + await runSuper(); + await hre.run(TASK_EXPORT_ABIS); +}); + +task(TASK_EXPORT_ABIS, async (_args, hre) => { + const srcDir = path.basename(hre.config.paths.sources); + const outDir = path.join(hre.config.paths.root, 'abis'); + + const [artifactNames] = await Promise.all([ + hre.artifacts.getAllFullyQualifiedNames(), + fs.mkdir(outDir, { recursive: true }), + ]); + + await Promise.all( + artifactNames.map(async (fqn) => { + const { abi, contractName, sourceName } = await hre.artifacts.readArtifact(fqn); + if (abi.length === 0 || !sourceName.startsWith(srcDir) || contractName.endsWith('Test')) + return; + await fs.writeFile(`${path.join(outDir, contractName)}.json`, `${canonicalize(abi)}\n`); + }), + ); +}); + +// Unencrypted contract deployment. +task('deploy') + .setAction(async (args, hre) => { + await hre.run('compile'); + + // For deployment unwrap the provider to enable contract verification. + const uwProvider = new JsonRpcProvider(hre.network.config.url); + const Quiz = await hre.ethers.getContractFactory('Quiz', new hre.ethers.Wallet(accounts[0], uwProvider)); + const quiz = await Quiz.deploy(); + await quiz.waitForDeployment(); + + console.log(`Quiz address: ${await quiz.getAddress()}`); + return quiz; +}); + +// Get list of valid coupons and spent coupons with the block number. +task('getCoupons') + .addPositionalParam('address', 'contract address') + .setAction(async (args, hre) => { + await hre.run('compile'); + + console.log(`Coupons for quiz contract ${args.address}`); + const quiz = await hre.ethers.getContractAt('Quiz', args.address); + + const [coupons, couponStatus] = await quiz.getCoupons(); + let validCoupons: string[] = []; + let removedCoupons: string[] = []; + let spentCoupons = new Map(); + for (let i=0; i { + spentCouponsStr += `${key}:${value},`; + } + ); + console.log(`Spent coupons (${spentCoupons.size}/${coupons.length}): ${spentCouponsStr.slice(0, spentCouponsStr.length-1)}`); + console.log(`Valid coupons (${validCoupons.length}/${coupons.length}): ${validCoupons}`); + console.log(`Removed coupons (${removedCoupons.length}/${coupons.length}): ${removedCoupons}`); + }); + +// Print out the Quiz status. Useful as a checklist. +task('status') + .addPositionalParam('address', 'contract address') + .setAction(async (args, hre) => { + await hre.run('compile'); + + console.log(`Status for quiz contract ${args.address}`); + const quiz = await hre.ethers.getContractAt('Quiz', args.address); + + // Questions + const questions = await quiz.getQuestions(""); + console.log(`Questions (counting from 0):`); + for (let i=0; i { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const tx = await quiz.pushQuestion(args.question, args.choices, args.correctChoice); + const receipt = await tx.wait(); + console.log(`Success! Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('clearQuestions') + .addPositionalParam('address', 'contract address') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const tx = await quiz.clearQuestions(args.question, args.choices, args.correctChoice); + const receipt = await tx.wait(); + console.log(`Success! Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('setQuestion') + .addPositionalParam('address', 'contract address') + .addPositionalParam('number', 'question number (starting from 0)') + .addPositionalParam('questionsFile', 'file containing questions in JSON format') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const questions = JSON.parse(await fs.readFile(args.questionsFile,'utf8')); + const tx = await quiz.setQuestion(args.number, questions[parseInt(args.number)].question, questions[parseInt(args.number)].choices, questions[parseInt(args.number)].correctChoice); + const receipt = await tx.wait(); + console.log(`Updated question ${questions[parseInt(args.number)].question}. Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('pushQuestions') + .addPositionalParam('address', 'contract address') + .addPositionalParam('questionsFile', 'file containing questions in JSON format') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const questions = JSON.parse(await fs.readFile(args.questionsFile,'utf8')); + for (var i=0; i { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const coupons = (await fs.readFile(args.couponsFile,'utf8')).split("\n"); + // Trim last empty line. + if (coupons[coupons.length-1]=="") { + coupons.pop(); + } + for (var i=0; i { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const tx = await quiz.setReward(hre.ethers.parseEther(args.reward)); + const receipt = await tx.wait(); + console.log(`Successfully set reward to ${args.reward} ROSE. Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('fund') + .addPositionalParam('address', 'contract address') + .addPositionalParam('amount', 'reclaim funds to this address') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const tx = await (await hre.ethers.getSigners())[0].sendTransaction({ + from: (await hre.ethers.getSigners())[0].address, + to: await quiz.getAddress(), + value: hre.ethers.parseEther(args.amount), + }); + const receipt = await tx.wait(); + console.log(`Successfully funded ${await quiz.getAddress()} with ${args.amount} ROSE. Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('reclaimFunds') + .addPositionalParam('address', 'contract address') + .addPositionalParam('payoutAddress', 'reclaim funds to this address') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + const tx = await quiz.reclaimFunds(args.payoutAddress); + const receipt = await tx.wait(); + console.log(`Successfully reclaimed funds to ${args.payoutAddress}. Transaction hash: ${receipt!.hash}`); + }); + +// Add a new question. +task('setGaslessKeyPair') + .addPositionalParam('address', 'contract address') + .addPositionalParam('payerAddress', 'payer address') + .addPositionalParam('payerSecret', 'payer secret key') + .setAction(async (args, hre) => { + await hre.run('compile'); + + let quiz = await hre.ethers.getContractAt('Quiz', args.address); + + const nonce = await hre.ethers.provider.getTransactionCount(args.payerAddress); + const tx = await quiz.setGaslessKeyPair(args.payerAddress, args.payerSecret, nonce); + const receipt = await tx.wait(); + console.log(`Successfully set gasless keypair to ${args.payerAddress}, secret ${args.payerSecret} and nonce ${nonce}. Transaction hash: ${receipt!.hash}`); + }); + +// Hardhat Node and sapphire-dev test mnemonic. +const TEST_HDWALLET = { + mnemonic: "test test test test test test test test test test test junk", + path: "m/44'/60'/0'/0", + initialIndex: 0, + count: 20, + passphrase: "", +}; + +const accounts = process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : TEST_HDWALLET; + +const config: HardhatUserConfig = { + networks: { + hardhat: { // https://hardhat.org/metamask-issue.html + chainId: 1337, + }, + 'sapphire': { + url: 'https://sapphire.oasis.io', + chainId: 0x5afe, + accounts, + }, + 'sapphire-testnet': { + url: 'https://testnet.sapphire.oasis.dev', + chainId: 0x5aff, + accounts, + }, + 'sapphire-localnet': { // docker run -it -p8545:8545 -p8546:8546 ghcr.io/oasisprotocol/sapphire-localnet -test-mnemonic + url: 'http://localhost:8545', + chainId: 0x5afd, + accounts, + }, + 'emerald-testnet': { + url: 'https://testnet.emerald.oasis.io', + chainId: 0xa515, + accounts, + }, + }, + solidity: { + version: '0.8.16', + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + viaIR: true, + }, + }, + watcher: { + compile: { + tasks: ['compile'], + files: ['./contracts/'], + }, + test: { + tasks: ['test'], + files: ['./contracts/', './test'], + }, + coverage: { + tasks: ['coverage'], + files: ['./contracts/', './test'], + }, + }, + mocha: { + require: ['ts-node/register/files'], + timeout: 50_000, + }, +}; + +export default config; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5cff778 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,65 @@ +{ + "private": true, + "name": "@oasisprotocol/demo-quiz-backend", + "version": "1.0.0", + "license": "Apache-2.0", + "main": "./lib/cjs/src/index.js", + "module": "./lib/esm/src/index.js", + "types": "./lib/cjs/src/index.d.ts", + "engines": { + "node": ">=18", + "pnpm": ">=8" + }, + "files": [ + "contracts", + "lib", + "src" + ], + "scripts": { + "lint:prettier": "prettier --check --plugin-search-dir=. --cache '*.json' 'tsconfig/*.json' '{scripts,test}/**/*.ts' 'contracts/**/*.sol'", + "lint:solhint": "solhint 'contracts/**/*.sol'", + "lint": "npm-run-all lint:**", + "format:prettier": "prettier --write --plugin-search-dir=. --cache '*.json' 'tsconfig/*.json' '{scripts,test}/**/*.ts' 'contracts/**/*.sol'", + "format:solhint": "solhint --fix 'contracts/**/*.sol'", + "format": "npm-run-all format:**", + "build:compile": "hardhat compile", + "build:cjs": "tsc -p tsconfig/cjs.json", + "build:esm": "tsc -p tsconfig/esm.json", + "build": "npm-run-all build:compile --parallel build:cjs build:esm", + "test": "hardhat test", + "prepublishOnly": "pnpm build" + }, + "exports": { + "default": "./lib/esm/src/index.js", + "node": { + "import": "./lib/esm/src/index.js", + "require": "./lib/cjs/src/index.js" + } + }, + "dependencies": { + "@openzeppelin/contracts": "^4.8.1", + "ethers": "^6.10.0" + }, + "devDependencies": { + "@nomicfoundation/hardhat-ethers": "^3.0.5", + "@oasisprotocol/sapphire-contracts": "^0.2.7", + "@oasisprotocol/sapphire-hardhat": "^2.19.4", + "@typechain/ethers-v6": "^0.5.1", + "@typechain/hardhat": "^9.1.0", + "@types/chai": "^4.3.4", + "@types/mocha": "^10.0.1", + "@types/node": "^18.14.0", + "canonicalize": "^1.0.8", + "chai": "^4.3.7", + "hardhat": "^2.19.2", + "hardhat-watcher": "^2.5.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.4", + "prettier-plugin-solidity": "1.1.2", + "solhint": "^3.4.0", + "solidity-coverage": "^0.8.2", + "ts-node": "^10.9.1", + "typechain": "^8.3.2", + "typescript": "^4.9.5" + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..dd492b8 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,3 @@ +export { + Quiz, Quiz__factory, +} from "../typechain-types" diff --git a/backend/test-coupons.txt b/backend/test-coupons.txt new file mode 100644 index 0000000..dc26f5e --- /dev/null +++ b/backend/test-coupons.txt @@ -0,0 +1,50 @@ +testCoupon1 +testCoupon2 +testCoupon3 +testCoupon4 +testCoupon5 +testCoupon6 +testCoupon7 +testCoupon8 +testCoupon9 +testCoupon10 +testCoupon11 +testCoupon12 +testCoupon13 +testCoupon14 +testCoupon15 +testCoupon16 +testCoupon17 +testCoupon18 +testCoupon19 +testCoupon20 +testCoupon21 +testCoupon22 +testCoupon23 +testCoupon24 +testCoupon25 +testCoupon26 +testCoupon27 +testCoupon28 +testCoupon29 +testCoupon30 +testCoupon31 +testCoupon32 +testCoupon33 +testCoupon34 +testCoupon35 +testCoupon36 +testCoupon37 +testCoupon38 +testCoupon39 +testCoupon40 +testCoupon41 +testCoupon42 +testCoupon43 +testCoupon44 +testCoupon45 +testCoupon46 +testCoupon47 +testCoupon48 +testCoupon49 +testCoupon50 diff --git a/backend/test-questions.json b/backend/test-questions.json new file mode 100644 index 0000000..5dcaf3e --- /dev/null +++ b/backend/test-questions.json @@ -0,0 +1,88 @@ +[ + { + "question": "What year did Satoshi Nakamoto publish the bitcoin whitepaper?", + "choices": ["2000", "2008", "2009", "2013"], + "correctChoice": 1 + }, + { + "question": "What is bitcoin?", + "choices": [ + "The newest Ethereum-compatible network developed by the Oasis community of developers", + "A cryptocurrency designed for criminals", + "A distributed network that allows you to transfer wealth over internet without intermediaries" + ], + "correctChoice": 2 + }, + { + "question": "What is Ethereum?", + "choices": [ + "A distributed network inspired by bitcoin which also enables execution of general-purpose programs beside the token transfers", + "A delicious cream designed by Kolinska in 1982", + "An automatic transmission design proposed by the Volkswagen AG concern" + ], + "correctChoice": 0 + }, + { + "question": "What is ledger?", + "choices": [ + "Political manifesto of the big banks and liberal capitalism opponents, written in 2008 at the outbreak of the global financial crisis", + "A linked list of blocks, each containing transactions and the hash value of the previous block to ensure data authenticity", + "An ancient religious book of the Celts, discovered in 1998 near Bath in the west of England" + ], + "correctChoice": 1 + }, + { + "question": "What's the expected block confirmation time on Oasis Sapphire?", + "choices": [ + "10 minutes", + "12 seconds", + "6 seconds" + ], + "correctChoice": 2 + }, + { + "question": "Which programming language do we usually write programs for the Ethereum Virtual Machine in?", + "choices": [ + "C", + "Solidity", + ".Net" + ], + "correctChoice": 1 + }, + { + "question": "What is Oasis Sapphire?", + "choices": [ + "A company that has been engaged in the production and distribution of water in the Middle West, Africa and Central Asia since 1962", + "A distributed network compatible with Ethereum development tools that allows programs to run in a secret manner", + "A diamond mine in the south-east of South Africa" + ], + "correctChoice": 1 + }, + { + "question": "What is the name of the native cryptocurrency used in the Oasis network?", + "choices": [ + "OAS", + "BTC", + "ROSE" + ], + "correctChoice": 2 + }, + { + "question": "Which of the addresses below is a valid wallet address on the Ethereum and Oasis Sapphire networks?", + "choices": [ + "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "surely89@oasis.io", + "0xDce075E1C39b1ae0b75D554558b6451A226ffe00" + ], + "correctChoice": 2 + }, + { + "question": "Which of the following is a valid wallet private key on the Ethereum and Oasis Sapphire networks?", + "choices": [ + "0b00110010010010001011100100100010111010100101010011010110101010", + "0xc0e43d8755f201b715fd5a9ce0034c568442543ae0a0ee1aec2985ffe40edb99", + "L1bdCJ1nZXxdHNWwDw8hVuQoqgZEue1kVtPE1SWXRwbJxQkfRTvT" + ], + "correctChoice": 1 + } +] diff --git a/backend/test/Quiz.ts b/backend/test/Quiz.ts new file mode 100644 index 0000000..3f9016a --- /dev/null +++ b/backend/test/Quiz.ts @@ -0,0 +1,189 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import {Quiz, Quiz__factory} from "../typechain-types"; +import {getDefaultProvider, JsonRpcProvider} from "ethers"; + +describe("Quiz", function () { + async function deployQuiz() { + const Quiz_factory = await ethers.getContractFactory("Quiz"); + const quiz = await Quiz_factory.deploy({ + value: ethers.parseEther("10.00"), + } + ); + await quiz.waitForDeployment(); + return { quiz }; + } + + async function addQuestions(quiz: Quiz) { + await quiz.pushQuestion("What's the European highest peak?", ["Triglav", "Mount Everest", "Mont Blanc"], 2); + await quiz.pushQuestion("When was the Bitcoin whitepaper published?", ["2009", "2006", "2012"], 0); + } + + async function addCoupons(quiz: Quiz) { + await quiz.addCoupons(["testCoupon1", "testCoupon2"]); + } + + async function setGaslessKeypair(quiz: Quiz) { + const addr = ethers.getAddress("0xDce075E1C39b1ae0b75D554558b6451A226ffe00"); + const sk = Uint8Array.from(Buffer.from("c0e43d8755f201b715fd5a9ce0034c568442543ae0a0ee1aec2985ffe40edb99", 'hex')); + const nonce = 0; + await quiz.setGaslessKeyPair(addr, sk, nonce); + } + + async function setReward(quiz: Quiz) { + await quiz.setReward(ethers.parseEther("10.00")); + } + + + it("Should add questions", async function () { + const {quiz} = await deployQuiz(); + await addQuestions(quiz); + + expect(await quiz.getQuestions("")).to.deep.equal( + [ + ["What's the European highest peak?", ["Triglav", "Mount Everest", "Mont Blanc"]], + ["When was the Bitcoin whitepaper published?", ["2009", "2006", "2012"]], + ]); + + await quiz.clearQuestions(); + expect(await quiz.getQuestions("")).to.deep.equal([]); + + await addQuestions(quiz); + expect(await quiz.getQuestions("")).to.deep.equal( + [ + ["What's the European highest peak?", ["Triglav", "Mount Everest", "Mont Blanc"]], + ["When was the Bitcoin whitepaper published?", ["2009", "2006", "2012"]], + ]); + }); + + it("Should add coupon", async function () { + const {quiz} = await deployQuiz(); + await addCoupons(quiz); + + // Check coupons. + expect(await quiz.countCoupons()).to.deep.equal([2n, 2n]); + expect(await quiz.getCoupons()).to.deep.equal([["testCoupon1", "testCoupon2"], [await quiz.COUPON_VALID(), await quiz.COUPON_VALID()]]); + + // Invalidate coupon. + await quiz.removeCoupon("testCoupon1"); + expect(await quiz.countCoupons()).to.deep.equal([1n, 2n]); + expect(await quiz.getCoupons()).to.deep.equal([["testCoupon1", "testCoupon2"], [await quiz.COUPON_REMOVED(), await quiz.COUPON_VALID()]]); + + // Re-enable coupon. + await quiz.addCoupons(["testCoupon1"]); + expect(await quiz.countCoupons()).to.deep.equal([2n, 2n]); + expect(await quiz.getCoupons()).to.deep.equal([["testCoupon1", "testCoupon2"], [await quiz.COUPON_VALID(), await quiz.COUPON_VALID()]]); + }); + + it("User should get questions", async function () { + if ((await ethers.provider.getNetwork()).chainId != 1337) { // This test fails with current Sapphire wrapper due to signer!=caller bug. + this.skip(); + } + + const {quiz} = await deployQuiz(); + await addQuestions(quiz); + await addCoupons(quiz); + + const userQuiz = quiz.connect((await ethers.getSigners())[1]); + //expect(userQuiz.getQuestions("invalidCoupon")).to.be.revertedWith("Invalid coupon"); + expect(await userQuiz.getQuestions("testCoupon1")).to.have.lengthOf(2); + }); + + it("Should set Gasless keypair", async function () { + const {quiz} = await deployQuiz(); + await setGaslessKeypair(quiz); + const kp = await quiz.getGaslessKeyPair(); + expect(kp[0]).to.equal("0xDce075E1C39b1ae0b75D554558b6451A226ffe00"); + expect(kp[1]).to.equal("0xc0e43d8755f201b715fd5a9ce0034c568442543ae0a0ee1aec2985ffe40edb99"); + expect(kp[2]).to.equal(0n); + }); + + it("Should set reward", async function () { + const {quiz} = await deployQuiz(); + await setReward(quiz); + expect(await quiz.getReward()).to.equal(10_000_000_000_000_000_000n); + }); + + it("Should reclaim funds", async function () { + const {quiz} = await deployQuiz(); + + const balance1 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + + const receipt = await (await quiz.reclaimFunds((await ethers.getSigners())[1].address)).wait(); + expect(receipt).to.not.equal(null); + expect(receipt!.status).to.equal(1); + + const balance2 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + expect(balance1 < balance2).to.be.true; + }); + + it("User should check answers", async function () { + const {quiz} = await deployQuiz(); + await addQuestions(quiz); + await addCoupons(quiz); + + expect(await quiz.checkAnswers("testCoupon1", [0, 0], ethers.ZeroAddress)).to.deep.equal([[false, true], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [0, 1], ethers.ZeroAddress)).to.deep.equal([[false, false], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [0, 2], ethers.ZeroAddress)).to.deep.equal([[false, false], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [1, 0], ethers.ZeroAddress)).to.deep.equal([[false, true], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [1, 1], ethers.ZeroAddress)).to.deep.equal([[false, false], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [1, 2], ethers.ZeroAddress)).to.deep.equal([[false, false], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [2, 0], ethers.ZeroAddress)).to.deep.equal([[true, true], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [2, 1], ethers.ZeroAddress)).to.deep.equal([[true, false], "0x"]); + expect(await quiz.checkAnswers("testCoupon1", [2, 2], ethers.ZeroAddress)).to.deep.equal([[true, false], "0x"]); + }); + + it("User should receive payout certificate", async function () { + if ((await ethers.provider.getNetwork()).chainId == 1337) { // Requires Sapphire + this.skip(); + } + const {quiz} = await deployQuiz(); + await addQuestions(quiz); + await addCoupons(quiz); + await setReward(quiz); + + const [_correctVector, payoutCertificate] = await quiz.checkAnswers("testCoupon1", [2, 0], (await ethers.getSigners())[1].address); + expect(payoutCertificate).to.not.equal("0x"); + + const balance1 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + const receipt = await (await quiz.claimReward(payoutCertificate)).wait(); + + expect(receipt).to.not.equal(null); + expect(receipt!.status).to.equal(1); + + const balance2 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + expect(balance1 < balance2).to.be.true; + }); + + it("User should send gasless transaction", async function () { + if ((await ethers.provider.getNetwork()).chainId == 1337) { // Requires Sapphire + this.skip(); + } + const {quiz} = await deployQuiz(); + await addQuestions(quiz); + await addCoupons(quiz); + await setReward(quiz); + await setGaslessKeypair(quiz); + + const transaction = await (await ethers.getSigners())[0].sendTransaction({ + from: (await ethers.getSigners())[0], + to: ethers.getAddress("0xDce075E1C39b1ae0b75D554558b6451A226ffe00"), + value: ethers.parseEther("1.00"), + }); + const fundingReceipt = await transaction.wait() + expect(fundingReceipt).to.not.equal(null); + expect(fundingReceipt!.status).to.be.equal(1); + + const [_correctVector, rawTx] = await quiz.checkAnswers("testCoupon1", [2, 0], (await ethers.getSigners())[1].address); + expect(rawTx).to.not.equal("0x"); + + const balance1 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + + const receipt = await (await ethers.provider.broadcastTransaction(rawTx)).wait(); + expect(receipt).to.not.equal(null); + expect(receipt!.status).to.be.equal(1); + + const balance2 = await ethers.provider.getBalance((await ethers.getSigners())[1].address); + expect(balance1 < balance2).to.be.true; + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4ca9292 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig/cjs.json", + "include": ["src", "scripts", "test", "typechain-types"], + "files": ["hardhat.config.ts"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/backend/tsconfig/base.json b/backend/tsconfig/base.json new file mode 100644 index 0000000..3ece33a --- /dev/null +++ b/backend/tsconfig/base.json @@ -0,0 +1,12 @@ +{ + "include": ["../src"], + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "strict": true, + "target": "es2020" + } +} diff --git a/backend/tsconfig/cjs.json b/backend/tsconfig/cjs.json new file mode 100644 index 0000000..dfff3f5 --- /dev/null +++ b/backend/tsconfig/cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../lib/cjs" + } +} diff --git a/backend/tsconfig/esm.json b/backend/tsconfig/esm.json new file mode 100644 index 0000000..f5ddd96 --- /dev/null +++ b/backend/tsconfig/esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "module": "es2020", + "outDir": "../lib/esm" + } +} diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..92c89c5 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,7 @@ +VITE_NETWORK=0x5afd +VITE_WEB3_GATEWAY=http://localhost:8545 +VITE_QUIZ_ADDR=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 +#VITE_NETWORK=0x5aff +#VITE_WEB3_GATEWAY=https://testnet.sapphire.oasis.io +#VITE_QUIZ_ADDR=0x385cAE1F3afFC50097Ca33f639184f00856928Ff +#BASE_DIR=/demo-quiz diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..3f18723 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,7 @@ +#VITE_NETWORK=0x5aff +#VITE_WEB3_GATEWAY=https://testnet.sapphire.oasis.io +#VITE_QUIZ_ADDR=0x385cAE1F3afFC50097Ca33f639184f00856928Ff +VITE_NETWORK=0x5afe +VITE_WEB3_GATEWAY=https://sapphire.oasis.io +VITE_QUIZ_ADDR=0xB6AE0C6cBc243741ba4538968209aE6Dac1D4328 +BASE_DIR=/demo-quiz diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0104377 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,15 @@ +/* eslint-env node */ +require('@rushstack/eslint-patch/modern-module-resolution') + +module.exports = { + root: true, + 'extends': [ + 'plugin:vue/vue3-essential', + 'eslint:recommended', + '@vue/eslint-config-typescript', + '@vue/eslint-config-prettier' + ], + parserOptions: { + ecmaVersion: 'latest' + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f6595cf --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +stats.html diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ab8950469afd128cb277b74efa16746eeb897916 GIT binary patch literal 4286 zcmcInK~ftr5FBHwd^D$AQB^KE@`Efd2tU9-AV08y2S8OS#^hp-xg|fasmeW1uz!G$ z98xIVqgA0DNhUbSQet;!r`0n(qg`2~hd<|p|K;m}$Qu!PhXIo`7_|S7!_Vcwf0D+3 zmWjWS1;!lLBijEZALex!BR`evmM3t{xipp=8N=tH9rSg4w(FssAuo^X`XJ{G zG@u?FDTXn>QV-xAa=6>dS&nA~Ov8NrEwp&{q21B`(m_8A`uo2aBl5g9hOOK)`G(%o zdc%)B7y2P+pT|0tj~YW-i;$P2-5&l*>sHE}Hb?(9KIM*ht@li>@GOrZXleCaMQnO* zd)v-2r;qZc{m|yt!H2bA@yexIrSW0CcCaB%i(SwMah>RTdd81aYUcqFf01OHZk_O+F5toL#;mWUl~k8%(esmtan#SXi&8C zUBN!K{hYaUf7tJ zcUX&E4WX;$sonc8S>JfISxw2E*M3yO*SKnSlP7a&+L7kPK2Pt1U)~d{UyZ9|&+?kd zhXTLl4*Xp6-MIcAztz92lP~4C&1T{c^)KjJ?7vF?Y~xFxfjfT(l-6|Uzidr#uB?9T zdo<;#T?1QucOb5X(IxEH(pn=X<>&V{wF6t@v-XUqwx(H+%33eHL)e?na@l+MaGj-n zy`m3n6Jvjf`!eX&twHVm4^1qey$45Etko`|5?-_#TbF^Xhz2p9Si38t2^Q z%(%72q1F$$*WY=ZtNrcE-e7(U9qb2i;@a)PSHo!T;e8Oe&==rnYQ0>W$J+f<-}_19 zpG4k2%KqU-_J(We4VG_Zu)JJeo)6Z?li}ugZ@3XTe02L5IK%r_cfiJH7$U<>b1~fX fE(YuK`~H1@DL?w_i3~Q442cGE?Du-!FUS5j8L2Dj literal 0 HcmV?d00001 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..29d2559 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Oasisov blockchain kviz ACM RTK 2024 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..458e4f9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,56 @@ +{ + "private": true, + "name": "@oasisprotocol/demo-quiz-frontend", + "version": "1.0.0", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "run-p type-check build-only", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --noEmit", + "lint:eslint": "eslint . --ext .vue,.ts --ignore-path .gitignore", + "lint:prettier": "prettier --check --cache '**/*.{vue,js,ts,json}'", + "lint": "npm-run-all lint:**", + "format:eslint": "eslint --fix . --ext .vue,.ts --ignore-path .gitignore", + "format:prettier": "prettier --write --cache '**/*.{vue,js,ts,json}'", + "format": "npm-run-all format:**" + }, + "dependencies": { + "@metamask/detect-provider": "^2.0.0", + "@metamask/jazzicon": "^2.0.0", + "@oasisprotocol/demo-quiz-backend": "workspace:^", + "@oasisprotocol/sapphire-paratime": "^1.3.2", + "ethers": "^6.10.0", + "pinia": "^2.0.28", + "vue": "^3.2.45", + "vue-content-loader": "^2.0.1", + "vue-router": "^4.1.6" + }, + "devDependencies": { + "@rushstack/eslint-patch": "^1.2.0", + "@types/node": "^18.14.0", + "@vitejs/plugin-vue": "^3.2.0", + "@vue/eslint-config-prettier": "^7.0.0", + "@vue/eslint-config-typescript": "^11.0.2", + "@vue/tsconfig": "^0.1.3", + "ajv-cli": "^5.0.0", + "autoprefixer": "^10.4.13", + "browserslist": "^4.21.4", + "cssnano": "^5.1.14", + "eslint": "^8.29.0", + "eslint-plugin-vue": "^9.8.0", + "npm-run-all": "^4.1.5", + "postcss": "^8.4.21", + "postcss-html": "^1.5.0", + "prettier": "^2.8.1", + "rollup-plugin-visualizer": "^5.8.3", + "tailwindcss": "^3.2.4", + "typescript": "~4.7.4", + "vite": "^3.2.5", + "vue-tsc": "^1.0.13" + }, + "browserslist": [ + "defaults and supports es6-module" + ] +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..7ee85e3 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: [ + require('tailwindcss/nesting'), + require('tailwindcss'), + require('autoprefixer'), + require('cssnano'), + ], +}; diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..e8f4e72 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/assets/images/logo-rtk.svg b/frontend/src/assets/images/logo-rtk.svg new file mode 100644 index 0000000..03bc593 --- /dev/null +++ b/frontend/src/assets/images/logo-rtk.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/logo-white.svg b/frontend/src/assets/images/logo-white.svg new file mode 100644 index 0000000..646ff64 --- /dev/null +++ b/frontend/src/assets/images/logo-white.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/logo.svg b/frontend/src/assets/images/logo.svg new file mode 100644 index 0000000..a03112f --- /dev/null +++ b/frontend/src/assets/images/logo.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..240114c --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +a { + text-decoration-line: underline; +} +input { + @apply block my-4 p-1 mx-auto text-3xl border border-gray-400; +} + +.form-group { + @apply relative mb-6; +} + +.form-group input, +textarea { + @apply block py-6 px-5 w-full text-base text-black appearance-none focus:outline-none focus:ring-0 bg-white; +} + +.form-group label { + @apply absolute text-base text-primaryDark duration-300 transform -translate-y-5 scale-75 top-6 z-10 origin-[0] left-5; +} + +.message { + @apply bg-white border-primary; + box-shadow: 0 7px 7px 0 rgba(0, 0, 0, 0.17); +} + +button { + font-family: Azeret Mono, sans-serif; + color: black !important; +} + +fieldset button { + border-style: none !important; +} diff --git a/frontend/src/components/AppButton.vue b/frontend/src/components/AppButton.vue new file mode 100644 index 0000000..17841d4 --- /dev/null +++ b/frontend/src/components/AppButton.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue new file mode 100644 index 0000000..8d41327 --- /dev/null +++ b/frontend/src/components/AppFooter.vue @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..ef2f674 --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/components/CheckIcon.vue b/frontend/src/components/CheckIcon.vue new file mode 100644 index 0000000..5a85751 --- /dev/null +++ b/frontend/src/components/CheckIcon.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/components/CheckedIcon.vue b/frontend/src/components/CheckedIcon.vue new file mode 100644 index 0000000..e860035 --- /dev/null +++ b/frontend/src/components/CheckedIcon.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/components/JazzIcon.vue b/frontend/src/components/JazzIcon.vue new file mode 100644 index 0000000..a510abe --- /dev/null +++ b/frontend/src/components/JazzIcon.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/components/MessageLoader.vue b/frontend/src/components/MessageLoader.vue new file mode 100644 index 0000000..07cf08b --- /dev/null +++ b/frontend/src/components/MessageLoader.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/components/QuizDetailsLoader.vue b/frontend/src/components/QuizDetailsLoader.vue new file mode 100644 index 0000000..da64e7b --- /dev/null +++ b/frontend/src/components/QuizDetailsLoader.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/frontend/src/components/SuccessIcon.vue b/frontend/src/components/SuccessIcon.vue new file mode 100644 index 0000000..4df2318 --- /dev/null +++ b/frontend/src/components/SuccessIcon.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/components/SuccessInfo.vue b/frontend/src/components/SuccessInfo.vue new file mode 100644 index 0000000..fd0acb3 --- /dev/null +++ b/frontend/src/components/SuccessInfo.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/components/UncheckedIcon.vue b/frontend/src/components/UncheckedIcon.vue new file mode 100644 index 0000000..e9e624f --- /dev/null +++ b/frontend/src/components/UncheckedIcon.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/contracts.ts b/frontend/src/contracts.ts new file mode 100644 index 0000000..8e51f4f --- /dev/null +++ b/frontend/src/contracts.ts @@ -0,0 +1,22 @@ +import type { ComputedRef } from 'vue'; +import { computed } from 'vue'; + +import { type Quiz, Quiz__factory } from '@oasisprotocol/demo-quiz-backend'; +export type { Quiz } from '@oasisprotocol/demo-quiz-backend'; + +import { useEthereumStore } from './stores/ethereum'; + +const addr = import.meta.env.VITE_QUIZ_ADDR!; + +export function useQuiz(): ComputedRef { + const eth = useEthereumStore(); + + return computed(() => { + if (!eth) { + console.error('[useQuiz] Ethereum Store not initialized'); + return null; + } + + return Quiz__factory.connect(addr, eth.provider); + }); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..79fcda7 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,13 @@ +import { createPinia } from 'pinia'; +import { createApp } from 'vue'; + +import App from './App.vue'; +import './assets/main.css'; +import router from './router'; + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); + +app.mount('#app'); diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..456483c --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,26 @@ +import { createRouter, createWebHashHistory } from 'vue-router'; + +import HomeView from './views/HomeView.vue'; + +const router = createRouter({ + strict: true, + history: createWebHashHistory(import.meta.env.BASE_URL), + routes: [ + { + path: `/`, + component: HomeView, + }, + { + path: '/quiz/:coupon?', + component: () => import('./views/QuizView.vue'), + props: true, + name: 'quiz', + }, + { + path: '/:path(.*)', + component: () => import('./views/404View.vue'), + }, + ], +}); + +export default router; diff --git a/frontend/src/stores/ethereum.ts b/frontend/src/stores/ethereum.ts new file mode 100644 index 0000000..9883263 --- /dev/null +++ b/frontend/src/stores/ethereum.ts @@ -0,0 +1,88 @@ +import detectEthereumProvider from '@metamask/detect-provider'; +import * as sapphire from '@oasisprotocol/sapphire-paratime'; +import { + BrowserProvider, + type Eip1193Provider, + JsonRpcProvider, + JsonRpcSigner, + type Provider, + type Signer, + toBeHex, +} from 'ethers'; +import { defineStore } from 'pinia'; +import { markRaw, type Raw, ref, shallowRef } from 'vue'; +import { MetaMaskNotInstalledError } from '@/utils/errors'; + +export enum Network { + Unknown = 0, + Ethereum = 1, + Goerli = 10, + BscMainnet = 56, + BscTestnet = 97, + EmeraldTestnet = 0xa515, + EmeraldMainnet = 0xa516, + SapphireTestnet = 0x5aff, + SapphireMainnet = 0x5afe, + SapphireLocalnet = 0x5afd, + Local = 1337, + + FromConfig = parseInt(import.meta.env.VITE_NETWORK), +} + +export enum ConnectionStatus { + Unknown, + Disconnected, + Connected, +} + +function networkByChainId(chainId: number | bigint | string): Network { + const id = typeof chainId === 'string' ? parseInt(chainId, 16) : chainId; + if (Network[Number(id)]) return id as Network; + return Network.Unknown; +} + +const networkNameMap: Record = { + [Network.Local]: 'Local Network', + [Network.EmeraldTestnet]: 'Emerald Testnet', + [Network.EmeraldMainnet]: 'Emerald Mainnet', + [Network.SapphireTestnet]: 'Sapphire Testnet', + [Network.SapphireMainnet]: 'Sapphire Mainnet', + [Network.SapphireLocalnet]: 'Sapphire Localnet', + [Network.BscMainnet]: 'BSC', + [Network.BscTestnet]: 'BSC Testnet', +}; + +export function networkName(network?: Network): string { + if (network && networkNameMap[network]) { + return networkNameMap[network]; + } + return 'Unknown Network'; +} + +declare global { + interface Window { + ethereum: BrowserProvider & Eip1193Provider & sapphire.SapphireAnnex; + } +} + +export const useEthereumStore = defineStore('ethereum', () => { + const provider = shallowRef( + new JsonRpcProvider(import.meta.env.VITE_WEB3_GATEWAY, undefined, { + staticNetwork: true, + }), + ); + + const network = ref(Network.FromConfig); + const status = ref(ConnectionStatus.Unknown); + + async function init(addr: string, eth: Eip1193Provider) { + provider.value = sapphire.wrap( + new JsonRpcProvider(import.meta.env.VITE_WEB3_GATEWAY, 'any'), + ) as JsonRpcProvider; + } + + return { + provider, + network, + }; +}); diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts new file mode 100644 index 0000000..e97c0e1 --- /dev/null +++ b/frontend/src/utils/errors.ts @@ -0,0 +1,5 @@ +export class MetaMaskNotInstalledError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/utils/promise.ts b/frontend/src/utils/promise.ts new file mode 100644 index 0000000..edda523 --- /dev/null +++ b/frontend/src/utils/promise.ts @@ -0,0 +1,24 @@ +function rejectDelay(reason: string) { + return new Promise(function (_, reject) { + setTimeout(reject.bind(null, reason), 5000); + }); +} + +export async function retry>( + attempt: () => T, + tryCb: (value: Awaited) => void = () => {}, + maxAttempts = 10, +): Promise T>> { + let p: Promise> = Promise.reject(); + + for (let i = 0; i < maxAttempts; i++) { + p = p + .catch(attempt) + .then((value) => { + return tryCb(value); + }) + .catch(rejectDelay) as Promise>; + } + + return p; +} diff --git a/frontend/src/utils/useMediaQuery.ts b/frontend/src/utils/useMediaQuery.ts new file mode 100644 index 0000000..5d50feb --- /dev/null +++ b/frontend/src/utils/useMediaQuery.ts @@ -0,0 +1,27 @@ +import { onMounted, onUnmounted, ref } from 'vue'; + +export function useMedia(query: string) { + let mediaQuery!: MediaQueryList; + + const matches = ref(mediaQuery ? mediaQuery.matches : false); + function handler(event: MediaQueryListEvent) { + matches.value = event.matches; + } + + onMounted(() => { + if (!mediaQuery) { + mediaQuery = window.matchMedia(query); + } + + matches.value = mediaQuery.matches; + mediaQuery.addEventListener('change', handler, { + capture: false, + }); + }); + + onUnmounted(() => { + mediaQuery.removeEventListener('change', handler); + }); + + return matches; +} diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts new file mode 100644 index 0000000..a610acc --- /dev/null +++ b/frontend/src/utils/utils.ts @@ -0,0 +1,5 @@ +export const abbrAddr = (address: string): string => { + if (!address) return ''; + const addr = address.replace('0x', ''); + return `${addr.slice(0, 5)}…${addr.slice(-5)}`; +}; diff --git a/frontend/src/views/404View.vue b/frontend/src/views/404View.vue new file mode 100644 index 0000000..41e6a8f --- /dev/null +++ b/frontend/src/views/404View.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..184a61b --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/views/QuizView.vue b/frontend/src/views/QuizView.vue new file mode 100644 index 0000000..7b6c06b --- /dev/null +++ b/frontend/src/views/QuizView.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..8b3231d --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + primary: '#010f98', + secondary: '#6efffa', + primaryDark: '#000062', + mediumDark: '#565b61', + primaryMedium: '#3ec8ff' + }, + }, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.config.json b/frontend/tsconfig.config.json new file mode 100644 index 0000000..424084a --- /dev/null +++ b/frontend/tsconfig.config.json @@ -0,0 +1,8 @@ +{ + "extends": "@vue/tsconfig/tsconfig.node.json", + "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], + "compilerOptions": { + "composite": true, + "types": ["node"] + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8d23599 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.web.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + + "references": [ + { + "path": "./tsconfig.config.json" + } + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..659c5e7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import { fileURLToPath, URL } from 'node:url'; + +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { visualizer } from 'rollup-plugin-visualizer'; + +// https://vitejs.dev/config/ +export default defineConfig({ + build: { + sourcemap: true, + }, + // define: { + // __VUE_OPTIONS_API__: false + // }, + plugins: [vue(), visualizer({ sourcemap: true, gzipSize: true })], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8788', + } + } +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..822dd9c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: [frontend, backend]