├── .github ├── pull_request_template.md └── workflows │ └── check.yaml ├── .gitignore ├── .npmrc ├── LICENSE ├── Makefile ├── README.md ├── docs ├── architecture.md ├── assets │ ├── NewGame.png │ └── NewGame.uml └── flow.md ├── package.json ├── packages ├── circuits │ ├── Makefile │ ├── README.md │ ├── circom.config.js │ ├── circom.config.json │ ├── package.json │ ├── src │ │ ├── instantiated │ │ │ ├── Draw.circom │ │ │ ├── DrawHand.circom │ │ │ └── Play.circom │ │ ├── lib │ │ │ ├── BytePacking.circom │ │ │ └── Card.circom │ │ └── proofs │ │ │ ├── Draw.circom │ │ │ ├── DrawHand.circom │ │ │ └── Play.circom │ ├── test │ │ ├── Draw.test.js │ │ ├── DrawHand.test.js │ │ ├── Play.test.js │ │ ├── circuits │ │ │ ├── Draw.test.circom │ │ │ ├── DrawHand.test.circom │ │ │ └── Play.test.circom │ │ └── utils.js │ └── verify.js ├── contracts │ ├── .env.example │ ├── .gitattributes │ ├── .github │ │ └── workflows │ │ │ └── ci.yml │ ├── .prettierrc.yml │ ├── .solhint.json │ ├── .vscode │ │ └── settings.json │ ├── Makefile │ ├── README.md │ ├── foundry.toml │ ├── lib │ │ ├── forge-std │ │ ├── hardhat │ │ │ └── console.sol │ │ ├── multicall │ │ │ └── Multicall3.sol │ │ └── openzeppelin │ │ │ └── src │ ├── package.json │ ├── remappings.txt │ ├── scripts │ │ └── extract_contract_addresses_abis.js │ └── src │ │ ├── CardsCollection.sol │ │ ├── DeckAirdrop.sol │ │ ├── Game.sol │ │ ├── Inventory.sol │ │ ├── InventoryCardsCollection.sol │ │ ├── MockResolver.sol │ │ ├── PlayerHandle.sol │ │ ├── deploy │ │ └── Deploy.s.sol │ │ ├── libraries │ │ ├── Constants.sol │ │ ├── Errors.sol │ │ ├── GameAction.sol │ │ ├── Structs.sol │ │ └── Utils.sol │ │ ├── test │ │ ├── Integration.t.sol │ │ ├── Inventory.t.sol │ │ └── PlayerHandle.t.sol │ │ └── verifiers │ │ ├── DrawHandVerifier.sol │ │ ├── DrawVerifier.sol │ │ └── PlayVerifier.sol ├── e2e │ ├── .github │ │ └── workflows │ │ │ └── playwright.yml │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── package.json │ ├── playwright.config.ts │ ├── tests │ │ ├── chain.ts │ │ ├── fixtures.ts │ │ └── specs │ │ │ └── create.spec.ts │ └── tsconfig.json └── webapp │ ├── .env.local │ ├── .eslintrc.cjs │ ├── .prettierrc.json │ ├── .vs │ └── slnx.sqlite │ ├── .vscode │ └── settings.json │ ├── Makefile │ ├── README.md │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.cjs │ ├── postcss.config.mjs │ ├── public │ ├── card_art │ │ ├── 0.jpg │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── favicon.png │ ├── font │ │ └── BluuNext-Bold.otf │ └── img │ │ └── spinner.svg │ ├── src │ ├── actions │ │ ├── attack.ts │ │ ├── concede.ts │ │ ├── defend.ts │ │ ├── drawCard.ts │ │ ├── endTurn.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── joinGame.ts │ │ ├── libContractWrite.ts │ │ └── playCard.ts │ ├── chain.ts │ ├── components │ │ ├── cards │ │ │ ├── boardCard.tsx │ │ │ ├── cardContainer.tsx │ │ │ ├── draggedCard.tsx │ │ │ └── handCard.tsx │ │ ├── collection │ │ │ ├── cardCollectionDisplay.tsx │ │ │ ├── deckList.tsx │ │ │ ├── deckPanel.tsx │ │ │ └── filterPanel.tsx │ │ ├── hand.tsx │ │ ├── lib │ │ │ ├── README.md │ │ │ ├── jotaiDebug.tsx │ │ │ ├── modal.tsx │ │ │ └── modalElements.tsx │ │ ├── link.tsx │ │ ├── modals │ │ │ ├── createGameModal.tsx │ │ │ ├── gameEndedModal.tsx │ │ │ ├── globalErrorModal.tsx │ │ │ ├── inGameMenuModalContent.tsx │ │ │ ├── joinGameModal.tsx │ │ │ ├── loadingModal.tsx │ │ │ └── mintDeckModal.tsx │ │ ├── navbar.tsx │ │ ├── playerBoard.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── navigation-menu.tsx │ │ │ └── sonner.tsx │ ├── constants.ts │ ├── deployment.ts │ ├── game │ │ ├── README.md │ │ ├── constants.ts │ │ ├── drawInitialHand.ts │ │ ├── fableProofs.ts │ │ └── misc.ts │ ├── hooks │ │ ├── useCancellationHandler.ts │ │ ├── useChainWrite.ts │ │ ├── useDebug.ts │ │ ├── useDragEvents.ts │ │ ├── useFableWrite.ts │ │ ├── useIsHydrated.ts │ │ ├── useIsMounted.ts │ │ ├── useOfflineCheck.ts │ │ └── useScrollBox.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── collection.tsx │ │ ├── index.tsx │ │ └── play.tsx │ ├── setup.ts │ ├── store │ │ ├── README.md │ │ ├── atoms.ts │ │ ├── checkFresh.ts │ │ ├── derive.ts │ │ ├── hooks.ts │ │ ├── network.ts │ │ ├── read.ts │ │ ├── setup.ts │ │ ├── subscriptions.ts │ │ ├── types.ts │ │ ├── update.ts │ │ └── write.ts │ ├── styles │ │ └── globals.css │ ├── utils │ │ ├── asyncLock.ts │ │ ├── card-list.ts │ │ ├── errors.ts │ │ ├── extensions.ts │ │ ├── hashing.ts │ │ ├── jotai.ts │ │ ├── js-utils.ts │ │ ├── navigate.ts │ │ ├── react-utils.ts │ │ ├── throttledFetch.ts │ │ ├── ui-utils.ts │ │ └── zkproofs │ │ │ ├── index.ts │ │ │ ├── proofWorker.ts │ │ │ ├── proofs.ts │ │ │ └── proveInWorker.ts │ └── wagmi │ │ └── BurnerConnector.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── wagmi.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Context (Problem, Motivation, Solution) 2 | 3 | Link related issues! 4 | 5 | ## Describe Your Changes 6 | 7 | ## Checklist 8 | 9 | - [ ] I have performed a self-review of my code 10 | - [ ] I ran `make check` and fixed resulting issues 11 | - [ ] I ran the relevant tests and they all pass 12 | - [ ] I wrote tests for my new features, or added regression tests for the bug I fixed 13 | 14 | ## Testing 15 | 16 | If you didn't write tests, explain how you made sure the code was correct and working as intended. -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Check project 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: pnpm/action-setup@v3 15 | 16 | - name: Install Foundry 17 | uses: foundry-rs/foundry-toolchain@v1 18 | with: 19 | version: nightly 20 | - name: Fake contract deployment 21 | run: mkdir -p packages/contracts/out && echo "{}" > packages/contracts/out/deployment.json 22 | - run: make setup 23 | - run: make check 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | *.env 3 | .vscode 4 | .vs 5 | 6 | # MacOS 7 | .DS_Store 8 | 9 | # Node 10 | node_modules 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | .pnpm-debug.log* 15 | 16 | # Contract outputs 17 | packages/contracts/cache/ 18 | packages/contracts/out/ 19 | packages/contracts/broadcast/ 20 | packages/webapp/src/generated.ts 21 | 22 | # IntelliJ config 23 | .idea/ 24 | *.iml 25 | 26 | # Webapp outputs 27 | packages/webapp/.next 28 | packages/webapp/next-env.d.ts 29 | packages/webapp/tsconfig.tsbuildinfo 30 | 31 | # Circuits outputs 32 | packages/circuits/build 33 | packages/circuits/out 34 | packages/circuits/trusted_setup 35 | packages/circuits/zkeys 36 | packages/webapp/public/proofs 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Clear BSD License 2 | 3 | Copyright (c) 2022 Norswap 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted 7 | (subject to the limitations in the disclaimer below) provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this list of conditions and 10 | the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions 13 | and the following disclaimer in the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | * Neither the name of 0xFable, nor the names of its contributors may be used to endorse or promote 17 | * products derived from this software without specific prior written permission. 18 | 19 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS LICENSE. THIS 20 | SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED 21 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 23 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 25 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 27 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ================================================================================================== 2 | # BASIC COMMANDS 3 | # To get the project running locally. 4 | 5 | # To be run when first setting up the repository. 6 | setup: init-modules install-frozen 7 | cd packages/contracts && make setup 8 | .PHONY: setup 9 | 10 | # Runs the project locally via run-pty, which manages multiple background processes in a single 11 | # terminal. This does not work in all terminal emulators, and might not be the most convenient 12 | # option for you, so you can also run 13 | dev: 14 | pnpm run-pty % make anvil % make webdev % make deploy % make circuits 15 | .PHONY: dev 16 | 17 | # Runs anvil (local EVM node). 18 | anvil: 19 | cd packages/contracts && make anvil 20 | .PHONY: anvil 21 | 22 | # Runs the webapp in dev mode. 23 | webdev: 24 | cd packages/webapp && make dev 25 | .PHONY: webdev 26 | 27 | # Deploys to the contracts to the local node (requires anvil to be running). 28 | deploy: 29 | cd packages/contracts && make deploy 30 | .PHONY: deploy 31 | 32 | # Build the zero-knowledge circuits. 33 | circuits: 34 | cd packages/circuits && make install-all 35 | .PHONY: circuits 36 | 37 | # Performs code-quality checks. 38 | check: 39 | cd packages/contracts && make check 40 | cd packages/webapp && make check 41 | .PHONY: check 42 | 43 | # Performs code formatting for the webapp files and contracts in their respective directories. 44 | format: 45 | cd packages/webapp && make format 46 | cd packages/contracts && make format 47 | .PHONY: format 48 | 49 | # ================================================================================================== 50 | # IMPLEMENTATION DETAILS 51 | 52 | # NOTE: we don't have any submodules currently, they are best avoided. 53 | init-modules: 54 | git submodule update --init --recursive 55 | .PHONY: init-modules 56 | 57 | # Install packages as specified in the pnpm-lockfile.yaml. 58 | install-frozen: 59 | pnpm install --frozen-lockfile 60 | .PHONY: install-deps 61 | 62 | # ================================================================================================== 63 | # DEPENDENCY MANAGEMENT 64 | # Update dependencies, check for outdated dependencies, etc. 65 | 66 | # NOTES: 67 | # Below "version specifier" refers to the version strings (e.g. "^1.2.3") in package.json. 68 | # You can safely use pnpm commands inside the packages, and things will behave like your expect 69 | # (i.e. update only the package, but use the pnpm monorepo architecture). 70 | 71 | # Like npm install: if a version matching version specifier is installed, does nothing, otherwise 72 | # install the most up-to-date version matching the specifier. 73 | install: 74 | pnpm install -r 75 | @echo "If the lockfileVersion changed, please update 'packageManager' in package.json!" 76 | .PHONY: install 77 | 78 | # Shows packages for which new versions are available (compared to the installed version). 79 | # This will also show new version that do not match the version specifiers! 80 | outdated: 81 | pnpm outdated -r 82 | .PHONY: outdated 83 | 84 | # Updates all packages to their latest version that match the version specifier. 85 | # It will also update the version specifiers to point to the new version. 86 | # You can also run this if your installed versions are > than the version specifiers and you want 87 | # to update them. 88 | update: 89 | pnpm update -r 90 | @echo "If the lockfileVersion changed, please update 'packageManager' in package.json!" 91 | .PHONY: update 92 | 93 | # Updates all packages to their latest version (even if they do not match the version specifier!). 94 | # It will also update the version specifiers to point to the new version. 95 | update-latest: 96 | pnpm update -r --latest 97 | @echo "If the lockfileVersion changed, please update 'packageManager' in package.json!" 98 | .PHONY: update-latest 99 | 100 | # In case you accidentally pollute the node_modules directories 101 | # (e.g. by running npm instead of pnpm) 102 | reset-modules: 103 | rm -rf node_modules packages/*/node_modules 104 | pnpm install --frozen-lockfile 105 | .PHONY: reset-modules 106 | 107 | # ================================================================================================== -------------------------------------------------------------------------------- /docs/assets/NewGame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/docs/assets/NewGame.png -------------------------------------------------------------------------------- /docs/assets/NewGame.uml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !theme blueprint 3 | start 4 | 5 | :Player A initiates createGame; 6 | :Chain generates gameID; 7 | note right: gameID is now associated with this game session 8 | 9 | fork 10 | :Player A checks game status; 11 | if (Is Player B joined and sent drawInitialHand?) then (yes) 12 | :Game is ready to start; 13 | else (no) 14 | :Waiting for Player B; 15 | if (Does Player A send cancelGame?) then (yes) 16 | :Cancel game; 17 | stop 18 | endif 19 | endif 20 | fork again 21 | :Player B attempts to join game using gameID; 22 | if (Is Player B already joined?) then (yes) 23 | :Reject join request; 24 | else (no) 25 | :Player B sends drawInitialHand; 26 | if (Is Player A also joined and sent drawInitialHand?) then (yes) 27 | :Game is ready to start; 28 | else (no) 29 | :Waiting for Player A; 30 | endif 31 | endif 32 | end fork 33 | 34 | if (Timeout condition met?) then (yes) 35 | :Continue game process; 36 | else (no) 37 | :Anyone (including third parties) sends timeout; 38 | note right: If drawInitialHand not sent within limit 39 | endif 40 | 41 | stop 42 | @enduml -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xfable/monorepo", 3 | "author": "0xFable Team", 4 | "license": "BSD-3-Clause-Clear", 5 | "version": "1.0.0", 6 | "private": true, 7 | "packageManager": "pnpm@8.8.0" 8 | } 9 | -------------------------------------------------------------------------------- /packages/circuits/README.md: -------------------------------------------------------------------------------- 1 | # Circuits 2 | 3 | ## Prerequisites 4 | 5 | - Install Circom2 (currently this is done from source), as [explained here][circom-install]. 6 | - check that `circom --version` works 7 | - We tested with Circom 2.1.4 8 | 9 | [circom-install]: https://docs.circom.io/getting-started/installation/ 10 | 11 | ## Running tests 12 | 13 | `make test` 14 | 15 | This will try to prove and verify the circuits with pre-generated inputs. 16 | 17 | TODO: reuse logic from the frontend to generate the inputs automatically. The current procedure is 18 | to run the application then print out the inputs from the browser console. 19 | 20 | ## Legacy Tests 21 | 22 | This only works on Linux. 23 | 24 | You'll also need to install the npm package `circom-helper`, either globally or locally (but don't 25 | commit to git). We removed it because it caused [build 26 | issues](https://github.com/norswap/0xFable/issues/53) on some configurations, and we're [moving away 27 | from it anyway](https://github.com/norswap/0xFable/issues/52). 28 | 29 | ``` 30 | # only the first time 31 | make install-test-deps 32 | 33 | # in a shell (will remain active) 34 | make test-server 35 | 36 | # in another shell 37 | make test 38 | ``` -------------------------------------------------------------------------------- /packages/circuits/circom.config.js: -------------------------------------------------------------------------------- 1 | const circom_path = require('child_process').execSync('which circom', {encoding: 'utf8'} ).trim() 2 | const circom_path_rel = require('path').relative(process.cwd() + '/out', circom_path) 3 | const config = { 4 | "circom": circom_path_rel, 5 | "snarkjs": "../node_modules/snarkjs/build/cli.cjs", 6 | "circuitDirs": [ 7 | "../test/circuits" 8 | ] 9 | } 10 | console.log(JSON.stringify(config)) -------------------------------------------------------------------------------- /packages/circuits/circom.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "circom": "../../../../.cargo/bin/circom", 3 | "snarkjs": "./node_modules/snarkjs/build/cli.cjs", 4 | "circuitDirs": [ 5 | "./test/circuits" 6 | ] 7 | } -------------------------------------------------------------------------------- /packages/circuits/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xfable/circuits", 3 | "author": "cards-team", 4 | "license": "BSD-3-Clause-Clear", 5 | "version": "1.0.0", 6 | "dependencies": { 7 | "circomlib": "^2.0.5", 8 | "circomlibjs": "^0.1.7", 9 | "ffjavascript": "^0.2.60", 10 | "snarkjs": "^0.7.1", 11 | "viem": "^1.16.6" 12 | }, 13 | "devDependencies": { 14 | "jest": "^29.7.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/circuits/src/instantiated/Draw.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../proofs/Draw.circom"; 4 | 5 | // // Max 64 (2*32) cards in a deck and in a hand. 6 | component main {public [deckRoot, newDeckRoot, handRoot, newHandRoot, saltHash, publicRandom, initialHandSize, lastIndex]} = Draw(2); -------------------------------------------------------------------------------- /packages/circuits/src/instantiated/DrawHand.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../proofs/DrawHand.circom"; 4 | 5 | // Max 64 (2*32) cards in a deck and in a hand, draw 7 cards. 6 | component main {public [initialDeck, lastIndex, deckRoot, handRoot, saltHash, publicRandom]} = DrawHand(2, 7); -------------------------------------------------------------------------------- /packages/circuits/src/instantiated/Play.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../proofs/Play.circom"; 4 | 5 | // Max 64 (2*32) cards in a hand. 6 | component main {public [handRoot, newHandRoot, saltHash, cardIndex, lastIndex, playedCard]} = Play(2); -------------------------------------------------------------------------------- /packages/circuits/src/lib/BytePacking.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../../node_modules/circomlib/circuits/comparators.circom"; 4 | include "../../node_modules/circomlib/circuits/bitify.circom"; 5 | 6 | /// @dev when dealing with Num2Bits in circom, LSB is stored in index 0, MSB in last index 7 | /// @dev we use little endian when packing the cards 8 | template UnpackCards(n) { 9 | 10 | signal input packedCards[n]; 11 | signal output unpackedCards[n*31]; 12 | 13 | // unpack cards into bytes 14 | component lt[n*31]; 15 | for (var i = 0; i < n; i++) { 16 | var sum = 0; 17 | var mult = 1; 18 | var currentIndex; 19 | for (var j = 0; j < 31; j++) { 20 | currentIndex = (i*31)+j; 21 | unpackedCards[currentIndex] <-- (packedCards[i] >> (j*8)) & 255; 22 | // We use LessEqThan rather than LessThan (which has fewer constraints) because we want 23 | // to avoid comparison with 256, which is too high for 8 bits. 24 | lt[currentIndex] = LessEqThan(8); 25 | lt[currentIndex].in[0] <== unpackedCards[currentIndex]; 26 | lt[currentIndex].in[1] <== 255; 27 | lt[currentIndex].out === 1; 28 | sum += unpackedCards[currentIndex] * mult; 29 | var mult4 = mult + mult + mult + mult; 30 | var mult16 = mult4 + mult4 + mult4 + mult4; 31 | var mult64 = mult16 + mult16 + mult16 + mult16; 32 | mult = mult64 + mult64 + mult64 + mult64; 33 | } 34 | sum === packedCards[i]; 35 | } 36 | } 37 | 38 | template PackCards(n) { 39 | 40 | signal input unpackedCards[n*31]; 41 | signal output packedCards[n]; 42 | 43 | // pack cards into felt 44 | component lt[n*31]; 45 | for (var i = 0; i < n; i++) { 46 | var sum = 0; 47 | var mult = 1; 48 | var currentIndex; 49 | for (var j = 0; j < 31; j++) { 50 | currentIndex = (i*31)+j; 51 | // We use LessEqThan rather than LessThan (which has fewer constraints) because we want 52 | // to avoid comparison with 256, which is too high for 8 bits. 53 | lt[currentIndex] = LessEqThan(8); 54 | lt[currentIndex].in[0] <== unpackedCards[currentIndex]; 55 | lt[currentIndex].in[1] <== 255; 56 | lt[currentIndex].out === 1; 57 | sum += unpackedCards[currentIndex] * mult; 58 | var mult4 = mult + mult + mult + mult; 59 | var mult16 = mult4 + mult4 + mult4 + mult4; 60 | var mult64 = mult16 + mult16 + mult16 + mult16; 61 | mult = mult64 + mult64 + mult64 + mult64; 62 | } 63 | sum ==> packedCards[i]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/circuits/src/proofs/Play.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../lib/Card.circom"; 4 | include "../lib/BytePacking.circom"; 5 | 6 | include "../../node_modules/circomlib/circuits/mimcsponge.circom"; 7 | 8 | template Play(elementSize) { 9 | /// 12448 constraints 10 | 11 | // public inputs 12 | signal input handRoot; 13 | signal input newHandRoot; 14 | signal input saltHash; 15 | signal input cardIndex; 16 | signal input lastIndex; 17 | signal input playedCard; 18 | 19 | // private inputs 20 | signal input salt; 21 | signal input hand[elementSize]; 22 | signal input newHand[elementSize]; 23 | 24 | // verify the private salt matches the public salt hash 25 | component checkSalt = MiMCSponge(1, 220, 1); 26 | checkSalt.ins[0] <== salt; 27 | checkSalt.k <== 0; 28 | saltHash === checkSalt.outs[0]; 29 | 30 | // check the hand root matches the hand content before playing 31 | component checkHand = MiMCSponge(elementSize+1, 220, 1); 32 | for (var i = 0; i < elementSize; i++) { 33 | checkHand.ins[i] <== hand[i]; 34 | } 35 | checkHand.ins[elementSize] <== salt; 36 | checkHand.k <== 0; 37 | checkHand.outs[0] === handRoot; 38 | 39 | // unpack initial hand 40 | component unpackHand = UnpackCards(elementSize); 41 | signal initialHandInNum[elementSize*31]; 42 | unpackHand.packedCards <== hand; 43 | initialHandInNum <== unpackHand.unpackedCards; 44 | 45 | component playCard = RemoveCard(elementSize*31); 46 | 47 | // update hand 48 | playCard.lastIndex <== lastIndex; 49 | playCard.candidateIndex <== cardIndex; 50 | playCard.cardList <== initialHandInNum; 51 | // constraint selected card 52 | playCard.selectedCard === playedCard; 53 | // pack the updated hand into 256-bit elements 54 | component packHand = PackCards(elementSize); 55 | packHand.unpackedCards <== playCard.updatedCardList; 56 | for (var i = 0; i < elementSize; i++) { 57 | newHand[i] === packHand.packedCards[i]; 58 | } 59 | 60 | // check the hand root matches the hand root after drawing 61 | component checkNewHand = MiMCSponge(elementSize+1, 220, 1); 62 | for (var i = 0; i < elementSize; i++) { 63 | checkNewHand.ins[i] <== newHand[i]; 64 | } 65 | checkNewHand.ins[elementSize] <== salt; 66 | checkNewHand.k <== 0; 67 | checkNewHand.outs[0] === newHandRoot; 68 | 69 | } -------------------------------------------------------------------------------- /packages/circuits/test/Draw.test.js: -------------------------------------------------------------------------------- 1 | const circomlib = require('circomlibjs'); 2 | const ff = require('ffjavascript'); 3 | const { callGenWitness } = require('circom-helper'); 4 | const { bytesPacking } = require('./utils'); 5 | 6 | describe("Draw Card Test", () => { 7 | let mimcsponge; 8 | let salt = BigInt(1234); // random private salt 9 | let publicRandom = BigInt(5678); // block hash from smart contract 10 | let initialDeck = [], initialHand = []; 11 | let initialDeckSize = 62; 12 | let remainingDeckSize = 55; 13 | let initialHandSize = 7; 14 | 15 | beforeAll(async () => { 16 | mimcsponge = await circomlib.buildMimcSponge(); 17 | 18 | // initialize deck leaves and hand leaves 19 | for (let i = 0; i < remainingDeckSize; i++) { 20 | initialDeck.push(BigInt(i)); 21 | } 22 | for (let i = remainingDeckSize; i < initialDeckSize; i++) { 23 | initialDeck.push(BigInt(255)); 24 | initialHand.push(i); 25 | } 26 | initialHand = [...initialHand, ...Array(remainingDeckSize).fill(BigInt(255))]; 27 | }); 28 | 29 | it("Should correctly construct an draw proof", async () => { 30 | // draw cards 31 | let deck = [...initialDeck]; 32 | let hand = [...initialHand]; 33 | const randomness = mimcsponge.F.toObject(mimcsponge.multiHash([salt, publicRandom])); 34 | const lastIndex = remainingDeckSize - 1; 35 | let drawnIndex = randomness % BigInt(remainingDeckSize); 36 | hand[initialHandSize] = deck[drawnIndex]; 37 | deck[drawnIndex] = deck[lastIndex]; 38 | deck[lastIndex] = BigInt(255); 39 | 40 | // construct root 41 | initialDeck = bytesPacking(initialDeck); 42 | initialHand = bytesPacking(initialHand); 43 | let newDeck = bytesPacking(deck); 44 | let newHand = bytesPacking(hand); 45 | let deckRoot = mimcsponge.multiHash([...initialDeck, salt]); 46 | let handRoot = mimcsponge.multiHash([...initialHand, salt]); 47 | let newHandRoot = mimcsponge.multiHash([...newHand, salt]); 48 | let newDeckRoot = mimcsponge.multiHash([...newDeck, salt]); 49 | 50 | // construct the circuit inputs 51 | const circuit = 'Draw.test'; 52 | const circuitInputs = ff.utils.stringifyBigInts({ 53 | // public inputs 54 | deckRoot: mimcsponge.F.toObject(deckRoot), 55 | newDeckRoot: mimcsponge.F.toObject(newDeckRoot), 56 | handRoot: mimcsponge.F.toObject(handRoot), 57 | newHandRoot: mimcsponge.F.toObject(newHandRoot), 58 | saltHash: mimcsponge.F.toObject(mimcsponge.multiHash([salt])), 59 | publicRandom: publicRandom, 60 | initialHandSize: initialHandSize, 61 | lastIndex: lastIndex, 62 | // private inputs 63 | salt: salt, 64 | deck: initialDeck, 65 | hand: initialHand, 66 | newDeck: newDeck, 67 | newHand: newHand 68 | }); 69 | 70 | // Generate the witness 71 | expect(await callGenWitness(circuit, circuitInputs)).toBeDefined(); 72 | }) 73 | }) -------------------------------------------------------------------------------- /packages/circuits/test/DrawHand.test.js: -------------------------------------------------------------------------------- 1 | const circomlib = require('circomlibjs'); 2 | const ff = require('ffjavascript'); 3 | const { callGenWitness } = require('circom-helper'); 4 | const { bytesPacking } = require('./utils'); 5 | 6 | describe("Draw Hand Test", () => { 7 | let mimcsponge; 8 | let salt = BigInt(1234); // random private salt 9 | let publicRandom = BigInt(5678); // block hash from smart contract 10 | let initialDeck = [], initialHand = []; 11 | let deckRoot, handRoot; 12 | let deckSize = 62; 13 | let initialLastIndex = 61; 14 | 15 | beforeAll(async () => { 16 | mimcsponge = await circomlib.buildMimcSponge(); 17 | 18 | // initialize deck leaves and hand leaves 19 | for (let i = 0; i < deckSize; i++) { 20 | initialDeck.push(BigInt(i)); 21 | initialHand.push(BigInt(255)); 22 | } 23 | }); 24 | 25 | // set longer timeout for test 26 | jest.setTimeout(30000); 27 | 28 | it("Should correctly construct an initial hand proof", async () => { 29 | // assume user draws 7 cards 30 | const cardCount = 7; 31 | 32 | // draw cards 33 | let deck = [...initialDeck]; 34 | let hand = [...initialHand]; 35 | const randomness = mimcsponge.F.toObject(mimcsponge.multiHash([salt, publicRandom])); 36 | let lastIndex = deckSize - 1; 37 | let drawnIndex; 38 | for (let i = 0; i < cardCount; i++) { 39 | drawnIndex = randomness % BigInt(lastIndex + 1); 40 | hand[i] = deck[drawnIndex]; 41 | deck[drawnIndex] = deck[lastIndex]; 42 | deck[lastIndex] = BigInt(255); 43 | lastIndex--; 44 | } 45 | 46 | // construct root 47 | let newDeck = bytesPacking(deck); 48 | let newHand = bytesPacking(hand); 49 | handRoot = mimcsponge.multiHash([...newHand, salt]); 50 | deckRoot = mimcsponge.multiHash([...newDeck, salt]); 51 | 52 | // construct the circuit inputs 53 | const circuit = 'DrawHand.test'; 54 | const circuitInputs = ff.utils.stringifyBigInts({ 55 | // public inputs 56 | initialDeck: bytesPacking(initialDeck), 57 | lastIndex: initialLastIndex, 58 | deckRoot: mimcsponge.F.toObject(deckRoot), 59 | handRoot: mimcsponge.F.toObject(handRoot), 60 | saltHash: mimcsponge.F.toObject(mimcsponge.multiHash([salt])), 61 | publicRandom: publicRandom, 62 | // private inputs 63 | salt: salt, 64 | deck: newDeck, 65 | hand: newHand 66 | }); 67 | 68 | // Generate the witness 69 | expect(await callGenWitness(circuit, circuitInputs)).toBeDefined(); 70 | }) 71 | }) -------------------------------------------------------------------------------- /packages/circuits/test/Play.test.js: -------------------------------------------------------------------------------- 1 | const circomlib = require('circomlibjs'); 2 | const ff = require('ffjavascript'); 3 | const { callGenWitness } = require('circom-helper'); 4 | const { bytesPacking } = require('./utils'); 5 | 6 | describe("Play Card Test", () => { 7 | let mimcsponge; 8 | let salt = BigInt(1234); // random private salt 9 | let publicRandom = BigInt(5678); // block hash from smart contract 10 | let initialHand = []; 11 | let handSize = 62; 12 | let initialHandSize = 20; 13 | 14 | beforeAll(async () => { 15 | mimcsponge = await circomlib.buildMimcSponge(); 16 | 17 | // initialize deck leaves and hand leaves 18 | for (let i = 0; i < initialHandSize; i++) { 19 | initialHand.push(BigInt(i)); 20 | } 21 | initialHand = [...initialHand, ...Array(handSize-initialHandSize).fill(BigInt(255))]; 22 | }); 23 | 24 | it("Should correctly construct a play proof", async () => { 25 | // play card 26 | let hand = [...initialHand]; 27 | const randomness = mimcsponge.F.toObject(mimcsponge.multiHash([salt, publicRandom])); 28 | const lastIndex = initialHandSize - 1; 29 | let drawnIndex = randomness % BigInt(lastIndex); 30 | let selectedCard = hand[drawnIndex]; 31 | hand[drawnIndex] = hand[lastIndex]; 32 | hand[lastIndex] = BigInt(255); 33 | 34 | // construct root 35 | initialHand = bytesPacking(initialHand); 36 | let newHand = bytesPacking(hand); 37 | let handRoot = mimcsponge.multiHash([...initialHand, salt]); 38 | let newHandRoot = mimcsponge.multiHash([...newHand, salt]); 39 | 40 | // construct the circuit inputs 41 | const circuit = 'Play.test'; 42 | const circuitInputs = ff.utils.stringifyBigInts({ 43 | // public inputs 44 | handRoot: mimcsponge.F.toObject(handRoot), 45 | newHandRoot: mimcsponge.F.toObject(newHandRoot), 46 | saltHash: mimcsponge.F.toObject(mimcsponge.multiHash([salt])), 47 | cardIndex: drawnIndex, 48 | lastIndex: lastIndex, 49 | playedCard: selectedCard, 50 | // private inputs 51 | salt: salt, 52 | hand: initialHand, 53 | newHand: newHand 54 | }); 55 | 56 | // Generate the witness 57 | expect(await callGenWitness(circuit, circuitInputs)).toBeDefined(); 58 | }) 59 | }) -------------------------------------------------------------------------------- /packages/circuits/test/circuits/Draw.test.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../../src/proofs/Draw.circom"; 4 | 5 | component main {public [deckRoot, newDeckRoot, handRoot, newHandRoot, saltHash, publicRandom, initialHandSize, lastIndex]} = Draw(2); -------------------------------------------------------------------------------- /packages/circuits/test/circuits/DrawHand.test.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../../src/proofs/DrawHand.circom"; 4 | 5 | component main {public [initialDeck, deckRoot, handRoot, saltHash, publicRandom, lastIndex]} = DrawHand(2, 7); -------------------------------------------------------------------------------- /packages/circuits/test/circuits/Play.test.circom: -------------------------------------------------------------------------------- 1 | pragma circom 2.0.0; 2 | 3 | include "../../src/proofs/Play.circom"; 4 | 5 | component main {public [handRoot, newHandRoot, saltHash, cardIndex, lastIndex, playedCard]} = Play(2); -------------------------------------------------------------------------------- /packages/circuits/test/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bytesPacking: function(arr) { 3 | // 31 cards per field element 4 | elements = []; 5 | for (let i = 0; i < 2; i++) { 6 | let bytes = ""; 7 | for (let j = 0; j < 31; j++) { 8 | const byte = arr[i * 31 + j].toString(16); 9 | if (byte.length < 2) { 10 | bytes = "0" + byte + bytes; 11 | } else { 12 | bytes = byte + bytes; 13 | } 14 | } 15 | bytes = "0x00" + bytes; 16 | elements.push(BigInt(bytes)); 17 | } 18 | return elements; 19 | } 20 | } -------------------------------------------------------------------------------- /packages/circuits/verify.js: -------------------------------------------------------------------------------- 1 | const snarkjs = require("snarkjs") 2 | const fs = require("fs") 3 | 4 | // ================================================================================================= 5 | 6 | async function run(circuitName, inputs) { 7 | 8 | const { proof, publicSignals } = await snarkjs.groth16.fullProve(inputs, 9 | `out/${circuitName}_js/${circuitName}.wasm`, 10 | `out/${circuitName}.zkey`) 11 | 12 | // console.log(`Proof: ${JSON.stringify(proof)}`) 13 | 14 | const vKey = JSON.parse(fs.readFileSync(`out/${circuitName}.vkey.json`)) 15 | 16 | const res = await snarkjs.groth16.verify(vKey, publicSignals, proof) 17 | 18 | if (res === true) { 19 | console.log("Verification OK") 20 | } else { 21 | console.log("Invalid proof") 22 | } 23 | } 24 | 25 | // ------------------------------------------------------------------------------------------------- 26 | 27 | async function runWithFile(circuitName, inputFilename) { 28 | const inputs = JSON.parse(fs.readFileSync(inputFilename)) 29 | return run(circuitName, inputFilename) 30 | } 31 | 32 | // ================================================================================================= 33 | // Standard Examples 34 | 35 | const drawHandInputs = { 36 | // public 37 | initialDeck: [ 38 | 452312848583266382662295851582552835256924104183953103905329565847915725056n, 39 | 452312848583266388373324160190187140051835877600158453279131187530910662655n ], 40 | lastIndex: 23n, 41 | deckRoot: "0x2cedcf06ec7f5c6350b8deb0b9905552148e6d1468d03718c74de8b4ac4b2f3d", 42 | handRoot: "0x1a5f10052631a3a5e5742bf41a7db6aab32e520e64f1d781def8b6d5d155dc8f", 43 | saltHash: 10644022205700269842939357604110603061463166818082702766765548366499887869490n, 44 | publicRandom: 69n, 45 | // private 46 | salt: 42n, 47 | deck: [ 48 | 452312848583266388373324160190187058404079193295056622468015728205796344064n, 49 | 452312848583266388373324160190187140051835877600158453279131187530910662655n ], 50 | 51 | hand: [ 52 | 452312848583266388373324160190187140051835877600158453279062250449992618757n, 53 | 452312848583266388373324160190187140051835877600158453279131187530910662655n ] 54 | } 55 | 56 | // NOTE: If you want to run this example via a JSON file, the formatting looks like this: 57 | /* 58 | { 59 | "initialDeck": [ 60 | "452312848583266382662295851582552835256924104183953103905329565847915725056", 61 | "452312848583266388373324160190187140051835877600158453279131187530910662655" ], 62 | "lastIndex": 23, 63 | "deckRoot": "0x2cedcf06ec7f5c6350b8deb0b9905552148e6d1468d03718c74de8b4ac4b2f3d", 64 | "handRoot": "0x1a5f10052631a3a5e5742bf41a7db6aab32e520e64f1d781def8b6d5d155dc8f", 65 | "saltHash": "10644022205700269842939357604110603061463166818082702766765548366499887869490", 66 | "publicRandom": 69, 67 | "salt": 42, 68 | "deck": [ 69 | "452312848583266388373324160190187058404079193295056622468015728205796344064", 70 | "452312848583266388373324160190187140051835877600158453279131187530910662655" ], 71 | "hand": [ 72 | "452312848583266388373324160190187140051835877600158453279062250449992618757", 73 | "452312848583266388373324160190187140051835877600158453279131187530910662655" ] 74 | } 75 | */ 76 | 77 | // ================================================================================================= 78 | 79 | // Example uses: 80 | // node verify.js "DrawHand" "input.json" 81 | // node verify.js "DrawHand" 82 | 83 | async function main() { 84 | // 0 is node, 1 is verify.js 85 | const circuit = process.argv[2] 86 | const inputFile = process.argv[3] 87 | 88 | if (inputFile !== undefined) 89 | return runWithFile(circuit, inputFile) 90 | 91 | if (circuit === "DrawHand") 92 | return run(circuit, drawHandInputs) 93 | 94 | if (circuit === "Draw") { 95 | console.log("TODO: Include Draw circuit input example") 96 | return 97 | } 98 | 99 | if (circuit === "Play") { 100 | console.log("TODO: Include Play circuits input example") 101 | return 102 | } 103 | 104 | console.log(`Unknown circuit: ${circuit}`) 105 | } 106 | 107 | main().then(() => process.exit(0)) 108 | 109 | // ================================================================================================= -------------------------------------------------------------------------------- /packages/contracts/.env.example: -------------------------------------------------------------------------------- 1 | # This file can be imported into makefiles or shells to set deployment variables. 2 | 3 | # This is Anvil/Hardhat account 0 - safe to make public. 4 | export PRIVATE_KEY_LOCAL=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 5 | export PRIVATE_KEY_TEST=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 6 | export PRIVATE_KEY_MAIN=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 7 | 8 | export RPC_LOCAL=http://localhost:8545 9 | export RPC_TEST=https://rpc.chiadochain.net 10 | export RPC_MAIN=https://rpc.ankr.com/gnosis -------------------------------------------------------------------------------- /packages/contracts/.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity -------------------------------------------------------------------------------- /packages/contracts/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Install Foundry 13 | uses: onbjerg/foundry-toolchain@v1.0.6 14 | with: 15 | version: nightly 16 | 17 | - name: Install node 18 | uses: actions/setup-node@v2 19 | 20 | - name: Clone repo with submodules 21 | uses: actions/checkout@v2 22 | with: 23 | submodules: recursive 24 | 25 | - name: Install pnpm 26 | run: npm i -g pnpm 27 | 28 | - name: Install dependencies 29 | run: make 30 | 31 | - name: Show Foundry config 32 | run: forge config 33 | 34 | - name: Check contracts are linted 35 | run: pnpm run lint:check 36 | 37 | - name: Run tests 38 | run: make test 39 | -------------------------------------------------------------------------------- /packages/contracts/.prettierrc.yml: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: false 3 | endOfLine: lf 4 | printWidth: 120 5 | singleQuote: false 6 | tabWidth: 4 7 | trailingComma: all 8 | plugins: ["prettier-plugin-solidity"] -------------------------------------------------------------------------------- /packages/contracts/.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | 4 | "rules": { 5 | "compiler-version": ["error", ">=0.8.0 <0.8.20"], 6 | "const-name-snakecase": "off", 7 | "constructor-syntax": "error", 8 | "func-visibility": ["error", { "ignoreConstructors": true }], 9 | "max-line-length": ["error", 120], 10 | "not-rely-on-time": "off", 11 | "no-empty-blocks": "off", 12 | "payable-fallback": "off", 13 | "reason-string": "off", 14 | "code-complexity": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/contracts/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "solidity.packageDefaultDependenciesContractsDirectory": "src", 3 | "solidity.packageDefaultDependenciesDirectory": "lib", 4 | "solidity.defaultCompiler": "localFile" 5 | } -------------------------------------------------------------------------------- /packages/contracts/Makefile: -------------------------------------------------------------------------------- 1 | # include .env file and export its env vars 2 | # (-include to ignore error if it does not exist) 3 | -include .env 4 | 5 | # Set CONFIG to "LOCAL" by default. Other valid values: "TEST" or "MAIN". 6 | CONFIG?=LOCAL 7 | export CONFIG 8 | 9 | # See README.md for documentation. 10 | 11 | # The reason for this weird setup is that the IntelliJ solidity plugin will not resolve imports 12 | # if they're not in `lib` and do not have a `src` directory (the `remappings.txt` file is ignored). 13 | setup: 14 | if [ ! -f .env ]; then cp .env.example .env; fi 15 | ln -sf ../node_modules/forge-std lib/forge-std 16 | mkdir -p lib/openzeppelin 17 | ln -sf ../../node_modules/@openzeppelin/contracts lib/openzeppelin/src 18 | .PHONY: setup 19 | 20 | build: 21 | forge build 22 | .PHONY: build 23 | 24 | test: 25 | forge test -vv 26 | .PHONY: test 27 | 28 | testv: 29 | forge test -vvvv 30 | .PHONY: testv 31 | 32 | test-gas: 33 | forge test --gas-report 34 | .PHONY: test-gas 35 | 36 | watch: 37 | forge test --watch src/ 38 | .PHONY: watch 39 | 40 | test-fork: 41 | forge test --gas-report --fork-url ${ETH_NODE} 42 | .PHONY: test-fork 43 | 44 | clean: 45 | forge clean 46 | .PHONY: clean 47 | 48 | check: lint format-check 49 | .PHONY: check 50 | 51 | format-check: 52 | forge fmt --check src/*.sol src/test/*.sol src/deploy/*.sol src/libraries/*.sol 53 | .PHONY: format-check 54 | 55 | lint: 56 | pnpm solhint --config ./.solhint.json "src/*.sol" "src/test/*.sol" "src/deploy/*.sol" "src/libraries/*.sol" 57 | .PHONY: lint 58 | 59 | format: 60 | forge fmt src/*.sol src/test/*.sol src/deploy/*.sol src/libraries/*.sol 61 | .PHONY: format 62 | 63 | # The 1337 chain id matches "localhost" in Wagmi & "Localhost 8545" in MetaMask. 64 | # 65 | # Note that (for now) the block time specification is crucial here and needs to be << the proof 66 | # generation time, otherwise the simulation of that tx will be made as though it was in the block 67 | # where the public randomness is supposed to be derived from, which will yield a 0 blockhash and 68 | # random value. 69 | anvil: 70 | anvil --chain-id 1337 --block-time 2 71 | .PHONY: anvil 72 | 73 | # Dumps function, event and error selectors to out/selectors.txt 74 | selectors: 75 | forge upload-selectors --all > out/selectors.txt 76 | .PHONY: selectors 77 | 78 | # Deploys locally, to testnet or mainnet depending on the $CONFIG value (locally if not set). 79 | deploy: build 80 | @forge script src/deploy/Deploy.s.sol:Deploy \ 81 | --fork-url $(RPC_$(CONFIG)) \ 82 | --private-key $(PRIVATE_KEY_$(CONFIG)) \ 83 | --broadcast \ 84 | --non-interactive \ 85 | | grep "address " > out/deployment.txt 86 | @cat out/deployment.txt 87 | @node scripts/extract_contract_addresses_abis.js \ 88 | out/deployment.txt \ 89 | out/abis.json \ 90 | > out/deployment.json 91 | @cd ../webapp && pnpm wagmi generate 92 | .PHONY: deploy 93 | 94 | # Like make deploy, but deploy contracts that do not check proofs. 95 | deploy-noproofs: 96 | CHECK_PROOFS=false make deploy 97 | .PHONY: deploy-noproofs 98 | 99 | # Like make deploy, but the randomness is deterministic and there is no timeouts. 100 | deploy-norandom: 101 | NO_RANDOM=true make deploy 102 | .PHONY: deploy-norandom 103 | 104 | # Like make deploy, but proofs are not checked, the randomness is deterministic and there is no 105 | # timeouts. 106 | deploy-noproofs-norandom: 107 | NO_RANDOM=true CHECK_PROOFS=false make deploy 108 | .PHONY: deploy-noproofs-norandom 109 | 110 | # Sometimes the deployment crashes, this allows us to see why. Hardcoded to only use local 111 | # deployment. 112 | debug-deploy: 113 | forge script src/deploy/Deploy.s.sol:Deploy \ 114 | --fork-url http://localhost:8545 \ 115 | --private-key $(PRIVATE_KEY_LOCAL) \ 116 | --broadcast 117 | .PHONY: debug-deploy 118 | 119 | # My hatred for git modules runs so deep, I'm straight up vendoring all of forge-std. 120 | update-forge-std: 121 | rm -rf lib/forge-std 122 | cd lib && git clone git@github.com:foundry-rs/forge-std.git 123 | cd lib/forge-std && git submodule update --init --recursive 124 | .PHONY: update-forge-std -------------------------------------------------------------------------------- /packages/contracts/README.md: -------------------------------------------------------------------------------- 1 | # 0xFable Contracts 2 | 3 | ## Installation 4 | 5 | Tooling required: 6 | 7 | - [Foundry](https://github.com/gakonst/foundry) 8 | - Make 9 | - Node.js & [PNPM](https://pnpm.io/) (`npm install -g pnpm`) 10 | 11 | ## Configuration 12 | 13 | Run `make setup` and customize `.env` if necessary. 14 | 15 | By default: 16 | - `PRIVATE_KEY_LOCAL` is set to the first Anvil devnet account (seeded by ETH) 17 | 18 | ## Makefile Commands 19 | 20 | ### Lifecycle 21 | 22 | - `cd ../.. && make setup` - initialize libraries and npm packages 23 | - `make setup` - sets up symlinks & copies `.env.example` to `.env` if `.env` does not exist 24 | - `make build` - build your project 25 | - `make watch` - watch files and re-run tests on temp local devnet 26 | - `make clean` - remove compiled files 27 | 28 | ### Testing 29 | 30 | - `make test` - run tests on temp local devnet 31 | - `make test-gas` - run tests and show gas report on temp local devnet 32 | - `make test-fork` - run tests and show gas report using `$ETH_NODE` as RPC endpoint 33 | 34 | ### Code Quality 35 | 36 | - `make lint` - lint files (look for code smells) 37 | - `make format-check`- checks that the code is properly formatted, but does not modify it 38 | - `make format` - formats code 39 | - `make check` - runs `make lint` and `make format-check` 40 | 41 | ### Deployment 42 | 43 | - `make anvil` - run local Anvil devnet on port 1337 44 | - `make deploy` - deploy the contracts on the $RPC_$CONFIG, using `$PRIVATE_KEY_$CONFIG` as deployer 45 | private key, you can configure your own $CONFIG values, but we suggest `LOCAL`, `TEST` and `MAIN` 46 | for local devnet, testnet and mainnet respectively 47 | - the contract addresses are output to `out/deployment.json` 48 | - also updates the wagmi-generated bindings (in `packages/webapp/src/generated.ts`) 49 | - `make deploy-debug` prints more about what happens to deploy (only to local devnet) 50 | 51 | ### Misc 52 | 53 | - `make selectors` - dumps to selectors for functions, events and errors to `out/selectors.txt` 54 | - `make update-forge-std` - updates forge-std by git cloning it inside this repo (fuck git modules) 55 | -------------------------------------------------------------------------------- /packages/contracts/foundry.toml: -------------------------------------------------------------------------------- 1 | # Full reference 2 | # https://book.getfoundry.sh/reference/config/ 3 | 4 | [profile.default] 5 | solc_version = "0.8.19" 6 | optimizer = true 7 | optimizer_runs = 20000 8 | gas_reports = ["*"] 9 | via_ir = false 10 | 11 | # allow all paths — necessary for pnpm 12 | allow_paths = ["/"] 13 | 14 | # Ignores the "unused-param" solidity warning, which triggers for Circom-generated code. 15 | # We are still checking for unused variables via `make lint-check`, so this is fine. 16 | # error code reference: https://book.getfoundry.sh/reference/config/solidity-compiler#ignored_error_codes 17 | ignored_error_codes = [5667] 18 | 19 | [profile.ci] 20 | # Gives super verbose output by default (-vvvv) when running tests on CI. 21 | verbosity = 4 22 | -------------------------------------------------------------------------------- /packages/contracts/lib/forge-std: -------------------------------------------------------------------------------- 1 | ../node_modules/forge-std -------------------------------------------------------------------------------- /packages/contracts/lib/hardhat/console.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | // Nothing here! This is just a stub so that the Circom-generated verifier files are able to import 5 | // "hardhat/console.sol". (Note they don't actually use the console function!) -------------------------------------------------------------------------------- /packages/contracts/lib/openzeppelin/src: -------------------------------------------------------------------------------- 1 | ../../node_modules/@openzeppelin/contracts -------------------------------------------------------------------------------- /packages/contracts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xfable/contracts", 3 | "author": "cards-team", 4 | "license": "BSD-3-Clause-Clear", 5 | "version": "1.0.0", 6 | "files": [ 7 | "*.sol" 8 | ], 9 | "devDependencies": { 10 | "ds-test": "github:dapphub/ds-test#e282159d5170298eb2455a6c05280ab5a73a4ef0", 11 | "forge-std": "github:foundry-rs/forge-std#e8a047e3f40f13fa37af6fe14e6e06283d9a060e", 12 | "solhint": "^3.6.2" 13 | }, 14 | "dependencies": { 15 | "@openzeppelin/contracts": "^4.9.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/contracts/remappings.txt: -------------------------------------------------------------------------------- 1 | forge-std/=lib/forge-std/src/ 2 | ds-test/=node_modules/ds-test/src/ 3 | openzeppelin/=lib/openzeppelin/src/ 4 | multicall/=lib/multicall/ -------------------------------------------------------------------------------- /packages/contracts/scripts/extract_contract_addresses_abis.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { exec } = require("child_process") 3 | 4 | // Usage: takes two arguments `deployment_log_file` and `abi_output_file` 5 | 6 | // A file name (deployment.txt) which contains logs from a Foundry, where each line looks like 7 | // `MyContractName address 0x5FbDB2315678afecb367f032d93F642f64180aa3`. 8 | const deployment_log_file = process.argv[2] 9 | 10 | // A JSON file name where to put a mapping from contract name to ABI. 11 | const abi_output_file = process.argv[3] 12 | 13 | // The program outputs (to stdout) a JSON object with the contract names as keys, and the 14 | // addresses as values. It also stores the mapping from contract name to ABI in 15 | // `abi_output_file` (in JSON, obtained by calling `forge inspect`). 16 | 17 | const output = {} 18 | let abis = {} 19 | 20 | const lineReader = require('readline').createInterface({ 21 | input: fs.createReadStream(deployment_log_file) 22 | }); 23 | 24 | lineReader.on('line', line => { 25 | const [label, _, address] = line.trim().split(" ") 26 | output[label] = address 27 | exec(`forge inspect ${label} abi`, (error, stdout, stderr) => { 28 | if (error) throw error 29 | abis[label] = JSON.parse(stdout) 30 | }) 31 | }) 32 | 33 | lineReader.on('close', () => { 34 | console.log(JSON.stringify(output, null, 4)) 35 | }) 36 | 37 | process.on('beforeExit', () => { 38 | if (!abis) return 39 | fs.writeFile(abi_output_file, JSON.stringify(abis), err => { if (err) throw err }) 40 | abis = undefined 41 | }) 42 | -------------------------------------------------------------------------------- /packages/contracts/src/CardsCollection.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | import {DeckAirdrop} from "./DeckAirdrop.sol"; 5 | import {Inventory} from "./Inventory.sol"; 6 | 7 | import {ERC721} from "openzeppelin/token/ERC721/ERC721.sol"; 8 | import {Ownable} from "openzeppelin/access/Ownable.sol"; 9 | 10 | struct Lore { 11 | string name; 12 | string flavor; 13 | string URL; // solhint-disable-line 14 | } 15 | 16 | struct Stats { 17 | uint8 attack; 18 | uint8 defense; 19 | } 20 | 21 | struct Card { 22 | uint256 id; 23 | Lore lore; 24 | Stats stats; 25 | uint32 cardTypeID; 26 | } 27 | 28 | error Unauthorized(); 29 | 30 | contract CardsCollection is ERC721, Ownable { 31 | Inventory public inventory; 32 | 33 | // Let's skip 0 to catch bugs due to default values. 34 | uint256 private nextID = 1; 35 | 36 | constructor() ERC721("Cards", "CARD") Ownable() {} 37 | 38 | mapping(uint256 => Lore) public lore; 39 | mapping(uint256 => Stats) private stats_; 40 | mapping(uint256 => uint32) public cardType; 41 | 42 | // Airdrop manager. 43 | address public airdrop; 44 | 45 | function setInventory(Inventory inventory_) external onlyOwner { 46 | inventory = inventory_; 47 | } 48 | 49 | function setAirdrop(DeckAirdrop airdrop_) external onlyOwner { 50 | airdrop = address(airdrop_); 51 | } 52 | 53 | // Authorize the inventory contract to transfer cards. 54 | function _isApprovedOrOwner(address spender, uint256 tokenId) internal view override returns (bool) { 55 | return spender == address(inventory) || super._isApprovedOrOwner(spender, tokenId); 56 | } 57 | 58 | function mint( 59 | address to, 60 | string calldata name, 61 | string calldata flavor, 62 | string calldata URL, // solhint-disable-line 63 | uint8 attack, 64 | uint8 defense 65 | ) external returns (uint256 tokenID) { 66 | if (msg.sender != owner() && msg.sender != airdrop) { 67 | revert Unauthorized(); 68 | } 69 | 70 | tokenID = nextID++; 71 | _safeMint(to, tokenID, ""); 72 | stats_[tokenID] = Stats(attack, defense); 73 | lore[tokenID] = Lore(name, flavor, URL); 74 | // NOTE(nonso): `tokenID` serves as a placeholder for the cardType ID 75 | cardType[tokenID] = uint32(tokenID); 76 | } 77 | 78 | function stats(uint256 card) external view returns (Stats memory) { 79 | return stats_[card]; 80 | } 81 | 82 | // TODO - remove this function? 83 | function getLore(uint256 card) external view returns (Lore memory) { 84 | return lore[card]; 85 | } 86 | 87 | function getCard(uint256 card) external view returns (Card memory) { 88 | return Card(card, lore[card], stats_[card], cardType[card]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/contracts/src/DeckAirdrop.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | import {Inventory} from "./Inventory.sol"; 5 | import {CardsCollection} from "./CardsCollection.sol"; 6 | 7 | import {Ownable} from "openzeppelin/access/Ownable.sol"; 8 | 9 | error AlreadyClaimed(); 10 | 11 | contract DeckAirdrop is Ownable { 12 | uint256 public deckSize; 13 | 14 | Inventory public inventory; 15 | CardsCollection public cardsCollection; 16 | 17 | mapping(address => bool) public claimed; 18 | 19 | constructor(Inventory inventory_) Ownable() { 20 | inventory = inventory_; 21 | cardsCollection = inventory.originalCardsCollection(); 22 | } 23 | 24 | function claimAirdrop() external { 25 | if (claimed[msg.sender]) { 26 | revert AlreadyClaimed(); 27 | } 28 | address target = msg.sender; 29 | uint256 first = cardsCollection.mint(target, "Horrible Gremlin", "", "", 1, 1); 30 | cardsCollection.mint(target, "Horrible Gremlin", "", "", 1, 1); 31 | cardsCollection.mint(target, "Horrible Gremlin", "", "", 1, 1); 32 | cardsCollection.mint(target, "Horrible Gremlin", "", "", 1, 1); 33 | cardsCollection.mint(target, "Wise Elf", "", "", 1, 3); 34 | cardsCollection.mint(target, "Wise Elf", "", "", 1, 3); 35 | cardsCollection.mint(target, "Wise Elf", "", "", 1, 3); 36 | cardsCollection.mint(target, "Wise Elf", "", "", 1, 3); 37 | cardsCollection.mint(target, "Fire Fighter", "", "", 2, 2); 38 | cardsCollection.mint(target, "Fire Fighter", "", "", 2, 2); 39 | cardsCollection.mint(target, "Fire Fighter", "", "", 2, 2); 40 | cardsCollection.mint(target, "Fire Fighter", "", "", 2, 2); 41 | cardsCollection.mint(target, "Grave Digger", "", "", 2, 3); 42 | cardsCollection.mint(target, "Grave Digger", "", "", 2, 3); 43 | cardsCollection.mint(target, "Grave Digger", "", "", 2, 3); 44 | cardsCollection.mint(target, "Grave Digger", "", "", 2, 3); 45 | cardsCollection.mint(target, "Mana Fiend", "", "", 3, 1); 46 | cardsCollection.mint(target, "Mana Fiend", "", "", 3, 1); 47 | cardsCollection.mint(target, "Mana Fiend", "", "", 3, 1); 48 | cardsCollection.mint(target, "Mana Fiend", "", "", 3, 1); 49 | cardsCollection.mint(target, "Goblin Queen", "", "", 3, 2); 50 | cardsCollection.mint(target, "Goblin Queen", "", "", 3, 2); 51 | cardsCollection.mint(target, "Goblin Queen", "", "", 3, 2); 52 | uint256 last = cardsCollection.mint(target, "Goblin Queen", "", "", 3, 2); 53 | 54 | for (uint256 i = first; i <= last; ++i) { 55 | inventory.addCard(msg.sender, i); 56 | } 57 | 58 | uint256 numCards = last - first + 1; 59 | uint256[] memory cards = new uint256[](numCards); 60 | for (uint256 i = 0; i < numCards; ++i) { 61 | cards[i] = first + i; 62 | } 63 | 64 | inventory.addDeck(msg.sender, Inventory.Deck(cards)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/contracts/src/InventoryCardsCollection.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | import {CardsCollection, Card} from "./CardsCollection.sol"; 5 | 6 | import {ERC721} from "openzeppelin/token/ERC721/ERC721.sol"; 7 | import {ERC721Enumerable} from "openzeppelin/token/ERC721/extensions/ERC721Enumerable.sol"; 8 | 9 | contract InventoryCardsCollection is ERC721, ERC721Enumerable { 10 | error CardNotInInventory(uint256 cardID); 11 | error CallerNotInventory(); 12 | error TokenIsSoulbound(); 13 | 14 | CardsCollection public cardsCollection; 15 | address public inventory; 16 | 17 | constructor(CardsCollection cardsCollection_) ERC721("Inventory Cards", "ICARD") { 18 | cardsCollection = cardsCollection_; 19 | inventory = msg.sender; 20 | } 21 | 22 | // Override ERC721 & ERC721Enumerable "supportsInterface" to support both interfaces 23 | function supportsInterface(bytes4 interfaceId) 24 | public 25 | view 26 | virtual 27 | override(ERC721, ERC721Enumerable) 28 | returns (bool) 29 | { 30 | return ERC721.supportsInterface(interfaceId) || ERC721Enumerable.supportsInterface(interfaceId); 31 | } 32 | 33 | function mint(address to, uint256 tokenID) external { 34 | // No need to check for caller: inventory is minted after card transfer, 35 | // and minting can only occur once. 36 | if (cardsCollection.ownerOf(tokenID) != inventory) { 37 | revert CardNotInInventory(tokenID); 38 | } 39 | _safeMint(to, tokenID); 40 | } 41 | 42 | function burn(uint256 tokenID) external { 43 | if (msg.sender != inventory) { 44 | revert CallerNotInventory(); 45 | } 46 | _burn(tokenID); 47 | } 48 | 49 | // Override ERC721 & ERC721Enumerable "_beforeTokenTransfer" to support both interfaces 50 | function _beforeTokenTransfer(address from, address to, uint256 firstTokenId, uint256 batchSize) 51 | internal 52 | override(ERC721, ERC721Enumerable) 53 | { 54 | super._beforeTokenTransfer(from, to, firstTokenId, batchSize); 55 | if (from != address(0) && to != address(0)) { 56 | revert TokenIsSoulbound(); 57 | } 58 | } 59 | 60 | function getOwnedTokens(address owner) private view returns (uint256[] memory) { 61 | uint256 tokenCount = balanceOf(owner); 62 | uint256[] memory tokens = new uint256[](tokenCount); 63 | for (uint256 i = 0; i < tokenCount; i++) { 64 | tokens[i] = tokenOfOwnerByIndex(owner, i); 65 | } 66 | return tokens; 67 | } 68 | 69 | // Return the list of cards in the collection of the given player. 70 | function getCollection(address player) external view returns (Card[] memory collectionCards) { 71 | uint256[] memory collectionTokensId = getOwnedTokens(player); 72 | collectionCards = new Card[](collectionTokensId.length); 73 | for (uint256 i = 0; i < collectionTokensId.length; ++i) { 74 | collectionCards[i] = cardsCollection.getCard(collectionTokensId[i]); 75 | } 76 | return collectionCards; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/contracts/src/MockResolver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "./PlayerHandle.sol"; 5 | 6 | contract MockENSResolver is IENSResolver { 7 | mapping(bytes32 => string) public names; 8 | 9 | function setName(bytes32 node, string memory _name) public { 10 | names[node] = _name; 11 | } 12 | 13 | function name(bytes32 node) external view override returns (string memory) { 14 | return names[node]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/contracts/src/PlayerHandle.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; 6 | 7 | interface IENSResolver { 8 | function name(bytes32 node) external view returns (string memory); 9 | } 10 | 11 | error InvalidHandle(); 12 | error HandleAlreadyTaken(); 13 | error PlayerNotEligible(); 14 | 15 | contract PlayerHandle is Ownable { 16 | using Strings for uint256; 17 | 18 | IENSResolver public ensResolver; 19 | mapping(address => string) private handles; 20 | mapping(string => address) private handleOwners; 21 | mapping(address => bool) private useEns; 22 | 23 | event HandleRegistered(address indexed player, string handle); 24 | event HandleChanged(address indexed player, string newHandle); 25 | event UseEnsSet(address indexed player, bool useEns); 26 | 27 | constructor(address _ensResolver) { 28 | ensResolver = IENSResolver(_ensResolver); 29 | } 30 | 31 | function registerHandle(string memory handle) external { 32 | if (!checkHandleValidity(handle)) { 33 | revert InvalidHandle(); 34 | } 35 | if (handleOwners[handle] != address(0)) { 36 | revert HandleAlreadyTaken(); 37 | } 38 | if (!checkPlayerEligibility(msg.sender)) { 39 | revert PlayerNotEligible(); 40 | } 41 | 42 | if (bytes(handles[msg.sender]).length > 0) { 43 | handleOwners[handles[msg.sender]] = address(0); 44 | } 45 | 46 | handles[msg.sender] = handle; 47 | handleOwners[handle] = msg.sender; 48 | 49 | emit HandleRegistered(msg.sender, handle); 50 | } 51 | 52 | function changeHandle(string memory newHandle) external { 53 | if (!checkHandleValidity(newHandle)) { 54 | revert InvalidHandle(); 55 | } 56 | 57 | if (handleOwners[newHandle] != address(0)) { 58 | revert HandleAlreadyTaken(); 59 | } 60 | 61 | string memory oldHandle = handles[msg.sender]; 62 | handles[msg.sender] = newHandle; 63 | handleOwners[newHandle] = msg.sender; 64 | handleOwners[oldHandle] = address(0); 65 | 66 | emit HandleChanged(msg.sender, newHandle); 67 | } 68 | 69 | function setUseEns(bool _useEns) external { 70 | useEns[msg.sender] = _useEns; 71 | emit UseEnsSet(msg.sender, _useEns); 72 | } 73 | 74 | function getPlayerHandle(address player) external view returns (string memory) { 75 | if (useEns[player]) { 76 | bytes32 node = keccak256(abi.encodePacked(addressToBytes32(player))); 77 | string memory ensName = ensResolver.name(node); 78 | if (bytes(ensName).length > 0) { 79 | return ensName; 80 | } 81 | } 82 | return handles[player]; 83 | } 84 | 85 | function checkHandleValidity(string memory handle) public pure returns (bool) { 86 | bytes memory b = bytes(handle); 87 | if (b.length < 5 || b.length > 15) { 88 | return false; 89 | } 90 | for (uint256 i; i < b.length; i++) { 91 | bytes1 char = b[i]; 92 | if (!(char >= 0x30 && char <= 0x39) && !(char >= 0x41 && char <= 0x5A) && !(char >= 0x61 && char <= 0x7A)) { 93 | return false; 94 | } 95 | } 96 | return true; 97 | } 98 | 99 | function checkPlayerEligibility(address player) public pure returns (bool) { 100 | return true; // Placeholder function; implement eligibility logic as needed 101 | } 102 | 103 | function addressToBytes32(address addr) private pure returns (bytes32) { 104 | return bytes32(uint256(uint160(addr))); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packages/contracts/src/deploy/Deploy.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | import {CardsCollection} from "../CardsCollection.sol"; 5 | import {DeckAirdrop} from "../DeckAirdrop.sol"; 6 | import {Game} from "../Game.sol"; 7 | import {Inventory} from "../Inventory.sol"; 8 | import {InventoryCardsCollection} from "../InventoryCardsCollection.sol"; 9 | import {Groth16Verifier as DrawVerifier} from "../verifiers/DrawVerifier.sol"; 10 | import {Groth16Verifier as DrawHandVerifier} from "../verifiers/DrawHandVerifier.sol"; 11 | import {Groth16Verifier as PlayVerifier} from "../verifiers/PlayVerifier.sol"; 12 | import {MockENSResolver} from "../MockResolver.sol"; 13 | import {PlayerHandle} from "../PlayerHandle.sol"; 14 | 15 | import {Script, console2} from "forge-std/Script.sol"; 16 | // import {Multicall3} from "multicall/Multicall3.sol"; 17 | 18 | contract Deploy is Script { 19 | bytes32 private constant salt = bytes32(uint256(4269)); 20 | 21 | CardsCollection public cardsCollection; 22 | Inventory public inventory; 23 | InventoryCardsCollection public inventoryCardsCollection; 24 | DrawVerifier public drawVerifier; 25 | PlayVerifier public playVerifier; 26 | DrawHandVerifier public drawHandVerifier; 27 | Game public game; 28 | DeckAirdrop public airdrop; 29 | MockENSResolver public mockEnsResolver; 30 | PlayerHandle public playerHandle; 31 | 32 | bool private doLog = true; 33 | 34 | function dontLog() external { 35 | doLog = false; 36 | } 37 | 38 | function log(string memory s, address a) private view { 39 | if (doLog) { 40 | console2.log(s, a); // solhint-disable-line 41 | } 42 | } 43 | 44 | function run() external { 45 | vm.startBroadcast(); 46 | 47 | // deploy 48 | cardsCollection = new CardsCollection(); 49 | inventory = new Inventory(salt, cardsCollection); 50 | inventoryCardsCollection = inventory.inventoryCardsCollection(); 51 | drawVerifier = new DrawVerifier(); 52 | playVerifier = new PlayVerifier(); 53 | drawHandVerifier = new DrawHandVerifier(); 54 | bool checkProofs = vm.envOr("CHECK_PROOFS", true); 55 | bool noRandom = vm.envOr("NO_RANDOM", false); 56 | game = new Game(inventory, drawVerifier, playVerifier, drawHandVerifier, checkProofs, noRandom); 57 | airdrop = new DeckAirdrop(inventory); 58 | mockEnsResolver = new MockENSResolver(); 59 | playerHandle = new PlayerHandle(address(mockEnsResolver)); 60 | 61 | // initialize 62 | cardsCollection.setInventory(inventory); 63 | inventory.setAirdrop(airdrop); 64 | inventory.setGame(game); 65 | cardsCollection.setAirdrop(airdrop); 66 | 67 | log("CardsCollection address", address(cardsCollection)); 68 | log("Inventory address", address(inventory)); 69 | log("InventoryCardsCollection address", address(inventoryCardsCollection)); 70 | log("Game address", address(game)); 71 | log("DeckAirdrop address", address(airdrop)); 72 | log("MockENSResolver address", address(mockEnsResolver)); 73 | log("PlayerHandle address", address(playerHandle)); 74 | 75 | vm.stopBroadcast(); 76 | 77 | // Anvil first two test accounts. 78 | string memory mnemonic = "test test test test test test test test test test test junk"; 79 | (address account0,) = deriveRememberKey(mnemonic, 0); 80 | (address account1,) = deriveRememberKey(mnemonic, 1); 81 | 82 | vm.broadcast(account0); 83 | airdrop.claimAirdrop(); 84 | vm.broadcast(account1); 85 | airdrop.claimAirdrop(); 86 | 87 | // In case we need it. 88 | // Multicall3 multicall = new Multicall3(); 89 | // console2.log("Multicall3 address", address(multicall)); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/contracts/src/libraries/Constants.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | library Constants { 5 | uint8 internal constant INITIAL_HAND_SIZE = 7; 6 | 7 | uint16 internal constant STARTING_HEALTH = 20; 8 | 9 | // Marks the absence of index inside an index array. 10 | uint8 internal constant NONE = 255; 11 | 12 | // Max number of decks that each player can have. 13 | uint256 internal constant MAX_DECKS = 256; 14 | 15 | // Min number of cards in a deck. 16 | uint256 internal constant MIN_DECK_SIZE = 10; 17 | 18 | // Max number of cards in a deck. 19 | uint256 internal constant MAX_DECK_SIZE = 62; 20 | 21 | // Max card copies in a deck. 22 | uint256 private constant MAX_CARD_COPY = 3; 23 | 24 | // The prime that bounds the field used by our proof scheme of choice. 25 | // Currently, this is for Plonk. 26 | uint256 internal constant PROOF_CURVE_ORDER = 27 | 21888242871839275222246405745257275088548364400416034343698204186575808495617; 28 | } 29 | -------------------------------------------------------------------------------- /packages/contracts/src/libraries/Errors.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | library Errors { 5 | // The game doesn't exist or has already ended. 6 | error NoGameNoLife(); 7 | 8 | // The game hasn't started yet (at least one player hasn't joined or drawn his initial hand). 9 | error FalseStart(); 10 | 11 | // Trying to take an action when it's not your turn. 12 | error WrongPlayer(); 13 | 14 | // Player is trying to take a game action but hasn't drawn his initial hand yet. 15 | error PlayerHasntDrawn(); 16 | 17 | // Trying to take a game action that is not allowed right now. 18 | error WrongStep(); 19 | 20 | // Trying to start a game with fewer than 2 people. 21 | error YoullNeverPlayAlone(); 22 | 23 | // Trying to create a game while already being in one. 24 | error OvereagerCreator(); 25 | 26 | // Game creators didn't supply the same number of decks than the number of players. 27 | error WrongNumberOfDecks(); 28 | 29 | // Trying to join or decline a game that you already joined. 30 | error AlreadyJoined(); 31 | 32 | // Trying to draw an initial hand again. 33 | error AlreadyDrew(); 34 | 35 | // Trying to cancel a game you didn't create. 36 | error OvereagerCanceller(); 37 | 38 | // Trying to join a full game (total number of players reached). 39 | error GameIsFull(); 40 | 41 | // Trying to cancel or join a game where all the players have already joined. 42 | // (We don't use the term "started", which we reserve for when all players have drawn their 43 | // initial hand.) 44 | error GameAlreadyLocked(); 45 | 46 | // Trying to do actions in a game that has already ended. 47 | error GameAlreadyEnded(); 48 | 49 | // ZK proof didn't verify. 50 | error WrongProof(); 51 | 52 | // Attempt to join game was rejected by the join check. 53 | error NotAllowedToJoin(); 54 | 55 | // Trying to concede a game that you are not participating in. 56 | error PlayerNotInGame(); 57 | 58 | // Trying to play a card whose index is invalid (bigger than card array size). 59 | error CardIndexTooHigh(); 60 | 61 | // Trying to attack a player whose index is out of range, or trying to attack oneself. 62 | error WrongAttackTarget(); 63 | 64 | // Signals that an attacker was specified that is not on the player's battlefield. 65 | error AttackerNotOnBattlefield(); 66 | 67 | // Mismatch between the number of specified attackers and defenders. 68 | error AttackerDefenderMismatch(); 69 | 70 | // Specifiying the same attacking creature multiple time. 71 | error DuplicateAttacker(); 72 | 73 | // Specifiying the same defending creature multiple time. 74 | error DuplicateDefender(); 75 | 76 | // Specifying a defender with an index bigger than the number of creatures on the battlefield. 77 | error DefenderIndexTooHigh(uint8 index); 78 | 79 | // Specifying an attacker with an index bigger than the number of attacking creatures. 80 | error AttackerIndexTooHigh(uint8 index); 81 | 82 | // Signals that a defender was specified that is not on the player's battlefield. 83 | error DefenderNotOnBattlefield(); 84 | 85 | // Trying to defend with an attacker (on the battlefield at the given index). 86 | error DefenderAttacking(uint8 index); 87 | 88 | // ZK proof generated is incorrect 89 | error InvalidProof(); 90 | 91 | // Trying to boot a timed out player when the timeout hasn't elapsed yet. 92 | error GameNotTimedOut(); 93 | 94 | // Should only revert with this error if the implementation is erroneous. 95 | error ImplementationError(); 96 | } 97 | -------------------------------------------------------------------------------- /packages/contracts/src/libraries/Structs.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | // Action that can be taken in the game. 5 | enum GameStep { 6 | UNINITIALIZED, 7 | DRAW, 8 | PLAY, 9 | ATTACK, 10 | DEFEND, 11 | END_TURN, 12 | ENDED 13 | } 14 | 15 | // Per-player game data. 16 | struct PlayerData { 17 | uint16 health; 18 | bool defeated; 19 | uint8 deckStart; 20 | uint8 deckEnd; 21 | uint8 handSize; 22 | uint8 deckSize; 23 | // The block number at which the player's joinGame transaction landed. 24 | // NOTE: Since this is only used at the start of the game, it could be packed into another 25 | // uint256 value (e.g. battlefield). 26 | uint256 joinBlockNum; 27 | // Hash of a secret salt value that the players uses to generate the hand and deck roots. 28 | uint256 saltHash; 29 | // A hash of the content of the player's hand + the player's secret salt. 30 | bytes32 handRoot; 31 | // A hash of the content of the player's deck + the player's secret salt. 32 | bytes32 deckRoot; 33 | // Bitfield of cards in the player's battlefield, for each bit: 1 if the card at the same 34 | // index as the bit in `GameData.cards` is on the battlefield, 0 otherwise. 35 | uint256 battlefield; 36 | // Bitfield of cards in the player's graveyard (same thing as `battlefield`). 37 | uint256 graveyard; 38 | uint8[] attacking; 39 | } 40 | 41 | // All the data for a single game instance. 42 | struct GameData { 43 | address gameCreator; 44 | mapping(address => PlayerData) playerData; 45 | address[] players; 46 | // Last block number at which the game data changed, updated in player actions via the 47 | // `step` modifier, as well as in createGame, joinGame, cancelGame and concedeGame. 48 | uint256 lastBlockNum; 49 | uint8 playersLeftToJoin; 50 | uint8[] livePlayers; 51 | function (uint256, address, uint8, bytes memory) external returns (bool) joinCheck; 52 | uint8 currentPlayer; 53 | GameStep currentStep; 54 | address attackingPlayer; 55 | // Array of playable cards in this game (NFT IDs) — concatenation of players' initial decks 56 | // used in this game. 57 | uint256[] cards; 58 | } 59 | 60 | // A read-friendly version of `GameData`, adding the gameID, flattening the player data into an 61 | // arary, excluding the joinCheck predicate, as well as the cards array that never changes. Use 62 | // `getCards()` to read them instead. 63 | struct FetchedGameData { 64 | uint256 gameID; 65 | address gameCreator; 66 | address[] players; 67 | PlayerData[] playerData; 68 | uint256 lastBlockNum; 69 | uint256 publicRandomness; 70 | uint8 playersLeftToJoin; 71 | uint8[] livePlayers; 72 | uint8 currentPlayer; 73 | GameStep currentStep; 74 | address attackingPlayer; 75 | uint256[] cards; 76 | } 77 | -------------------------------------------------------------------------------- /packages/contracts/src/libraries/Utils.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Constants} from "./Constants.sol"; 5 | 6 | library Utils { 7 | // https://gist.github.com/subhodi/b3b86cc13ad2636420963e692a4d896f 8 | function sort(uint256[] memory data) internal view returns (uint256[] memory) { 9 | quickSort(data, int256(0), int256(data.length - 1)); 10 | return data; 11 | } 12 | 13 | function quickSort(uint256[] memory arr, int256 left, int256 right) internal view { 14 | int256 i = left; 15 | int256 j = right; 16 | if (i == j) return; 17 | uint256 pivot = arr[uint256(left + (right - left) / 2)]; 18 | while (i <= j) { 19 | while (arr[uint256(i)] < pivot) i++; 20 | while (pivot < arr[uint256(j)]) j--; 21 | if (i <= j) { 22 | (arr[uint256(i)], arr[uint256(j)]) = (arr[uint256(j)], arr[uint256(i)]); 23 | i++; 24 | j--; 25 | } 26 | } 27 | if (left < j) { 28 | quickSort(arr, left, j); 29 | } 30 | if (i < right) { 31 | quickSort(arr, i, right); 32 | } 33 | } 34 | 35 | // --------------------------------------------------------------------------------------------- 36 | 37 | // Clear (zero) the contents of the array and make it zero-sized. 38 | function clear(uint8[] storage array) internal { 39 | // TODO should be done in assembly, avoiding to overwrite the size on every pop 40 | for (uint256 i = 0; i < array.length; ++i) { 41 | array.pop(); 42 | } 43 | } 44 | 45 | // --------------------------------------------------------------------------------------------- 46 | 47 | // Returns true if the array contains the items. 48 | function contains(uint8[] storage array, uint8 item) internal view returns (bool) { 49 | for (uint256 i = 0; i < array.length; ++i) { 50 | if (array[i] == item) return true; 51 | } 52 | return false; 53 | } 54 | 55 | // --------------------------------------------------------------------------------------------- 56 | 57 | // Returns true if the array contains duplicate elements (in O(n) time). 255 (NONE) is ignored. 58 | function hasDuplicate(uint8[] calldata array) internal pure returns (bool) { 59 | uint256 bitmap = 0; 60 | for (uint256 i = 0; i < array.length; ++i) { 61 | if (array[i] != Constants.NONE && (bitmap & (1 << array[i])) != 0) return true; 62 | bitmap |= 1 << array[i]; 63 | } 64 | return false; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/contracts/src/test/Inventory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-3-Clause-Clear 2 | pragma solidity ^0.8.0; 3 | 4 | import {CardsCollection} from "../CardsCollection.sol"; 5 | import {DeckAirdrop} from "../DeckAirdrop.sol"; 6 | import {Inventory} from "../Inventory.sol"; 7 | 8 | import {Test} from "forge-std/Test.sol"; 9 | import {Deploy} from "../deploy/Deploy.s.sol"; 10 | 11 | contract InventoryTest is Test { 12 | Deploy private deployment; 13 | CardsCollection private cardsCollection; 14 | Inventory private inventory; 15 | DeckAirdrop private airdrop; 16 | 17 | address private constant player1 = 0x00000000000000000000000000000000DeaDBeef; 18 | address private constant player2 = 0x00000000000000000000000000000000deAdBAbE; 19 | 20 | function setUp() public { 21 | deployment = new Deploy(); 22 | deployment.dontLog(); 23 | deployment.run(); 24 | 25 | cardsCollection = deployment.cardsCollection(); 26 | inventory = deployment.inventory(); 27 | airdrop = deployment.airdrop(); 28 | 29 | vm.prank(player1); 30 | airdrop.claimAirdrop(); 31 | } 32 | 33 | // expect revert if player's deck contains a card with more than `MAX_CARD_COPY` copies. 34 | function testCheckDeckExceedsMaxCopy() public { 35 | uint8 deckId = 0; 36 | uint256 randomCard = inventory.getDeck(player1, deckId)[2]; 37 | 38 | // increase card `randomCard` copies to 4 39 | vm.startPrank(player1); 40 | inventory.addCardToDeck(player1, deckId, randomCard); 41 | inventory.addCardToDeck(player1, deckId, randomCard); 42 | inventory.addCardToDeck(player1, deckId, randomCard); 43 | 44 | vm.expectRevert(abi.encodeWithSelector(Inventory.CardExceedsMaxCopy.selector, randomCard)); 45 | 46 | inventory.checkDeck(player1, deckId); 47 | } 48 | 49 | // expect revert if player's deck contains a card they don't own. 50 | function testCheckDeckOnlyInventoryCards() public { 51 | // mint card `randomMint` to player2. 52 | vm.startPrank(cardsCollection.airdrop()); 53 | uint256 randomMint = cardsCollection.mint(player2, "Horrible Gremlin", "", "", 1, 1); 54 | 55 | uint8 deckId = 0; 56 | 57 | // scenario 1: Add Non-Inventory card to deck. 58 | // player1 adds a card that has has not being staked in the inventory to its deck 59 | changePrank(player1); 60 | // add card `randomMint` to inventory. 61 | inventory.addCardToDeck(player1, deckId, randomMint); 62 | vm.expectRevert("ERC721: invalid token ID"); 63 | inventory.checkDeck(player1, deckId); 64 | 65 | // scenario 2: Add Inventory card to deck. 66 | // player1 adds a card that has being staked in the inventory to their deck but 67 | // they don't own it. 68 | changePrank(player2); 69 | // add card `randomMint` to inventory. 70 | inventory.addCard(player2, randomMint); 71 | 72 | changePrank(player1); 73 | // add card `randomMint` to player1's deck. 74 | inventory.addCardToDeck(player1, deckId, randomMint); 75 | vm.expectRevert(abi.encodeWithSelector(Inventory.CardNotInInventory.selector, randomMint)); 76 | inventory.checkDeck(player1, deckId); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/contracts/src/test/PlayerHandle.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "forge-std/Test.sol"; 5 | import "../PlayerHandle.sol"; 6 | import {MockENSResolver} from "../MockResolver.sol"; 7 | 8 | // running tests for PlayerHandle and MockENSResolver 9 | contract PlayerHandleTest is Test { 10 | PlayerHandle public playerHandle; 11 | MockENSResolver public mockENSResolver; 12 | 13 | address public alice = address(0x1); 14 | address public bob = address(0x2); 15 | 16 | function setUp() public { 17 | mockENSResolver = new MockENSResolver(); 18 | playerHandle = new PlayerHandle(address(mockENSResolver)); 19 | } 20 | 21 | function testRegisterHandle() public { 22 | vm.prank(alice); 23 | playerHandle.registerHandle("AliceHandle"); 24 | 25 | assertEq(playerHandle.getPlayerHandle(alice), "AliceHandle"); 26 | } 27 | 28 | function testRegisterInvalidHandle() public { 29 | vm.prank(alice); 30 | vm.expectRevert(abi.encodeWithSignature("InvalidHandle()")); 31 | playerHandle.registerHandle("a"); 32 | } 33 | 34 | function testHandleAlreadyTaken() public { 35 | vm.prank(alice); 36 | playerHandle.registerHandle("AliceHandle"); 37 | 38 | vm.prank(bob); 39 | vm.expectRevert(abi.encodeWithSignature("HandleAlreadyTaken()")); 40 | playerHandle.registerHandle("AliceHandle"); 41 | } 42 | 43 | function testChangeHandle() public { 44 | vm.prank(alice); 45 | playerHandle.registerHandle("AliceHandle"); 46 | 47 | vm.prank(alice); 48 | playerHandle.changeHandle("NewAliceHandle"); 49 | 50 | assertEq(playerHandle.getPlayerHandle(alice), "NewAliceHandle"); 51 | } 52 | 53 | function testSetUseEns() public { 54 | bytes32 node = keccak256(abi.encodePacked(addressToBytes32(alice))); 55 | mockENSResolver.setName(node, "alice.eth"); 56 | 57 | vm.prank(alice); 58 | playerHandle.setUseEns(true); 59 | 60 | assertEq(playerHandle.getPlayerHandle(alice), "alice.eth"); 61 | } 62 | 63 | function testUnsetUseEns() public { 64 | bytes32 node = keccak256(abi.encodePacked(addressToBytes32(alice))); 65 | mockENSResolver.setName(node, "alice.eth"); 66 | 67 | vm.prank(alice); 68 | playerHandle.registerHandle("AliceHandle"); 69 | playerHandle.setUseEns(true); 70 | playerHandle.setUseEns(false); 71 | 72 | assertEq(playerHandle.getPlayerHandle(alice), "AliceHandle"); 73 | } 74 | 75 | function addressToBytes32(address addr) private pure returns (bytes32) { 76 | return bytes32(uint256(uint160(addr))); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/e2e/.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - name: Install dependencies 17 | run: pnpm install 18 | - name: Install Playwright Browsers 19 | run: pnpm exec playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: pnpm exec playwright test 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /packages/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /playwright/.cache/ 5 | -------------------------------------------------------------------------------- /packages/e2e/Makefile: -------------------------------------------------------------------------------- 1 | # NOTE: Using playwright commands — synpress commands use Cypress 2 | 3 | URL?=http://localhost:3000 4 | 5 | # Synpress: needed to share state between tests 6 | export SERIAL_MODE?=true 7 | 8 | HEADLESS?=true 9 | ifeq ($(HEADLESS),true) 10 | # Synpress: HEADLESS_MODE defined means don't show the browser. 11 | export HEADLESS_MODE?=true 12 | else 13 | HEADED=--headed 14 | endif 15 | 16 | # Only run test on Chrome 17 | chrome: 18 | pnpm playwright test $(HEADED) --project=chromium 19 | .PHONY: chrome 20 | 21 | # Only run test on Firefox 22 | firefox: 23 | pnpm playwright test $(HEADED) --project=firefox 24 | .PHONY: firefox 25 | 26 | # Only run test on Webkit 27 | webkit: 28 | pnpm playwright test $(HEADED) --project=webkit 29 | .PHONY: webkit 30 | 31 | # Run tests on all browsers 32 | # Used to be `pnpm playwright test $(HEADED)` but this makes the tests flaky (don't know why). 33 | all: chrome firefox webkit 34 | .PHONY: all 35 | 36 | # Use UI mode (potentially useful for debugging) 37 | ui: 38 | HEADLESS_MODE=false pnpm playwright test $(HEADED) --ui 39 | .PHONY: ui 40 | 41 | # Show last generated report 42 | report: 43 | pnpm playwright show-report 44 | .PHONY: report -------------------------------------------------------------------------------- /packages/e2e/README.md: -------------------------------------------------------------------------------- 1 | # End To End Testing 2 | 3 | Using [Playwright](https://playwright.dev/) & [Synpress](https://github.com/Synthetixio/synpress), 4 | see [Makefile](Makefile) for commands. Prefix any command with `HEADLESS=false` to display the 5 | browser window while the tests are running. 6 | 7 | The local chain + app must be running (and the contracts must have been deployed) for the tests to 8 | proceed (`make anvil`, `make deploy` and `make dev` in the top-level Makefile). 9 | 10 | You might have to change the `PROOF_TIME` constant in `./tests/specs/create.spec.ts` to a 11 | larger value. The current value is 25s which is a little above the time it takes to generate the 12 | proof on Firefox on a beefy M1 Macbook Pro (Chrome is usually faster). 13 | 14 | Note that if the tests fail midway through, it will be necessary to redeploy the contracts in order 15 | to rerun the tests. 16 | 17 | TODO: Write a cleanup script that cleans up to on-chain state to avoid this. 18 | 19 | Usually testing with `make chrome` is enough, but please run `make all-browsers` before submitting 20 | your pull request. 21 | 22 | To avoid the overhead of proof generation, you can prefix `NO_PROOFS=1` in front of the `make` 23 | command, which will make the e2e tests with `make deploy-noproofs` and `make dev-noproofs` 24 | 25 | ## Writing New Tests 26 | 27 | To record a test by clicking in the user window, include `await sharedPage.pause()` in the tests, at 28 | the point where wish to record. Then run the tests **with HEADLESS=false**. The inspector window 29 | will pop, letting you record test actions. -------------------------------------------------------------------------------- /packages/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xfable/e2e", 3 | "version": "1.0.0", 4 | "private": "true", 5 | "type": "module", 6 | "devDependencies": { 7 | "@playwright/test": "^1.38.1", 8 | "@synthetixio/synpress": "^3.7.1", 9 | "@types/node": "^20.8.7", 10 | "viem": "^1.16.6" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | // baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: { ...devices['Desktop Chrome'] }, 38 | }, 39 | 40 | { 41 | name: 'firefox', 42 | use: { ...devices['Desktop Firefox'] }, 43 | }, 44 | 45 | { 46 | name: 'webkit', 47 | use: { ...devices['Desktop Safari'] }, 48 | }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | // webServer: { 73 | // command: 'npm run start', 74 | // url: 'http://127.0.0.1:3000', 75 | // reuseExistingServer: !process.env.CI, 76 | // }, 77 | }); 78 | -------------------------------------------------------------------------------- /packages/e2e/tests/chain.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createPublicClient, 3 | createWalletClient, 4 | decodeEventLog, 5 | getContract, HDAccount, 6 | http, 7 | TransactionReceipt 8 | } from "viem" 9 | import { mnemonicToAccount } from "viem/accounts" 10 | import { localhost } from "viem/chains" 11 | 12 | globalThis.exports = {} 13 | global.exports = {} 14 | 15 | import { gameABI } from "../../webapp/src/generated" 16 | import { deployment } from "../../webapp/src/deployment" 17 | 18 | const publicClient = createPublicClient({ 19 | chain: localhost, 20 | transport: http() 21 | }) 22 | 23 | const walletClient = createWalletClient({ 24 | chain: localhost, 25 | transport: http() 26 | }) 27 | 28 | const mnemonic = "test test test test test test test test test test test junk" 29 | const account1 = mnemonicToAccount(mnemonic, { addressIndex: 0 }) 30 | const account2 = mnemonicToAccount(mnemonic, { addressIndex: 1 }) 31 | 32 | const game = getContract({ 33 | address: deployment.Game, 34 | abi: gameABI, 35 | publicClient, 36 | walletClient 37 | }) 38 | 39 | export async function createGame(): Promise { 40 | const hash = await game.write.createGame([2], { 41 | chain: localhost, 42 | account: account1 43 | }) 44 | const tx: TransactionReceipt = await publicClient.waitForTransactionReceipt({ hash }) 45 | const gameCreatedEvent = decodeEventLog({ 46 | abi: gameABI, 47 | data: tx.logs[0].data, 48 | topics: tx.logs[0]["topics"] 49 | }) 50 | return gameCreatedEvent.args["gameID"] 51 | } 52 | 53 | // Temporary, we do use 0x0 to signal the absence of a root, so we need to use a different value. 54 | const HashOne = "0x0000000000000000000000000000000000000000000000000000000000000001" 55 | 56 | export async function joinGame(account: HDAccount, gameID: bigint): Promise { 57 | const hash = await game.write.joinGame([ 58 | gameID, 59 | 0, // deckID 60 | HashOne, // data for callback 61 | HashOne, // hand root 62 | HashOne, // deck root 63 | HashOne, // proof 64 | ], { 65 | chain: localhost, 66 | account 67 | }) 68 | await publicClient.waitForTransactionReceipt({ hash }) 69 | } 70 | 71 | export async function setupGame(): Promise { 72 | const gameID = await createGame() 73 | await joinGame(account1, gameID) 74 | await joinGame(account2, gameID) 75 | } 76 | 77 | export async function getGameID(): Promise { 78 | return await game.read.inGame([ account1.address ]) 79 | } -------------------------------------------------------------------------------- /packages/e2e/tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/drptbl/synpress-examples/blob/master/playwright/shared-state/fixtures.ts 2 | // Changed global to globalThis to avoid warnings. 3 | 4 | import { test as base, chromium, type BrowserContext } from "@playwright/test"; 5 | import metamask from "@synthetixio/synpress/commands/metamask.js"; 6 | import helpers from "@synthetixio/synpress/helpers.js"; 7 | 8 | const { initialSetup } = metamask; 9 | const { prepareMetamask } = helpers; 10 | 11 | export const test = base.extend<{ 12 | context: BrowserContext; 13 | }>({ 14 | context: async ({}, use) => { 15 | // required for synpress 16 | globalThis.expect = expect; 17 | // download metamask 18 | const metamaskPath = await prepareMetamask( 19 | process.env.METAMASK_VERSION || "10.25.0" 20 | ); 21 | // prepare browser args 22 | const browserArgs = [ 23 | `--disable-extensions-except=${metamaskPath}`, 24 | `--load-extension=${metamaskPath}`, 25 | "--remote-debugging-port=9222", 26 | ]; 27 | if (process.env.CI) { 28 | browserArgs.push("--disable-gpu"); 29 | } 30 | if (process.env.HEADLESS_MODE) { 31 | browserArgs.push("--headless=new"); 32 | } 33 | // launch browser 34 | const context = await chromium.launchPersistentContext("", { 35 | headless: false, 36 | args: browserArgs, 37 | }); 38 | // wait for metamask 39 | await context.pages()[0].waitForTimeout(3000); 40 | // setup metamask 41 | await initialSetup(chromium, { 42 | secretWordsOrPrivateKey: 43 | // "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", 44 | "test test test test test test test test test test test junk", 45 | // "amused spin first verb garlic pumpkin dish aerobic run smoke subway slogan", 46 | // "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", 47 | network: "localhost", 48 | password: "Tester@1234", 49 | enableAdvancedSettings: true, 50 | }); 51 | await use(context); 52 | if (!process.env.SERIAL_MODE) { 53 | await context.close(); 54 | } 55 | }, 56 | }); 57 | export const expect = test.expect; 58 | 59 | // Anvil account 2 secret key: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" -------------------------------------------------------------------------------- /packages/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "baseUrl": "tests", 5 | // "baseUrl": "node_modules", 6 | "types": [ 7 | "node", 8 | "cypress", 9 | "@synthetixio/synpress/support", 10 | "cypress-wait-until", 11 | "@testing-library/cypress" 12 | ], 13 | "lib": ["dom", "dom.iterable", "es2022"], 14 | "allowJs": true, 15 | "checkJs": true, 16 | "skipLibCheck": true, 17 | "strict": false, 18 | "alwaysStrict": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noEmit": true, 21 | "incremental": true, 22 | "esModuleInterop": true, 23 | "module": "es2022", 24 | "moduleResolution": "node", 25 | "resolveJsonModule": true, 26 | "isolatedModules": true, 27 | }, 28 | "include": ["**/*.ts"], 29 | "exclude": ["playwright.config.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/webapp/.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_NO_PROOFS=$NO_PROOFS -------------------------------------------------------------------------------- /packages/webapp/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], 4 | parser: "@typescript-eslint/parser", 5 | parserOptions: { 6 | project: "./tsconfig.json", 7 | sourceType: "module", 8 | ecmaVersion: "latest", 9 | }, 10 | plugins: ["@typescript-eslint", "simple-import-sort"], 11 | root: true, 12 | ignorePatterns: ["node_modules", "src/generated.ts"], 13 | rules: { 14 | "@typescript-eslint/no-unsafe-argument": "off", 15 | "@typescript-eslint/restrict-template-expressions": "off", 16 | "react/no-unescaped-entities": "off", 17 | "@typescript-eslint/no-empty-function": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-restricted-imports": "off", 21 | "@typescript-eslint/no-restricted-imports": [ 22 | "error", 23 | { 24 | patterns: ["./*", "../*"], 25 | }, 26 | ], 27 | 28 | "no-unused-vars": "off", 29 | "@typescript-eslint/no-unused-vars": [ 30 | "warn", 31 | { 32 | // ignore unused args that start with underscore 33 | argsIgnorePattern: "^_", 34 | varsIgnorePattern: "^_", 35 | caughtErrorsIgnorePattern: "^_", 36 | }, 37 | ], 38 | "import/first": "error", 39 | "import/newline-after-import": "error", 40 | "import/no-duplicates": "error", 41 | "simple-import-sort/imports": [ 42 | "error", 43 | { 44 | groups: [ 45 | // Packages. `react` related packages come first. 46 | ["^react", "^next"], 47 | // External packages. 48 | ["^@?\\w"], 49 | // Custom group for src/ prefixed imports 50 | ["^src/"], 51 | // Parent imports. Put `..` last. 52 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 53 | // Other relative imports. Put same-folder imports and `.` last. 54 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], 55 | // Style imports. 56 | ["^.+\\.s?css$"], 57 | // Side effect imports. 58 | ["^\\u0000"], 59 | ], 60 | }, 61 | ], 62 | }, 63 | } 64 | 65 | module.exports = config 66 | -------------------------------------------------------------------------------- /packages/webapp/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "tabWidth": 4, 6 | "useTabs": false, 7 | "printWidth": 120, 8 | "plugins": ["prettier-plugin-tailwindcss"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/webapp/.vs/slnx.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/.vs/slnx.sqlite -------------------------------------------------------------------------------- /packages/webapp/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": false, 3 | "typescript.validate.enable": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/webapp/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | pnpm wagmi generate 3 | pnpm next build 4 | .PHONY: build 5 | 6 | # Serves webapp in production environment (after building) 7 | serve: 8 | pnpm next start 9 | .PHONY: serve 10 | 11 | # Serves webapp in dev environment (building not required) 12 | dev: 13 | pnpm next dev 14 | .PHONY: dev 15 | 16 | # Serves webapp in dev environment (building not required), do not generate proofs, sends bogus 17 | # proofs instead. 18 | dev-noproofs: 19 | NO_PROOFS=1 pnpm next dev 20 | .PHONY: dev-noproofs 21 | 22 | # Runs linter (use make check instead which also checks typescript typing). 23 | lint: 24 | pnpm next lint --max-warnings 0 25 | .PHONY: lint 26 | 27 | # Runs code quality checks. 28 | check: 29 | make lint 30 | pnpm prettier --check "src/**/*.{js,jsx,ts,tsx,json,css}" 31 | .PHONY: check 32 | 33 | # Runs prettier formatting across webapp files with specified file extensions. 34 | format: 35 | pnpm eslint . --fix 36 | pnpm prettier --write "**/*.{js,jsx,ts,tsx,json,css,cjs,mjs}" 37 | .PHONY: format -------------------------------------------------------------------------------- /packages/webapp/README.md: -------------------------------------------------------------------------------- 1 | # 0xFable Web App 2 | 3 | See the [Makefile](./Makefile) for the available command to build, check code standards, etc... 4 | 5 | The frontend is tested as part of the end-to-end tests [in the e2e package](../packages/e2e). 6 | 7 | ## Dependencies 8 | 9 | - `tailwindcss` — for styling, with peer dependencies `autoprefixer` and `postcss` 10 | - `daisyui` — component library for tailwindcss 11 | - `jotai-devtools` with peer dependency @emotion/react (styling for the UI devtool) 12 | 13 | ## React Debugging 14 | 15 | The `why-did-you-render` is installed via `next.config.mjs` and `scripts/whyDidYouRender.js`. 16 | I couldn't get its advertised features (detection of unnecessary re-renders) to work, but it 17 | enables tracking all renders of a component by writing something like this: 18 | 19 | ```js 20 | // @ts-ignore 21 | MyComponent.whyDidYouRender = { 22 | logOnDifferentValues: true, 23 | } 24 | ``` 25 | 26 | It can also be [customized][wdyr-custom] to learn about higher-level hooks. 27 | 28 | [wdyr-custom]: https://github.com/welldone-software/why-did-you-render 29 | 30 | For improving the dev/debug experience with Jotai, you can use: 31 | 32 | - [Jotai Devtools] — enables debug React hooks that display an UI to track atom values. 33 | - [Jotai SWC Extensions] — enable adding debug labels to atoms (show up in React devtools), and 34 | preserving atom values when using React refresh (hot reloading). 35 | 36 | [Jotai Devtools]: https://jotai.org/docs/tools/devtools 37 | [Jotai SWC Extensions]: https://jotai.org/docs/tools/swc -------------------------------------------------------------------------------- /packages/webapp/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import path from "path" 4 | import { fileURLToPath } from "url" 5 | import { dirname } from "path" 6 | import { createRequire } from "module" 7 | 8 | const require = createRequire(import.meta.url) 9 | 10 | /** @type {import("next").NextConfig} */ 11 | const nextConfig = { 12 | reactStrictMode: true, 13 | 14 | /** 15 | * If you have the "experimental: { appDir: true }" setting enabled, then you 16 | * must comment the below `i18n` config out. 17 | * 18 | * @see https://github.com/vercel/next.js/issues/41980 19 | */ 20 | i18n: { 21 | locales: ["en"], 22 | defaultLocale: "en", 23 | }, 24 | eslint: { 25 | // Warning: This allows production builds to successfully complete even if 26 | // your project has ESLint errors. 27 | ignoreDuringBuilds: true, 28 | }, 29 | experimental: { 30 | // Currently broken in Next 13.3.0: https://github.com/pmndrs/swc-jotai/issues/6 31 | // Unlike what is suggested, also broken when I downgrade to Next 13.2.3 and Next 13.1.6. 32 | swcPlugins: [ 33 | // ['@swc-jotai/react-refresh', {}], 34 | // ["@swc-jotai/debug-label", {}] 35 | ], 36 | }, 37 | webpack(config, { dev, isServer }) { 38 | // prevent node-gyp from failing because "can't resolve fs" 39 | config.resolve.fallback = { 40 | ...config.resolve.fallback, 41 | fs: false, 42 | net: false, 43 | tls: false, 44 | readline: false, 45 | } 46 | 47 | config.experiments = { 48 | ...config.experiments, 49 | topLevelAwait: true, // enable await at top-level in modules 50 | } 51 | 52 | // This would be great, but is sadly disallowed by Next, because they hate freedom. 53 | // https://nextjs.org/docs/messages/improper-devtool 54 | // Having this would enable parsing hook names in the React DevTools. 55 | // config.devtool = "cheap-module-source-map" 56 | return config 57 | }, 58 | // This hack makes it possible to use the Jotai devtools 59 | // Sources: 60 | // https://github.com/jotaijs/jotai-devtools/issues/47 61 | // https://github.com/martpie/next-transpile-modules/releases/tag/the-end 62 | transpilePackages: ["jotai-devtools"], 63 | } 64 | 65 | // // This hack makes it possible to use the Jotai devtools 66 | // // Source: https://github.com/jotaijs/jotai-devtools/issues/47 67 | // const withTranspileModules = require("next-transpile-modules")([ 68 | // "jotai-devtools", 69 | // ]) 70 | // export default withTranspileModules(nextConfig) 71 | export default nextConfig 72 | -------------------------------------------------------------------------------- /packages/webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xfable/webapp", 3 | "version": "0.1.0", 4 | "private": true, 5 | "browser": { 6 | "fs": false, 7 | "path": false, 8 | "os": false 9 | }, 10 | "dependencies": { 11 | "@dnd-kit/core": "^6.0.8", 12 | "@dnd-kit/sortable": "^7.0.2", 13 | "@dnd-kit/utilities": "^3.2.1", 14 | "@emotion/react": "^11.11.1", 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-navigation-menu": "^1.1.4", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "circomlibjs": "^0.1.7", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.1.0", 21 | "connectkit": "^1.5.3", 22 | "jotai": "^2.4.3", 23 | "jotai-devtools": "^0.7.0", 24 | "lodash": "^4.17.21", 25 | "lucide-react": "^0.309.0", 26 | "next": "^13.5.6", 27 | "next-themes": "^0.2.1", 28 | "next-transpile-modules": "^10.0.1", 29 | "prettier": "^3.2.5", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-icons": "^4.11.0", 33 | "snarkjs": "^0.7.1", 34 | "sonner": "^1.4.0", 35 | "tailwind-merge": "^2.2.0", 36 | "tailwindcss-animate": "^1.0.7", 37 | "viem": "^1.16.6", 38 | "wagmi": "^1.4.5" 39 | }, 40 | "devDependencies": { 41 | "@swc-jotai/debug-label": "^0.1.0", 42 | "@swc-jotai/react-refresh": "^0.1.0", 43 | "@types/eslint": "^8.44.6", 44 | "@types/lodash": "^4.14.200", 45 | "@types/node": "^20.8.7", 46 | "@types/react": "^18.2.31", 47 | "@types/react-dom": "^18.2.14", 48 | "@typescript-eslint/eslint-plugin": "^6.8.0", 49 | "@typescript-eslint/parser": "^6.8.0", 50 | "@wagmi/cli": "^1.5.2", 51 | "@welldone-software/why-did-you-render": "^7.0.1", 52 | "autoprefixer": "^10.4.16", 53 | "eslint": "^8.52.0", 54 | "eslint-config-next": "^13.5.6", 55 | "eslint-config-prettier": "^9.1.0", 56 | "eslint-plugin-import": "^2.29.1", 57 | "eslint-plugin-simple-import-sort": "^12.0.0", 58 | "postcss": "^8.4.31", 59 | "prettier-plugin-tailwindcss": "^0.5.11", 60 | "tailwindcss": "^3.3.3", 61 | "typescript": "^5.2.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/webapp/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | 8 | module.exports = config 9 | -------------------------------------------------------------------------------- /packages/webapp/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/webapp/public/card_art/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/0.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/1.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/10.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/2.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/3.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/4.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/5.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/6.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/7.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/8.jpg -------------------------------------------------------------------------------- /packages/webapp/public/card_art/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/card_art/9.jpg -------------------------------------------------------------------------------- /packages/webapp/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/favicon.png -------------------------------------------------------------------------------- /packages/webapp/public/font/BluuNext-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFableOrg/0xFable/f4aece4c513161e78d3dd78579687133cfd4f22a/packages/webapp/public/font/BluuNext-Bold.otf -------------------------------------------------------------------------------- /packages/webapp/public/img/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | 11 | 12 | 14 | 16 | 17 | 18 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/webapp/src/actions/attack.ts: -------------------------------------------------------------------------------- 1 | import { defaultErrorHandling } from "src/actions/errors" 2 | import { contractWriteThrowing } from "src/actions/libContractWrite" 3 | import { Address } from "src/chain" 4 | import { deployment } from "src/deployment" 5 | import { gameABI } from "src/generated" 6 | import { checkFresh, freshWrap } from "src/store/checkFresh" 7 | import { getOpponentIndex } from "src/store/read" 8 | 9 | // ================================================================================================= 10 | 11 | export type AttackArgs = { 12 | gameID: bigint 13 | playerAddress: Address 14 | setLoading: (label: string | null) => void 15 | /** 16 | * A list of attacking creatures indexes. 17 | */ 18 | selectedCreaturesIndexes: number[] 19 | } 20 | 21 | // ------------------------------------------------------------------------------------------------- 22 | 23 | /** 24 | * Declares an attack with the creatures selected by the player, by sending the `attack` 25 | * transaction. 26 | * 27 | * Returns `true` iff the player successfully declared the attack. 28 | */ 29 | export async function attack(args: AttackArgs): Promise { 30 | try { 31 | return await attackImpl(args) 32 | } catch (err) { 33 | args.setLoading(null) 34 | return defaultErrorHandling("attack", err) 35 | } 36 | } 37 | 38 | // ------------------------------------------------------------------------------------------------- 39 | 40 | async function attackImpl(args: AttackArgs): Promise { 41 | checkFresh( 42 | await freshWrap( 43 | contractWriteThrowing({ 44 | contract: deployment.Game, 45 | abi: gameABI, 46 | functionName: "attack", 47 | args: [args.gameID, getOpponentIndex()!, args.selectedCreaturesIndexes], 48 | setLoading: args.setLoading, 49 | }) 50 | ) 51 | ) 52 | 53 | return true 54 | } 55 | 56 | // ================================================================================================= 57 | -------------------------------------------------------------------------------- /packages/webapp/src/actions/concede.ts: -------------------------------------------------------------------------------- 1 | import { defaultErrorHandling } from "src/actions/errors" 2 | import { contractWriteThrowing } from "src/actions/libContractWrite" 3 | import { Address } from "src/chain" 4 | import { deployment } from "src/deployment" 5 | import { gameABI } from "src/generated" 6 | import { checkFresh, freshWrap } from "src/store/checkFresh" 7 | 8 | // ================================================================================================= 9 | 10 | export type ConcedeArgs = { 11 | gameID: bigint 12 | playerAddress: Address 13 | setLoading: (label: string | null) => void 14 | onSuccess: () => void 15 | } 16 | 17 | // ------------------------------------------------------------------------------------------------- 18 | 19 | /** 20 | * Concedes the game. 21 | * 22 | * Returns `true` iff the player successfully conceded the defenders. 23 | */ 24 | export async function concede(args: ConcedeArgs): Promise { 25 | try { 26 | return await concedeImpl(args) 27 | } catch (err) { 28 | args.setLoading(null) 29 | return defaultErrorHandling("concede", err) 30 | } 31 | } 32 | 33 | // ------------------------------------------------------------------------------------------------- 34 | 35 | async function concedeImpl(args: ConcedeArgs): Promise { 36 | checkFresh( 37 | await freshWrap( 38 | contractWriteThrowing({ 39 | contract: deployment.Game, 40 | abi: gameABI, 41 | functionName: "concedeGame", 42 | args: [args.gameID], 43 | setLoading: args.setLoading, 44 | }) 45 | ) 46 | ) 47 | 48 | args.onSuccess() 49 | return true 50 | } 51 | 52 | // ================================================================================================= 53 | -------------------------------------------------------------------------------- /packages/webapp/src/actions/defend.ts: -------------------------------------------------------------------------------- 1 | import { defaultErrorHandling } from "src/actions/errors" 2 | import { contractWriteThrowing } from "src/actions/libContractWrite" 3 | import { Address } from "src/chain" 4 | import { deployment } from "src/deployment" 5 | import { gameABI } from "src/generated" 6 | import { checkFresh, freshWrap } from "src/store/checkFresh" 7 | 8 | // ================================================================================================= 9 | 10 | export type DefendArgs = { 11 | gameID: bigint 12 | playerAddress: Address 13 | setLoading: (label: string | null) => void 14 | /** 15 | * A list of defending creatures indexes. This array must be the same length as the list of 16 | * attacking creatures, and maybe contain 0 to signal that an attacking creature should not be 17 | * blocked. 18 | */ 19 | defendingCreaturesIndexes: number[] 20 | } 21 | 22 | // ------------------------------------------------------------------------------------------------- 23 | 24 | /** 25 | * Declares defenders and resolve combat, by sending the `defend` transaction. 26 | * 27 | * Returns `true` iff the player successfully declared the defenders. 28 | */ 29 | export async function defend(args: DefendArgs): Promise { 30 | try { 31 | return await defendImpl(args) 32 | } catch (err) { 33 | args.setLoading(null) 34 | return defaultErrorHandling("defend", err) 35 | } 36 | } 37 | 38 | // ------------------------------------------------------------------------------------------------- 39 | 40 | async function defendImpl(args: DefendArgs): Promise { 41 | checkFresh( 42 | await freshWrap( 43 | contractWriteThrowing({ 44 | contract: deployment.Game, 45 | abi: gameABI, 46 | functionName: "defend", 47 | args: [args.gameID, args.defendingCreaturesIndexes], 48 | setLoading: args.setLoading, 49 | }) 50 | ) 51 | ) 52 | 53 | return true 54 | } 55 | 56 | // ================================================================================================= 57 | -------------------------------------------------------------------------------- /packages/webapp/src/actions/endTurn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An user action responsible for making a player play a card he previously drew, by sending the 3 | * `playCard` transaction. 4 | * 5 | * @module action/playCard 6 | */ 7 | 8 | import { defaultErrorHandling } from "src/actions/errors" 9 | import { contractWriteThrowing } from "src/actions/libContractWrite" 10 | import { Address } from "src/chain" 11 | import { deployment } from "src/deployment" 12 | import { gameABI } from "src/generated" 13 | import { checkFresh, freshWrap } from "src/store/checkFresh" 14 | import { getCurrentPlayerAddress, getGameData, getGameID, getPlayerAddress } from "src/store/read" 15 | import { GameStep } from "src/store/types" 16 | 17 | // ================================================================================================= 18 | 19 | export type EndTurnArgs = { 20 | gameID: bigint 21 | playerAddress: Address 22 | setLoading: (label: string | null) => void 23 | } 24 | 25 | // ================================================================================================= 26 | 27 | /** 28 | * Ends the player's current turn, by sending the `endTurn` transaction. 29 | * Returns `true` iff the transaction was successfully sent. 30 | */ 31 | export async function endTurn(args: EndTurnArgs): Promise { 32 | try { 33 | return await skipTurnImpl(args) 34 | } catch (err) { 35 | return defaultErrorHandling("skipTurn", err) 36 | } 37 | } 38 | 39 | // ================================================================================================= 40 | 41 | export async function skipTurnImpl(args: EndTurnArgs): Promise { 42 | const gameID = getGameID() 43 | const playerAddress = getPlayerAddress() 44 | const gameData = getGameData() 45 | 46 | if (gameID !== args.gameID || playerAddress !== args.playerAddress || gameData === null) return false // old/stale call 47 | 48 | if (getCurrentPlayerAddress(gameData) !== playerAddress) return false // old/stale call 49 | 50 | if (![GameStep.PLAY, GameStep.ATTACK].includes(gameData.currentStep)) return false // old/stale call 51 | 52 | checkFresh( 53 | await freshWrap( 54 | contractWriteThrowing({ 55 | contract: deployment.Game, 56 | abi: gameABI, 57 | functionName: "endTurn", 58 | args: [gameID], 59 | setLoading: args.setLoading, 60 | }) 61 | ) 62 | ) 63 | 64 | return true 65 | } 66 | 67 | // ================================================================================================= 68 | -------------------------------------------------------------------------------- /packages/webapp/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The `actions` module contains the implementation of user-initiated actions. These typically span 3 | * over the store, the UI, and the network (currently only the blockchain via wagmi). 4 | * 5 | * This package re-exports the actions from the other packages as well as utilities that can be 6 | * called from the UI. In general the UI should only import from this package and not from the 7 | * sub-packages. 8 | * 9 | * @module actions 10 | */ 11 | 12 | export { joinGame } from "actions/joinGame" 13 | export { reportInconsistentGameState } from "actions/errors" 14 | -------------------------------------------------------------------------------- /packages/webapp/src/components/cards/boardCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useState } from "react" 2 | import Image from "next/image" 3 | 4 | import { testCards } from "src/utils/card-list" 5 | 6 | interface BoardCardProps { 7 | id: number 8 | } 9 | 10 | const BoardCard = forwardRef(({ id }, ref) => { 11 | const [showCardName, setShowCardName] = useState(false) 12 | 13 | return ( 14 |
setShowCardName(true)} 18 | onMouseLeave={() => setShowCardName(false)} 19 | > 20 | {`${id}`} 30 | {showCardName && ( 31 | <> 32 |
33 |
34 | {`${testCards[id]?.attack}`} 35 |
36 |
37 | {`${testCards[id]?.defense}`} 38 |
39 |
40 | 41 | 46 | {`${testCards[id]?.name}`} 47 | 48 | 49 | )} 50 |
51 | ) 52 | }) 53 | 54 | BoardCard.displayName = "BoardCard" 55 | 56 | export default BoardCard 57 | -------------------------------------------------------------------------------- /packages/webapp/src/components/cards/cardContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { useSortable } from "@dnd-kit/sortable" 4 | import { CSS } from "@dnd-kit/utilities" 5 | 6 | import BoardCard from "src/components/cards/boardCard" 7 | import DraggedCard from "src/components/cards/draggedCard" 8 | import HandCard from "src/components/cards/handCard" 9 | import { CardPlacement } from "src/store/types" 10 | import { convertStringToSafeNumber } from "src/utils/js-utils" 11 | 12 | interface BaseCardProps { 13 | id: string 14 | className?: string 15 | handHovered?: boolean 16 | placement: CardPlacement 17 | cardGlow?: boolean 18 | } 19 | 20 | const CardContainer: React.FC = ({ id, handHovered, placement, cardGlow }) => { 21 | const { attributes, listeners, setNodeRef, isDragging, transform, transition } = useSortable({ 22 | id: placement === CardPlacement.BOARD ? `B-${id}` : `H-${id}`, 23 | }) 24 | 25 | const sortableStyle = { 26 | transform: CSS.Transform.toString(transform), 27 | transition, 28 | } 29 | 30 | const idAsNum = convertStringToSafeNumber(id) // to refer to cards in JSON file 31 | 32 | const renderCardContent = () => { 33 | switch (placement) { 34 | case CardPlacement.HAND: 35 | return ( 36 | 43 | ) 44 | case CardPlacement.BOARD: 45 | return 46 | case CardPlacement.DRAGGED: 47 | return 48 | default: 49 | return null 50 | } 51 | } 52 | return ( 53 |
60 | {renderCardContent()} 61 |
62 | ) 63 | } 64 | 65 | export default CardContainer 66 | -------------------------------------------------------------------------------- /packages/webapp/src/components/cards/draggedCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react" 2 | import Image from "next/image" 3 | 4 | import { testCards } from "src/utils/card-list" 5 | 6 | interface DraggedCardProps { 7 | id: number 8 | } 9 | 10 | const DraggedCard = forwardRef(({ id }, ref) => { 11 | return ( 12 | <> 13 | {`${id}`} 24 | 25 | ) 26 | }) 27 | 28 | DraggedCard.displayName = "DraggedCard" 29 | 30 | export default DraggedCard 31 | -------------------------------------------------------------------------------- /packages/webapp/src/components/cards/handCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useState } from "react" 2 | import Image from "next/image" 3 | 4 | import { testCards } from "src/utils/card-list" 5 | 6 | interface HandCardProps { 7 | id: number 8 | handHovered?: boolean 9 | isDragging: boolean 10 | cardGlow?: boolean 11 | } 12 | 13 | const HandCard = forwardRef(({ id, isDragging, handHovered, cardGlow }, ref) => { 14 | const [cardHover, setCardHover] = useState(false) 15 | const [isDetailsVisible, setIsDetailsVisible] = useState(false) 16 | const showingDetails = isDetailsVisible && !isDragging 17 | 18 | return ( 19 |
{ 28 | setIsDetailsVisible(!isDetailsVisible) 29 | }} 30 | onMouseEnter={() => setCardHover(true)} 31 | onMouseLeave={() => { 32 | setCardHover(false) 33 | setIsDetailsVisible(false) 34 | }} 35 | ref={ref} 36 | > 37 | 46 | {testCards[id]?.name} 47 | 48 | {`${id}`} 58 | {showingDetails && ( 59 | <> 60 |

61 | {testCards[id]?.description} 62 |

63 |
64 |
65 |

⚔️ {testCards[id]?.attack}

66 |

🛡 {testCards[id]?.defense}

67 |
68 | 69 | )} 70 |
71 | ) 72 | }) 73 | 74 | HandCard.displayName = "HandCard" 75 | 76 | export default HandCard 77 | -------------------------------------------------------------------------------- /packages/webapp/src/components/collection/cardCollectionDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Image from "next/image" 3 | 4 | import { MintDeckModal } from "src/components/modals/mintDeckModal" 5 | import { Card } from "src/store/types" 6 | import { testCards } from "src/utils/card-list" 7 | 8 | interface CardCollectionDisplayProps { 9 | cards: Card[] 10 | isHydrated: boolean 11 | setSelectedCard: (card: Card | null) => void 12 | onCardToggle: (card: Card) => void 13 | selectedCards: Card[] 14 | isEditing: boolean 15 | } 16 | 17 | const CardCollectionDisplay: React.FC = ({ 18 | cards, 19 | isHydrated, 20 | setSelectedCard, 21 | selectedCards, 22 | onCardToggle, 23 | isEditing, 24 | }) => { 25 | return ( 26 | <> 27 |
28 | {isHydrated && cards.length === 0 && ( 29 |
30 | 31 |
32 | )} 33 | 34 | {isHydrated && cards.length > 0 && ( 35 |
36 | {cards.map((card, index) => ( 37 |
c.id === card.id) 41 | ? "shadow-highlight shadow-orange-300" 42 | : "" 43 | } w-[220px] max-w-[330px] grow rounded-lg border-4 border-slate-900 p-4 hover:bg-slate-800`} 44 | onMouseEnter={() => setSelectedCard(card)} 45 | onClick={() => { 46 | if (isEditing) { 47 | onCardToggle(card) 48 | } 49 | }} 50 | > 51 | Number(tc.id) === index + 1)?.image || ""} 54 | alt={card.lore.name} 55 | width={256} 56 | height={256} 57 | /> 58 |
{card.lore.name}
59 |
60 |
61 | {card.stats.attack} 62 |
63 |
64 | {card.stats.defense} 65 |
66 |
67 |
68 | ))} 69 |
70 | )} 71 |
72 | 73 | ) 74 | } 75 | 76 | export default CardCollectionDisplay 77 | -------------------------------------------------------------------------------- /packages/webapp/src/components/collection/deckList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import Link from "src/components/link" 4 | import { Button } from "src/components/ui/button" 5 | import { Deck } from "src/store/types" 6 | 7 | interface DeckCollectionDisplayProps { 8 | decks: Deck[] 9 | onDeckSelect: (deckID: number) => void 10 | } 11 | 12 | const DeckCollectionDisplay: React.FC = ({ decks, onDeckSelect }) => { 13 | return ( 14 |
15 | {/* New Deck Button */} 16 |
17 | 23 |
24 | 25 | {/* Deck Buttons */} 26 | {decks.map((deck, deckID) => ( 27 | 36 | ))} 37 |
38 | ) 39 | } 40 | 41 | export default DeckCollectionDisplay 42 | -------------------------------------------------------------------------------- /packages/webapp/src/components/collection/filterPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Image from "next/image" 3 | 4 | import { Card } from "src/store/types" 5 | 6 | interface FilterPanelProps { 7 | effects: string[] 8 | types: string[] 9 | effectMap: { [key: string]: boolean } 10 | typeMap: { [key: string]: boolean } 11 | handleEffectClick: (index: number) => void 12 | handleTypeClick: (index: number) => void 13 | handleInputChange: (event: React.ChangeEvent) => void 14 | selectedCard: Card | null 15 | } 16 | 17 | const FilterPanel: React.FC = ({ 18 | effects, 19 | types, 20 | effectMap, 21 | typeMap, 22 | handleEffectClick, 23 | handleTypeClick, 24 | handleInputChange, 25 | selectedCard, 26 | }) => { 27 | const cardName = selectedCard?.lore.name || "Select a card" 28 | const cardFlavor = selectedCard?.lore.flavor || "Select a card to see its details" 29 | 30 | return ( 31 |
32 |
33 | {/* Search */} 34 |

Search

35 |
36 | 42 |
43 | 44 | {/* Effects */} 45 |

Effects

46 |
47 | {effects.map((effect, index) => ( 48 | 57 | ))} 58 |
59 | 60 | {/* Types */} 61 |

Types

62 |
63 | {types.map((type, index) => ( 64 | 73 | ))} 74 |
75 | 76 | {/* todo @eviterin: makes sense to add a filter for the card collection display to only show one of each card. */} 77 | 78 | {/* Selected Card Display */} 79 |
80 |

Card details

81 |
82 | {cardName} 83 |
{cardName}
84 |
85 |
{cardFlavor}
86 |
87 |
88 |
89 | ) 90 | } 91 | 92 | export default FilterPanel 93 | -------------------------------------------------------------------------------- /packages/webapp/src/components/hand.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import { AiOutlineLeft, AiOutlineRight } from "react-icons/ai" 3 | 4 | import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable" 5 | 6 | import CardContainer from "src/components/cards/cardContainer" 7 | import { CancellationHandler } from "src/components/modals/loadingModal" 8 | import useScrollBox from "src/hooks/useScrollBox" 9 | import { CardPlacement } from "src/store/types" 10 | import { convertBigIntArrayToStringArray } from "src/utils/js-utils" 11 | 12 | const Hand = ({ 13 | cards, 14 | className, 15 | }: { 16 | cards: readonly bigint[] | null 17 | className?: string 18 | setLoading: (label: string | null) => void 19 | cancellationHandler: CancellationHandler 20 | }) => { 21 | const [isFocused, setIsFocused] = useState(false) 22 | const scrollWrapperRef = useRef(null) 23 | const { showLeftArrow, scrollLeft, showRightArrow, scrollRight, isLastCardGlowing } = useScrollBox( 24 | scrollWrapperRef, 25 | cards 26 | ) 27 | 28 | const { setNodeRef } = useSortable({ 29 | id: CardPlacement.HAND, 30 | }) 31 | 32 | const convertedCards = convertBigIntArrayToStringArray(cards) 33 | const range = convertedCards?.map((_, index) => index + 1) ?? [] 34 | 35 | useEffect(() => { 36 | const handleResize = () => { 37 | setIsFocused(true) 38 | } 39 | window.addEventListener("resize", handleResize) 40 | return () => { 41 | window.removeEventListener("resize", handleResize) 42 | } 43 | }, []) 44 | 45 | return ( 46 |
{ 52 | setIsFocused(true) 53 | }} 54 | onMouseLeave={() => { 55 | setIsFocused(false) 56 | }} 57 | > 58 | {showLeftArrow && isFocused && ( 59 |
63 | 64 |
65 | )} 66 |
67 |
68 |
69 |
70 | 71 | {range.map((index) => ( 72 |
73 | 78 |
79 | ))} 80 |
81 |
82 |
83 |
84 |
85 | {showRightArrow && isFocused && ( 86 |
90 | 91 |
92 | )} 93 |
94 | ) 95 | } 96 | 97 | export default Hand 98 | -------------------------------------------------------------------------------- /packages/webapp/src/components/lib/README.md: -------------------------------------------------------------------------------- 1 | # components/lib 2 | 3 | This directory contain reusable components that are not tied to a specific location in the 4 | UX flow but can be reused easily. -------------------------------------------------------------------------------- /packages/webapp/src/components/lib/jotaiDebug.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomsDebugValue, useAtomsDevtools } from "jotai-devtools" 2 | 3 | const JotaiDebug = () => { 4 | // An atom that contains a list of all the names and values of all the atoms in the app. 5 | // This enables inspecting them the in the React devtool extension. 6 | // (By default in Next, the atoms are listed but they don't have their proper names.) 7 | // Note that the naming here relies on atoms having their `debugLabel` properties set. 8 | useAtomsDebugValue() 9 | // Enables tracking atom value changes in the Redux dev tool, as well as time travelling, etc 10 | // The Redux dev tool needs to be open and a state change to happen for it to display anything. 11 | useAtomsDevtools("atomDevtools") 12 | return null 13 | } 14 | 15 | export default function jotaiDebug() { 16 | // The first clause guards against server-side rendering. 17 | if (typeof window !== "undefined" && process.env.NODE_ENV === "development") return 18 | else return null 19 | } 20 | -------------------------------------------------------------------------------- /packages/webapp/src/components/lib/modalElements.tsx: -------------------------------------------------------------------------------- 1 | // ================================================================================================= 2 | 3 | import Image from "next/image" 4 | 5 | import { Button } from "src/components/ui/button" 6 | 7 | export const Spinner = () => { 8 | return ( 9 |
10 | loading 11 |
12 | ) 13 | } 14 | 15 | // ------------------------------------------------------------------------------------------------- 16 | 17 | export const ModalMenuButton = ({ display, label }: { display: () => void; label: string }) => { 18 | return ( 19 | 26 | ) 27 | } 28 | 29 | // ================================================================================================= 30 | -------------------------------------------------------------------------------- /packages/webapp/src/components/link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Link from "next/link" 3 | import { useRouter } from "next/router" 4 | 5 | interface QueryParamLinkProps { 6 | children: React.ReactNode 7 | href: string 8 | } 9 | 10 | /** 11 | * A Link component wrapper that appends a 'index' query parameter to the URL in development mode. 12 | * This is used to persist state across navigation during testing. 13 | */ 14 | const QueryParamLink: React.FC = ({ children, href }) => { 15 | const router = useRouter() 16 | 17 | let url = href 18 | 19 | if (process.env.NODE_ENV === "development") { 20 | const index = parseInt(router.query.index as string) 21 | if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) 22 | url += (url.includes("?") ? "&" : "?") + `index=${index}` 23 | } 24 | return {children} 25 | } 26 | 27 | export default QueryParamLink 28 | -------------------------------------------------------------------------------- /packages/webapp/src/components/modals/gameEndedModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react" 2 | import { useRouter } from "next/router" 3 | 4 | import { Button } from "src/components/ui/button" 5 | import { Dialog, DialogContent, DialogDescription, DialogTitle } from "src/components/ui/dialog" 6 | import { useGameData, useGameID } from "src/store/hooks" 7 | import { navigate } from "src/utils/navigate" 8 | 9 | /** 10 | * This modal is displayed in the "/play" page, when the game ends. It can be dismissed if the 11 | * player wishes to view the final state of the game board, after which he can still go back 12 | * to the menu through a button on the game board. 13 | */ 14 | export const GameEndedModal = ({ closeCallback }: { closeCallback: () => void }) => { 15 | const router = useRouter() 16 | const [, setGameID] = useGameID() 17 | const gameData = useGameData() 18 | const [open, isOpen] = useState(true) 19 | 20 | const exitToMenu = useCallback(() => { 21 | setGameID(null) 22 | void navigate(router, "/") 23 | }, [router, setGameID]) 24 | 25 | const viewGame = useCallback(() => { 26 | isOpen(false) 27 | closeCallback() 28 | }, [closeCallback]) 29 | 30 | return ( 31 | 32 | 33 | Game Ended 34 | 35 |

Winner: {gameData?.players[gameData.livePlayers[0]]}

36 |
37 | 40 | 43 |
44 |
45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /packages/webapp/src/components/modals/globalErrorModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | import { Button } from "src/components/ui/button" 4 | import { Dialog, DialogContent, DialogTitle } from "src/components/ui/dialog" 5 | import { ErrorConfig } from "src/store/types" 6 | 7 | /** 8 | * A modal displayed globally (setup in _app.tsx) whenever the errorConfig state is set to non-null. 9 | * This modal can be dismissed by setting the errorConfig state to null. 10 | */ 11 | export const GlobalErrorModal = ({ config }: { config: ErrorConfig }) => { 12 | // Maybe in the future we might want to store the error somewhere and make it surfaceable in the 13 | // UI. This is good practice as it lets the user figure out what happened. Really not a priority 14 | // at the moment, and the error should be systematically logged to the console instead, for 15 | // debugging purposes. 16 | const [open, setOpen] = useState(false) 17 | useEffect(() => { 18 | if (config !== null && !open) setOpen(true) 19 | else setOpen(false) 20 | }, [config, open]) 21 | 22 | return ( 23 | 24 | {config.title} 25 | 26 | {config.message !== "" &&

{config.message}

} 27 |
28 | {config.buttons.map((button, i) => ( 29 | 32 | ))} 33 |
34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /packages/webapp/src/components/modals/inGameMenuModalContent.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Button } from "src/components/ui/button" 4 | import { DialogDescription, DialogTitle } from "src/components/ui/dialog" 5 | 6 | /** 7 | * This modal content is shared by both the {@link CreateGameModal} (for the game creator) and the 8 | * {@link JoinGameModal} (for the joiner), and is displayed when the game is in progress but the 9 | * player navigates back to the menu. 10 | * 11 | * @param {{concede}} concede - The function to call to concede the game. 12 | */ 13 | export const InGameMenuModalContent = ({ concede }: { concede?: () => void }) => { 14 | return ( 15 | <> 16 | Game in progress! 17 | 18 |
19 | 20 | 23 | 24 | 27 |
28 |
29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /packages/webapp/src/components/modals/mintDeckModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | 3 | import { LoadingModalContent } from "src/components/modals/loadingModal" 4 | import { Button } from "src/components/ui/button" 5 | import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "src/components/ui/dialog" 6 | import { useDeckAirdropWrite } from "src/hooks/useFableWrite" 7 | 8 | interface MintDeckModalContentProps { 9 | loading: string | null 10 | setLoading: React.Dispatch> 11 | callback: () => void 12 | } 13 | 14 | // ================================================================================================= 15 | 16 | export const MintDeckModal = ({ callback = () => {} }) => { 17 | const [loading, setLoading] = useState(null) 18 | 19 | return ( 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | // ================================================================================================= 37 | 38 | const MintDeckModalContent: React.FC = ({ loading, setLoading, callback }) => { 39 | const [success, setSuccess] = useState(false) 40 | 41 | const { write: claim } = useDeckAirdropWrite({ 42 | functionName: "claimAirdrop", 43 | enabled: true, 44 | setLoading, 45 | onSuccess() { 46 | callback?.() 47 | setSuccess(true) 48 | }, 49 | }) 50 | 51 | // ----------------------------------------------------------------------------------------------- 52 | 53 | if (loading) return 54 | 55 | return ( 56 | <> 57 | {!success && ( 58 | <> 59 | Minting Deck... 60 | 61 |

Mint a deck of cards to play the game with your friends.

62 |
63 | 66 |
67 |
68 | 69 | )} 70 | {success && ( 71 | <> 72 | Deck Minted Successfully 73 | 74 |

Go enjoy the game!

75 |
76 | 77 | )} 78 | 79 | ) 80 | } 81 | 82 | // ================================================================================================= 83 | -------------------------------------------------------------------------------- /packages/webapp/src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { ConnectKitButton } from "connectkit" 4 | 5 | import { Button } from "src/components/ui/button" 6 | import { NavigationMenu, NavigationMenuItem, NavigationMenuList } from "src/components/ui/navigation-menu" 7 | 8 | export const Navbar = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/webapp/src/components/playerBoard.tsx: -------------------------------------------------------------------------------- 1 | import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable" 2 | 3 | import CardContainer from "src/components/cards/cardContainer" 4 | import * as store from "src/store/hooks" 5 | import { CardPlacement } from "src/store/types" 6 | import { convertBigIntArrayToStringArray, shortenAddress } from "src/utils/js-utils" 7 | 8 | interface PlayerBoardProps { 9 | playerAddress: `0x${string}` | undefined | null 10 | playedCards: readonly bigint[] | null 11 | } 12 | 13 | const PlayerBoard: React.FC = ({ playerAddress, playedCards }) => { 14 | const { setNodeRef, isOver } = useSortable({ 15 | id: CardPlacement.BOARD, 16 | }) 17 | 18 | const currentPlayerAddress = store.usePlayerAddress() 19 | const playerActive = isOver && playerAddress === currentPlayerAddress 20 | const convertedCards = convertBigIntArrayToStringArray(playedCards) 21 | return ( 22 |
34 |
35 |
36 |

37 | {`🛡 ${shortenAddress(playerAddress)}`} 38 |

39 |

♥️ 100

40 |
41 | 42 |
47 | 48 | {convertedCards?.map((card) => ( 49 | 50 | ))} 51 | 52 |
53 |
54 |
55 | ) 56 | } 57 | 58 | export default PlayerBoard 59 | -------------------------------------------------------------------------------- /packages/webapp/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { cva, type VariantProps } from "class-variance-authority" 5 | 6 | import { cn } from "src/utils/ui-utils" 7 | 8 | // ref: https://ui.shadcn.com/docs/components/button 9 | const buttonVariants = cva( 10 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 15 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 16 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-10 px-4 py-2", 23 | sm: "h-9 rounded-md px-3", 24 | lg: "h-11 rounded-md px-8", 25 | icon: "h-10 w-10", 26 | }, 27 | width: { 28 | full: "w-full", 29 | auto: "w-auto", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | export interface ButtonProps 40 | extends React.ButtonHTMLAttributes, 41 | VariantProps { 42 | asChild?: boolean 43 | } 44 | 45 | const Button = React.forwardRef( 46 | ({ className, variant, size, width, asChild = false, ...props }, ref) => { 47 | const Comp = asChild ? Slot : "button" 48 | return 49 | } 50 | ) 51 | Button.displayName = "Button" 52 | 53 | export { Button, buttonVariants } 54 | -------------------------------------------------------------------------------- /packages/webapp/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "src/utils/ui-utils" 4 | 5 | export interface InputProps extends React.InputHTMLAttributes {} 6 | 7 | // ref: https://ui.shadcn.com/docs/components/input 8 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ) 20 | }) 21 | Input.displayName = "Input" 22 | 23 | export { Input } 24 | -------------------------------------------------------------------------------- /packages/webapp/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | 3 | import { Toaster as Sonner } from "sonner" 4 | 5 | type ToasterProps = React.ComponentProps 6 | 7 | // ref: https://ui.shadcn.com/docs/components/sonner 8 | // docs: https://sonner.emilkowal.ski/ 9 | const Toaster = ({ ...props }: ToasterProps) => { 10 | const { theme = "system" } = useTheme() 11 | 12 | return ( 13 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /packages/webapp/src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants that don't logically belong somewhere else. 3 | * 4 | * @module constants 5 | */ 6 | 7 | export const GIT_REPO = "https://github.com/0xFableOrg/0xFable" 8 | export const GIT_ISSUES = `${GIT_REPO}/issues` 9 | 10 | /** Proof generation timeout (in seconds) for the proof of the initial hand. */ 11 | export const DRAW_HAND_PROOF_TIMEOUT = 60 12 | 13 | /** Proof generation timeout (in seconds) for the proof of drawing a card. */ 14 | export const DRAW_CARD_PROOF_TIMEOUT = 30 15 | 16 | /** Proof generation timeout (in seconds) for the proof of playing a card. */ 17 | export const PLAY_CARD_PROOF_TIMEOUT = 30 18 | 19 | /** The default throttle period (minimum time between two on-chain fetches) in milliseconds. */ 20 | export const DEFAULT_THROTTLE_PERIOD = 2000 21 | 22 | /** 23 | * How often to refresh the state of the game (in milliseconds) — note the state will usually 24 | * refresh when we receive an event. 25 | * 26 | * Also note that the fetched are throttled to max one per {@link DEFAULT_THROTTLE_PERIOD} via 27 | * {@link module:throttledFetch}. 28 | */ 29 | export const GAME_DATA_REFRESH_INTERVAL = 5000 30 | -------------------------------------------------------------------------------- /packages/webapp/src/deployment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module imports the JSON containing the deployed addresses from the `contracts` package, and 3 | * exports it as a TypeScript type. 4 | * 5 | * @module deployment 6 | */ 7 | 8 | import * as deployment_ from "contracts/out/deployment.json" assert { type: "json" } 9 | import type { Address } from "wagmi" 10 | 11 | export interface Deployment { 12 | CardsCollection: Address 13 | Inventory: Address 14 | InventoryCardsCollection: Address 15 | Game: Address 16 | DeckAirdrop: Address 17 | Multicall3: Address 18 | PlayerHandle: Address 19 | MockENSResolver: Address 20 | } 21 | 22 | // NOTE: This silly `default` affair is required for running the e2e tests which cause 23 | // `deployment_` to have the type `{ default: Deployment }` instead of `Deployment`. 24 | // Maybe Next doesn't process things the same as the vanilla Node/TS config ?? 25 | 26 | export const deployment = 27 | (deployment_ as any).default === undefined 28 | ? (deployment_ as Deployment) 29 | : ((deployment_ as any).default as Deployment) 30 | -------------------------------------------------------------------------------- /packages/webapp/src/game/README.md: -------------------------------------------------------------------------------- 1 | # Game Directory 2 | 3 | This directory holds pure game logic, devoid of any store, UI or networking concern. 4 | 5 | It is not the only place that game logic is present, but whenever there is a solid chunk of logic 6 | that can be extracted and avoid being tangled with the rest of the code, we should extract it 7 | and place it in this directory. -------------------------------------------------------------------------------- /packages/webapp/src/game/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Game-logic related constants. 3 | * 4 | * @module game/constants 5 | */ 6 | 7 | // ================================================================================================= 8 | 9 | /** Size of the initial hand. */ 10 | export const INITIAL_HAND_SIZE = 7 11 | 12 | /** Maximum size of a deck. */ 13 | export const MAX_HAND_SIZE = 62 14 | 15 | /** Maximum size of a hand (same as the deck = no limit). */ 16 | export const MAX_DECK_SIZE = 62 17 | 18 | /** 19 | * Size of a field element in bytes, in the field used by our chosen ZK scheme. In our case, we use 20 | * Circom/snarkjs' implementation of Plonk with the BN128 (aka BN254, alt_bn_128) curve. 21 | * 22 | * This should always be lower or equal to 32, to fit in a uint256 EVM word. 23 | */ 24 | export const FELT_SIZE = 31 25 | 26 | /** 27 | * Number of field elements needed to represent a deck or a hand. 28 | * 29 | * This means that the maximum technical limit of deck and hand size is `NUM_FELTS_FOR_CARDS * 30 | * FELT_SIZE`. 31 | */ 32 | export const NUM_FELTS_FOR_CARDS = 2 33 | 34 | /** 35 | * The number of cards to provide for proofs over a deck or a hand, 36 | * equal to `NUM_FELTS_FOR_CARDS * FELT_SIZE`. 37 | */ 38 | export const NUM_CARDS_FOR_PROOF = NUM_FELTS_FOR_CARDS * FELT_SIZE 39 | 40 | // NOTE: If we were willing to restrict the number of cards in a hand more strictly, we could use 41 | // use only one field elements for hands. We would need separate set of constants to handle both 42 | // cases. 43 | 44 | /** 45 | * The prime that bounds the field used by our proof scheme of choice. 46 | * Currently, this is for Plonk. 47 | */ 48 | export const PROOF_CURVE_ORDER = 21888242871839275222246405745257275088548364400416034343698204186575808495617n 49 | 50 | // ================================================================================================= 51 | -------------------------------------------------------------------------------- /packages/webapp/src/game/drawInitialHand.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logic for drawing the initial player hand, cf. {@link drawInitialHand}. 3 | * 4 | * @module game/drawInitialHand 5 | */ 6 | 7 | import { Hash } from "src/chain" 8 | import { FELT_SIZE, INITIAL_HAND_SIZE, MAX_DECK_SIZE, MAX_HAND_SIZE } from "src/game/constants" 9 | import { PrivateInfo } from "src/store/types" 10 | import { mimcHash } from "src/utils/hashing" 11 | import { bigintToHexString, parseBigInt } from "src/utils/js-utils" 12 | 13 | // ================================================================================================= 14 | 15 | /** 16 | * Given a deck listing, its starting index within the array of all cards in the game, the player's 17 | * salt, and the public randomness, computes the initial hand for the player and updates the deck. 18 | * 19 | * Returns a structure containing the new hand, the updated deck, and the new deck and hand roots, 20 | * suitable for updating the player's private info. 21 | */ 22 | export function drawInitialHand( 23 | initialDeck: readonly bigint[], 24 | deckStartIndex: number, 25 | salt: bigint, 26 | publicRandomness: bigint 27 | ): Omit { 28 | const randomness = mimcHash([salt, publicRandomness]) 29 | 30 | // draw cards and update deck 31 | 32 | const deckIndexes = new Array(MAX_DECK_SIZE) 33 | const handIndexes = new Array(MAX_HAND_SIZE) 34 | 35 | for (let i = 0; i < initialDeck.length; i++) deckIndexes[i] = deckStartIndex + i 36 | for (let i = initialDeck.length; i < deckIndexes.length; i++) deckIndexes[i] = 255 37 | handIndexes.fill(255) 38 | 39 | for (let i = 0; i < INITIAL_HAND_SIZE; i++) { 40 | const deckLength = initialDeck.length - i 41 | const cardIndex = Number(randomness % BigInt(deckLength)) 42 | handIndexes[i] = deckIndexes[cardIndex] 43 | deckIndexes[cardIndex] = deckIndexes[deckLength - 1] 44 | deckIndexes[deckLength - 1] = 255 45 | } 46 | 47 | const deckRootInputs = [] 48 | const handRootInputs = [] 49 | 50 | // Pack the deck and hand indexes into FELT_SIZE-byte chunks. 51 | for (let i = 0; i * FELT_SIZE < MAX_DECK_SIZE; i++) 52 | deckRootInputs.push(parseBigInt(deckIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) 53 | for (let i = 0; i * FELT_SIZE < MAX_HAND_SIZE; i++) 54 | handRootInputs.push(parseBigInt(handIndexes.slice(i * FELT_SIZE, (i + 1) * FELT_SIZE), "little")) 55 | 56 | deckRootInputs.push(salt) 57 | handRootInputs.push(salt) 58 | 59 | const deckRoot: Hash = `0x${bigintToHexString(mimcHash(deckRootInputs), 32)}` 60 | const handRoot: Hash = `0x${bigintToHexString(mimcHash(handRootInputs), 32)}` 61 | 62 | return { handIndexes, deckIndexes, deckRoot, handRoot } 63 | } 64 | 65 | // ================================================================================================= 66 | -------------------------------------------------------------------------------- /packages/webapp/src/game/fableProofs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logic useful for zero-knowledge proof, specific to 0xFable. 3 | * 4 | * See {@link module:utils/zkproofs} for more generic ZK logic. 5 | * 6 | * @module game/fableProofs 7 | */ 8 | 9 | import { FELT_SIZE, NUM_FELTS_FOR_CARDS } from "src/game/constants" 10 | import { packBytes } from "src/utils/zkproofs/proofs" 11 | 12 | // ================================================================================================= 13 | 14 | /** 15 | * Calls {@link packBytes}, where each byte represent a index in the game data's `cards` array. 16 | * Fills in the parameters specific to our ZK scheme and to this encoding. 17 | */ 18 | export function packCards(cards: number[]): bigint[] { 19 | return packBytes(cards, NUM_FELTS_FOR_CARDS, FELT_SIZE) 20 | } 21 | 22 | // ================================================================================================= 23 | -------------------------------------------------------------------------------- /packages/webapp/src/game/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Misc game logic helpers. 3 | */ 4 | import { Address } from "src/chain" 5 | import { FetchedGameData, GameStep } from "src/store/types" 6 | 7 | // ================================================================================================= 8 | 9 | /** 10 | * Returns true iff it is legal to end a turn in the given game step. 11 | */ 12 | export function isEndingTurn(gameStep: GameStep): boolean { 13 | return gameStep === GameStep.PLAY || gameStep === GameStep.ATTACK || gameStep === GameStep.END_TURN 14 | } 15 | 16 | // ------------------------------------------------------------------------------------------------- 17 | 18 | /** 19 | * Return the current player's address. 20 | */ 21 | export function currentPlayer(gameData: FetchedGameData): Address { 22 | return gameData.players[gameData.currentPlayer] 23 | } 24 | 25 | // ================================================================================================= 26 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useCancellationHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cf. {@link useCancellationHandler} 3 | * 4 | * @module hooks/useCancellationHandler 5 | */ 6 | 7 | import { useEffect, useRef } from "react" 8 | 9 | import { CancellationHandler } from "src/components/modals/loadingModal" 10 | 11 | // ================================================================================================= 12 | 13 | /** 14 | * Return a {@link CancellationHandler} for a {@link LoadingModal}. This hook keeps a cancellation 15 | * handler at the ready at all times, and discards the current handler in favour of a new one 16 | * whenever the loading state transitions from non-null to null (as this signifies that the current 17 | * action has been completed and the current action cannot be cancelled anymore, making the existing 18 | * cancellation handlers obsolete). 19 | */ 20 | export function useCancellationHandler(loading: string | null): CancellationHandler { 21 | const previous = useRef(loading) 22 | const cancellationHandler = useRef(null) 23 | 24 | // If the loading state changes from non-null to null, then discard the old cancellation handler, 25 | // and create a new one. 26 | if (previous !== null && loading === null) { 27 | cancellationHandler.current = new CancellationHandler() 28 | } 29 | 30 | // This is only to initialize the very first cancellation handler, and avoid calling the 31 | // constructor every time the hook is invoked. 32 | if (cancellationHandler.current === null) { 33 | cancellationHandler.current = new CancellationHandler() 34 | } 35 | 36 | // Update previous value. 37 | useEffect(() => { 38 | previous.current = loading 39 | }, [loading]) 40 | 41 | return cancellationHandler.current 42 | } 43 | 44 | // ================================================================================================= 45 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useDebug.ts: -------------------------------------------------------------------------------- 1 | import { useDebugValue, useState } from "react" 2 | 3 | import { toString } from "src/utils/js-utils" 4 | 5 | // ------------------------------------------------------------------------------------------------- 6 | 7 | /** 8 | * This hook wraps React's {@link useDebugValue} in a custom hook that displays a dictionary, making 9 | * the dictionary available for display in the React Dev Tools. Unlike {@link useDebugValue}, this 10 | * can be used at the top level of a component. 11 | */ 12 | export function useDebugValues(dict: Record) { 13 | useDebugValue(dict) 14 | } 15 | 16 | // ------------------------------------------------------------------------------------------------- 17 | 18 | /** 19 | * Wraps a React's {@link useState} into a custom hook that adds a string (via {@link useDebugValue}) 20 | * showing the value directly within the React dev tool. An optional label can be provided to help 21 | * identify the value. 22 | * 23 | * Note that changing a {@link useState} to {@link useDebugState} is not friendly to fast refreshes. 24 | * To preserve state but still help with debugging, use {@link useDebugValues} instead. 25 | */ 26 | export function useDebugState(initial: T, label?: string) { 27 | const [value, setValue] = useState(initial) 28 | useDebugValue(label ? `${label}: ${toString(value)}` : value) 29 | return [value, setValue] 30 | } 31 | 32 | // ------------------------------------------------------------------------------------------------- 33 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useDragEvents.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react" 2 | 3 | import { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core" 4 | 5 | import { playCard } from "src/actions/playCard" 6 | import { CancellationHandler } from "src/components/modals/loadingModal" 7 | import * as store from "src/store/hooks" 8 | import { CardPlacement } from "src/store/types" 9 | import { extractCardID } from "src/utils/js-utils" 10 | 11 | function useDragEvents( 12 | setActiveId: (id: UniqueIdentifier | null) => void, 13 | setLoading: (loading: string | null) => void, 14 | cancellationHandler: CancellationHandler 15 | ) { 16 | const playerAddress = store.usePlayerAddress() 17 | const [gameID, _] = store.useGameID() 18 | const playerBattlefield = store.usePlayerBattlefield() 19 | 20 | const handleDragStart = useCallback( 21 | ({ active }: DragStartEvent) => { 22 | const matchedCardId = extractCardID(active.id as unknown as string) 23 | setActiveId(matchedCardId) 24 | }, 25 | [setActiveId] 26 | ) 27 | 28 | const handleDragEnd = useCallback( 29 | (event: DragEndEvent) => { 30 | const { over, active } = event 31 | if (over && over.id === CardPlacement.BOARD) { 32 | const cardID = extractCardID(active.id as unknown as string) 33 | const cardIndex = playerBattlefield!.findIndex((card) => card === BigInt(cardID as string)) 34 | void playCard({ 35 | gameID: gameID!, 36 | playerAddress: playerAddress!, 37 | cardIndexInHand: cardIndex >= 0 ? cardIndex : 0, 38 | setLoading: setLoading, 39 | cancellationHandler: cancellationHandler, 40 | }) 41 | } else if (over && over.id === CardPlacement.HAND) { 42 | return 43 | } 44 | }, 45 | [cancellationHandler, setLoading, playerBattlefield, gameID, playerAddress] 46 | ) 47 | 48 | const handleDragCancel = useCallback( 49 | ({}: DragStartEvent) => { 50 | setActiveId(null) 51 | }, 52 | [setActiveId] 53 | ) 54 | 55 | return { 56 | handleDragStart, 57 | handleDragEnd, 58 | handleDragCancel, 59 | } 60 | } 61 | 62 | export default useDragEvents 63 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useFableWrite.ts: -------------------------------------------------------------------------------- 1 | import { type TransactionReceipt } from "viem" 2 | 3 | import { Hash } from "src/chain" 4 | import { deployment } from "src/deployment" 5 | import { cardsCollectionABI, deckAirdropABI, gameABI, inventoryABI } from "src/generated" 6 | import { useChainWrite, UseWriteResult } from "src/hooks/useChainWrite" 7 | 8 | // ================================================================================================= 9 | // useWrite: just `useWrite` with the contract address and ABI already set. 10 | 11 | export type UseContractSpecificWriteParams = { 12 | functionName: string 13 | args?: any[] 14 | onWrite?: () => void 15 | onSigned?: (data: { hash: Hash }) => void 16 | onSuccess?: (data: TransactionReceipt) => void 17 | onError?: (err: Error) => void 18 | setLoading?: (label: string | null) => void 19 | enabled?: boolean 20 | } 21 | 22 | // ------------------------------------------------------------------------------------------------- 23 | 24 | export function useGameWrite(params: UseContractSpecificWriteParams): UseWriteResult { 25 | try { 26 | return useChainWrite({ ...params, contract: deployment.Game, abi: gameABI }) 27 | } catch (e) { 28 | return { write: undefined } 29 | } 30 | } 31 | 32 | // ------------------------------------------------------------------------------------------------- 33 | 34 | export function useCardsCollectionWrite(params: UseContractSpecificWriteParams): UseWriteResult { 35 | try { 36 | return useChainWrite({ ...params, contract: deployment.CardsCollection, abi: cardsCollectionABI }) 37 | } catch (e) { 38 | return { write: undefined } 39 | } 40 | } 41 | 42 | // ------------------------------------------------------------------------------------------------- 43 | 44 | export function useInventoryWrite(params: UseContractSpecificWriteParams): UseWriteResult { 45 | try { 46 | return useChainWrite({ ...params, contract: deployment.Inventory, abi: inventoryABI }) 47 | } catch (e) { 48 | return { write: undefined } 49 | } 50 | } 51 | 52 | // ------------------------------------------------------------------------------------------------- 53 | 54 | export function useDeckAirdropWrite(params: UseContractSpecificWriteParams): UseWriteResult { 55 | try { 56 | return useChainWrite({ ...params, contract: deployment.DeckAirdrop, abi: deckAirdropABI }) 57 | } catch (e) { 58 | return { write: undefined } 59 | } 60 | } 61 | // ================================================================================================= 62 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useIsHydrated.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | /** 4 | * Returns true if the component has already been hydrated (it is not the first render), false 5 | * otherwise (first render). Automatically triggers a re-render after hydration. 6 | * 7 | * You can also check if you're undergoing server-side rendering if the window property exists: 8 | * `if (typeof window !== "undefined") // not server side-rendering`. 9 | */ 10 | export const useIsHydrated = () => { 11 | const [isHydrated, setIsHydrated] = useState(false) 12 | 13 | useEffect(() => { 14 | setIsHydrated(true) 15 | }, []) 16 | 17 | return isHydrated 18 | } 19 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This hook should be used when setting state from a callback, to make sure the component is still 3 | * mounted (it's an error to set state in an unmounted component). 4 | * 5 | * NOTE: In React strict mode, React will run the effect then immediately remove it, only to re-run 6 | * it later. In the interval where the effect is removed, it might run other effects that will see 7 | * `fisMounted.current` as false. This should generally be fine, because this will be the *second* 8 | * run of these effects. Nevertheless it could cause errors if these effects observe the the 9 | * `isMounted` value to do something more fancy than simply not updating React state (which in a 10 | * second run would simply be a no-op as the state was already set). 11 | * 12 | * Source: https://usehooks-ts.com/react-hook/use-is-mounted 13 | */ 14 | 15 | import { RefObject, useEffect, useRef } from "react" 16 | 17 | export function useIsMounted(): RefObject { 18 | const isMounted = useRef(true) 19 | useEffect(() => { 20 | isMounted.current = true 21 | return () => { 22 | isMounted.current = false 23 | } 24 | }, []) 25 | 26 | return isMounted 27 | } 28 | -------------------------------------------------------------------------------- /packages/webapp/src/hooks/useOfflineCheck.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | import { toast } from "sonner" 4 | 5 | function useOfflineCheck() { 6 | const [isOnline, setIsOnline] = useState(true) 7 | 8 | useEffect(() => { 9 | const handleOnline = () => setIsOnline(true) 10 | const handleOffline = () => setIsOnline(false) 11 | 12 | window.addEventListener("online", handleOnline) 13 | window.addEventListener("offline", handleOffline) 14 | 15 | return () => { 16 | window.removeEventListener("online", handleOnline) 17 | window.removeEventListener("offline", handleOffline) 18 | } 19 | }, []) 20 | 21 | useEffect(() => { 22 | if (!isOnline) { 23 | toast.error("App is offline. Please check your internet connection.", { 24 | dismissible: false, 25 | duration: Infinity, 26 | }) 27 | } else { 28 | toast.dismiss() 29 | } 30 | }, [isOnline]) 31 | } 32 | 33 | export default useOfflineCheck 34 | -------------------------------------------------------------------------------- /packages/webapp/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectKitButton, useModal } from "connectkit" 2 | import { useAccount, useNetwork } from "wagmi" 3 | 4 | import { Address, chains } from "src/chain" 5 | import Link from "src/components/link" 6 | import { CreateGameModal } from "src/components/modals/createGameModal" 7 | import { JoinGameModal } from "src/components/modals/joinGameModal" 8 | import { MintDeckModal } from "src/components/modals/mintDeckModal" 9 | import { Button } from "src/components/ui/button" 10 | import { deployment } from "src/deployment" 11 | import { useGameInGame } from "src/generated" 12 | import { FablePage } from "src/pages/_app" 13 | import { useGameID } from "src/store/hooks" 14 | 15 | const Home: FablePage = ({ isHydrated }) => { 16 | const { address } = useAccount() 17 | const { setOpen } = useModal() 18 | const { chain: usedChain } = useNetwork() 19 | const [_gameID, setGameID] = useGameID() 20 | 21 | // Refresh game ID and put it in the store. 22 | // noinspection JSDeprecatedSymbols 23 | useGameInGame({ 24 | address: deployment.Game, 25 | args: [address as Address], 26 | enabled: !!address, 27 | onSuccess: (gameID) => { 28 | // 0 means we're not in a game 29 | if (gameID !== 0n) setGameID(gameID) 30 | }, 31 | }) 32 | 33 | const chainSupported = chains.some((chain) => chain.id === usedChain?.id) 34 | 35 | // These three states are mutually exclusive. One of them is always true. 36 | const notConnected = !isHydrated || !address 37 | const isRightNetwork = !notConnected && chainSupported 38 | const isWrongNetwork = !notConnected && !chainSupported 39 | 40 | return ( 41 |
42 |
43 |

44 | 0xFABLE 45 |

46 | 47 | {notConnected && ( 48 |
49 | 56 |
57 | )} 58 | 59 | {isWrongNetwork && } 60 | 61 | {isRightNetwork && ( 62 | <> 63 |
64 | 65 | 66 | 67 | 68 | 74 | 75 |
76 | 77 | 78 | )} 79 |
80 |
81 | ) 82 | } 83 | 84 | export default Home 85 | -------------------------------------------------------------------------------- /packages/webapp/src/store/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions to fetch game data from the chain, taking care of various concerns like retries (via 3 | * wagmi), throttling and zombie filtering (via {@link fetch}). 4 | * 5 | * @module store/network 6 | */ 7 | 8 | import { readContract } from "wagmi/actions" 9 | 10 | import { type Address } from "src/chain" 11 | import { deployment } from "src/deployment" 12 | import { gameABI, inventoryABI } from "src/generated" 13 | import { type FetchedGameData } from "src/store/types" 14 | import { type Fetched, throttledFetch } from "src/utils/throttledFetch" 15 | 16 | // ================================================================================================= 17 | 18 | /** 19 | * Fetches the game data, handling throttling and zombie updates, as well as retries (via wagmi). 20 | * Returns null in case of throttling or zombie. 21 | */ 22 | export const fetchGameData: ( 23 | gameID: bigint, 24 | player: Address, 25 | shouldFetchCards: boolean 26 | ) => Promise> = throttledFetch( 27 | async (gameID: bigint, player: Address, shouldFetchCards: boolean) => { 28 | return readContract({ 29 | address: deployment.Game, 30 | abi: gameABI, 31 | functionName: "fetchGameData", 32 | args: [gameID, player, shouldFetchCards], 33 | }) 34 | } 35 | ) 36 | 37 | // ------------------------------------------------------------------------------------------------- 38 | 39 | /** 40 | * Fetches the deck with the given ID for the given player. 41 | * 42 | * Never called at the moment. Doesn't handle throttling and zombies. 43 | */ 44 | export async function fetchDeck(player: Address, deckID: number): Promise { 45 | return readContract({ 46 | address: deployment.Inventory, 47 | abi: inventoryABI, 48 | functionName: "getDeck", 49 | args: [player, deckID], 50 | }) 51 | } 52 | 53 | // ================================================================================================= 54 | -------------------------------------------------------------------------------- /packages/webapp/src/store/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sets up the store as a side-effect of module initialization. 3 | * 4 | * Similar to `src/setup`, but can't be called from there because of a cyclical dependency (loading 5 | * storage-loaded atoms needs a JSON parsing hook to be setup). 6 | * 7 | * @module store/setup 8 | */ 9 | 10 | import { getAccount, watchAccount, watchNetwork } from "wagmi/actions" 11 | 12 | import { GAME_DATA_REFRESH_INTERVAL } from "src/constants" 13 | import * as store from "src/store/atoms" 14 | import { gameIDListener, refreshGameData, updateNetwork, updatePlayerAddress } from "src/store/update" 15 | 16 | // ================================================================================================= 17 | 18 | function setupStore() { 19 | if (typeof window === "undefined") 20 | // Do not set up subscriptions and timers on the server. 21 | return 22 | 23 | // Whenever the connected wallet address changes, update the player address. 24 | watchAccount(updatePlayerAddress) 25 | 26 | // Make sure to clear game data if we switch to an unsupported network. 27 | watchNetwork(updateNetwork) 28 | 29 | // Make sure we don't miss the initial value, if already set. 30 | updatePlayerAddress(getAccount()) 31 | 32 | // Update / clear game data whenever the game ID changes. 33 | store.store.sub(store.gameID, () => { 34 | gameIDListener(store.get(store.gameID)) 35 | }) 36 | 37 | // Make sure we don't miss the initial value, if already set. 38 | const gameID = store.get(store.gameID) 39 | if (gameID !== null) gameIDListener(gameID) 40 | 41 | // Periodically refresh game data. 42 | setInterval(() => { 43 | const gameID = store.get(store.gameID) 44 | const player = store.get(store.playerAddress) 45 | if (gameID !== null && player !== null) void refreshGameData() 46 | }, GAME_DATA_REFRESH_INTERVAL) 47 | } 48 | 49 | // ================================================================================================= 50 | 51 | setupStore() 52 | 53 | // ================================================================================================= 54 | -------------------------------------------------------------------------------- /packages/webapp/src/store/write.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Function to modify the store, either directly or after transforming the input data. 3 | * 4 | * @module store/write 5 | */ 6 | 7 | import { Address } from "src/chain" 8 | import { PROOF_CURVE_ORDER } from "src/game/constants" 9 | import * as store from "src/store/atoms" 10 | import { ErrorConfig, PrivateInfo } from "src/store/types" 11 | import { mimcHash } from "src/utils/hashing" 12 | import { randomUint256 } from "src/utils/js-utils" 13 | 14 | import "src/utils/extensions" 15 | 16 | const get = store.get 17 | const set = store.set 18 | 19 | // ================================================================================================= 20 | // SIMPLE SETTERS 21 | 22 | /** 23 | * Sets the game ID in the store. 24 | */ 25 | export function setGameID(gameID: bigint) { 26 | set(store.gameID, gameID) 27 | } 28 | 29 | // ------------------------------------------------------------------------------------------------- 30 | 31 | /** 32 | * Triggers the display a global UI error, or clears the error if `null` is passed. 33 | */ 34 | export function setError(error: ErrorConfig | null) { 35 | console.log(`setting error modal: ${JSON.stringify(error)}`) 36 | set(store.errorConfig, error) 37 | } 38 | 39 | // ================================================================================================= 40 | // DERIVED SETTERS 41 | 42 | // ------------------------------------------------------------------------------------------------- 43 | 44 | /** 45 | * Sets the private information specific to the given game and player in the preivate info store. 46 | */ 47 | export function setPrivateInfo(gameID: bigint, player: Address, privateInfo: PrivateInfo) { 48 | const privateInfoStore = get(store.privateInfoStore) 49 | const strID = gameID.toString() 50 | set(store.privateInfoStore, { 51 | ...privateInfoStore, 52 | [strID]: { 53 | ...privateInfoStore[strID], 54 | [player]: privateInfo, 55 | }, 56 | }) 57 | } 58 | 59 | // ------------------------------------------------------------------------------------------------- 60 | 61 | /** 62 | * Returns the private information specific to the given game and player, initializing it if 63 | * it doesn't exist yet. Meant to be called when joining a game. 64 | */ 65 | export function getOrInitPrivateInfo(gameID: bigint, playerAddress: Address): PrivateInfo { 66 | const privateInfoStore = store.get(store.privateInfoStore) 67 | let privateInfo = privateInfoStore[gameID.toString()]?.[playerAddress] 68 | 69 | if (privateInfo !== undefined) return privateInfo 70 | 71 | // The player's secret salt, necessary to hide information. 72 | const salt = randomUint256() % PROOF_CURVE_ORDER 73 | 74 | privateInfo = { 75 | salt, 76 | saltHash: mimcHash([salt]), 77 | // dummy values 78 | handIndexes: [], 79 | deckIndexes: [], 80 | handRoot: `0x0`, 81 | deckRoot: `0x0`, 82 | } 83 | 84 | setPrivateInfo(gameID, playerAddress, privateInfo) 85 | return privateInfo 86 | } 87 | 88 | // ================================================================================================= 89 | -------------------------------------------------------------------------------- /packages/webapp/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply overflow-y-hidden; 7 | } 8 | 9 | @font-face { 10 | font-family: "fable"; 11 | src: url("/font/BluuNext-Bold.otf") format("opentype"); 12 | font-weight: normal; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | 17 | @layer utilities { 18 | @layer responsive { 19 | /* Hide scrollbar for Chrome, Safari, and Opera */ 20 | .no-scrollbar::-webkit-scrollbar { 21 | display: none; 22 | } 23 | 24 | /* Hide scrollbar for IE, Edge, and Firefox */ 25 | .no-scrollbar { 26 | -ms-overflow-style: none; /* IE and Edge */ 27 | scrollbar-width: none; /* Firefox */ 28 | } 29 | } 30 | } 31 | 32 | /* this can be visualized and changed here: https://ui.shadcn.com/themes 33 | -- these values are read by tailwind.config.cjs 34 | */ 35 | @layer base { 36 | :root { 37 | --background: 305 19% 11%; 38 | --foreground: 182 6% 83%; 39 | 40 | --card: 0 0% 100%; 41 | --card-foreground: 0 0% 3.9%; 42 | 43 | --popover: 0 0% 100%; 44 | --popover-foreground: 0 0% 3.9%; 45 | 46 | --primary: 30 67% 51%; 47 | --primary-foreground: 28 35% 13%; 48 | 49 | --secondary: 182 25% 13%; 50 | --secondary-foreground: 182 6% 83%; 51 | 52 | --muted: 0 0% 96.1%; 53 | --muted-foreground: 0 0% 45.1%; 54 | 55 | --accent: 0 0% 96.1%; 56 | --accent-foreground: 0 0% 9%; 57 | 58 | --destructive: 0 84.2% 60.2%; 59 | --destructive-foreground: 0 0% 98%; 60 | 61 | --border: 0 0% 89.8%; 62 | --input: 0 0% 89.8%; 63 | --ring: 0 0% 3.9%; 64 | 65 | --radius: 0.5rem; 66 | } 67 | 68 | .dark { 69 | --background: 0 0% 3.9%; 70 | --foreground: 0 0% 98%; 71 | 72 | --card: 0 0% 3.9%; 73 | --card-foreground: 0 0% 98%; 74 | 75 | --popover: 0 0% 3.9%; 76 | --popover-foreground: 0 0% 98%; 77 | 78 | --primary: 0 0% 98%; 79 | --primary-foreground: 0 0% 9%; 80 | 81 | --secondary: 0 0% 14.9%; 82 | --secondary-foreground: 0 0% 98%; 83 | 84 | --muted: 0 0% 14.9%; 85 | --muted-foreground: 0 0% 63.9%; 86 | 87 | --accent: 0 0% 14.9%; 88 | --accent-foreground: 0 0% 98%; 89 | 90 | --destructive: 0 62.8% 30.6%; 91 | --destructive-foreground: 0 0% 98%; 92 | 93 | --border: 0 0% 14.9%; 94 | --input: 0 0% 14.9%; 95 | --ring: 0 0% 83.1%; 96 | } 97 | } 98 | 99 | @layer base { 100 | * { 101 | @apply border-border; 102 | } 103 | body { 104 | @apply bg-background text-foreground; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/asyncLock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A (non-re-entrant) lock implemented using a promise. 3 | * 4 | * Bracket the critical section(s) of your code with `protect()` (or with `lock.take()` and 5 | * `lock.release()`) to ensure that the execution of two bracketed sections are never interleaved 6 | * (which could happen in Javascript if they contain async code). 7 | */ 8 | export class AsyncLock { 9 | #promise: Promise 10 | #resolve: () => void 11 | 12 | constructor() { 13 | this.#resolve = () => {} // shut up bogus warnings 14 | this.#promise = new Promise((resolve) => (this.#resolve = resolve)) 15 | this.#resolve() 16 | } 17 | 18 | async take() { 19 | await this.#promise 20 | this.#promise = new Promise((resolve) => (this.#resolve = resolve)) 21 | } 22 | 23 | release() { 24 | this.#resolve() 25 | } 26 | 27 | async protect(f: () => Promise): Promise { 28 | await this.take() 29 | try { 30 | return await f() 31 | } finally { 32 | this.release() 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic error types. 3 | * 4 | * @module utils/errors 5 | */ 6 | 7 | // ================================================================================================= 8 | 9 | /** 10 | * Thrown when an operation times out. 11 | */ 12 | export class TimeoutError extends Error { 13 | constructor(msg: string) { 14 | super(msg) 15 | } 16 | } 17 | 18 | // ================================================================================================= 19 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/extensions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Useful extensions to native types. 3 | * 4 | * @module utils/extensions 5 | */ 6 | 7 | export {} 8 | 9 | // ================================================================================================= 10 | 11 | declare global { 12 | interface Array { 13 | last(): T 14 | setLast(item: T): void 15 | } 16 | } 17 | 18 | Object.defineProperty(Array.prototype, "last", { 19 | enumerable: false, // don't include in for...in loops 20 | configurable: true, // enable redefinition — good for hotloading 21 | value: function () { 22 | return this[this.length - 1] 23 | }, 24 | }) 25 | 26 | Object.defineProperty(Array.prototype, "setLast", { 27 | enumerable: false, // don't include in for...in loops 28 | configurable: true, // enable redefinition — good for hotloading 29 | value: function (item: any) { 30 | this[this.length - 1] = item 31 | }, 32 | }) 33 | 34 | // ================================================================================================= 35 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/hashing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Function to compute hashes and Merkle roots. 3 | * 4 | * @module utils/hash 5 | */ 6 | 7 | // I hate typescript package management bullshit. 8 | // @ts-ignore 9 | import { buildMimcSponge } from "circomlibjs" 10 | 11 | // ================================================================================================= 12 | // HASH FUNCTIONS 13 | 14 | const mimcSponge = await buildMimcSponge() 15 | 16 | /** 17 | * The MiMCSponge hash function. 18 | */ 19 | export function mimcHash(inputs: readonly bigint[]): bigint { 20 | return mimcSponge.F.toObject(mimcSponge.multiHash(inputs)) 21 | } 22 | 23 | // ================================================================================================= 24 | // CONSTANTS 25 | 26 | /** 27 | * Value to fill an array that is smaller than the requested (power of two) size for Merkleizing 28 | * an array. The array may not contain this value otherwise Merkle roots are not unique! 29 | */ 30 | export const fillerValue = 255n 31 | 32 | // ================================================================================================= 33 | // MERKLEIZATION 34 | 35 | /** 36 | * Returns the MiMC-based Merkle root of `items`, after extending it to size `size` by filling it up 37 | * with `filler`. `size` must be a power of two. 38 | */ 39 | export function merkleize(size: number, items: readonly bigint[], filler: bigint = fillerValue): bigint { 40 | if (size & (size - 1) || size == 0) throw new Error("size must be a power of 2") 41 | 42 | const extended = [...items] 43 | for (let i = items.length; i < size; i++) extended.push(filler) 44 | 45 | return _merkleize(extended) 46 | } 47 | 48 | // ------------------------------------------------------------------------------------------------- 49 | 50 | /** 51 | * Merkleize `items`, assuming its size is a power of 2. 52 | */ 53 | function _merkleize(items: readonly bigint[]): bigint { 54 | if (items.length === 1) return items[0] 55 | const half = items.length / 2 56 | return mimcHash([_merkleize(items.slice(0, half)), _merkleize(items.slice(half))]) 57 | } 58 | 59 | // ================================================================================================= 60 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/jotai.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities to deal with the Jotai state management library. 3 | * 4 | * @module utils/jotai 5 | */ 6 | 7 | import { Atom, atom } from "jotai" 8 | import { isEqual } from "lodash" 9 | 10 | // ================================================================================================= 11 | 12 | /** Re-export simplified definition of jotai Getter type. */ 13 | export type Getter = (atom: Atom) => Value 14 | 15 | /** Re-export simplified definition of jotai Read type. */ 16 | export type Read = (get: Getter) => Value 17 | 18 | /** 19 | * Similar to a read-only derived atom, but the result will be cached via deep-comparison, 20 | * to avoid spurious re-renders. 21 | */ 22 | export function cachedAtom(read: Read): Atom { 23 | let cache: Value | null = null 24 | return atom((get) => { 25 | const value = read(get as any) 26 | if (!isEqual(value, cache)) cache = value 27 | return cache! 28 | }) 29 | } 30 | 31 | // ================================================================================================= 32 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/navigate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities to navigate from one page to the next. 3 | * 4 | * @module utils/navigate 5 | */ 6 | 7 | import { NextRouter } from "next/router" 8 | 9 | // ================================================================================================= 10 | 11 | /** 12 | * Wraps `router.push`, carrying over some query parameters from the current URL in development 13 | * mode. 14 | */ 15 | export async function navigate(router: NextRouter, url: string): Promise { 16 | if (process.env.NODE_ENV === "development") { 17 | const index = parseInt(router.query.index as string) 18 | if (index !== undefined && !isNaN(index) && 0 <= index && index <= 9) 19 | url = url + (url.includes("?") ? "&" : "?") + `index=${index}` 20 | } 21 | return router.push(url) 22 | } 23 | 24 | // ================================================================================================= 25 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/react-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for use with React & associated libraries. 3 | * 4 | * @module utils/react-utils 5 | */ 6 | 7 | // ================================================================================================= 8 | 9 | import { type Atom, atom, type Getter, type Setter, type WritableAtom } from "jotai" 10 | 11 | // ================================================================================================= 12 | // Recreating (simplified) Jotai types 13 | 14 | /** 15 | * Simplified version of the unexported Jotai `Read` type (parameter to some {@link atom} 16 | * overloads). 17 | */ 18 | export type JotaiRead = (get: Getter) => Value 19 | 20 | /** 21 | * Simplified version of the unexported Jotai `Write` type (parameter to some {@link atom} 22 | * overloads), meant to work with {@link writeableAtom} and its {@link WAtom} return type. 23 | */ 24 | export type JotaiWrite = (get: Getter, set: Setter, ...args: [Value]) => void 25 | 26 | // ================================================================================================= 27 | 28 | export function readOnlyAtom(readWriteAtom: Atom): Atom { 29 | return atom((get) => get(readWriteAtom)) 30 | } 31 | 32 | // ------------------------------------------------------------------------------------------------- 33 | 34 | /** 35 | * Just like {@link WritableAtom} where the object supplied to the setter is the same as the type 36 | * returned by the getter and the setter has no return value. 37 | */ 38 | export type WAtom = WritableAtom 39 | 40 | // ------------------------------------------------------------------------------------------------- 41 | 42 | /** Just an alias for the overload of {@link atom} that returns a {@link WAtom}. */ 43 | export function writeableAtom(read: JotaiRead, write: JotaiWrite): WAtom { 44 | return atom(read, write) 45 | } 46 | 47 | // ------------------------------------------------------------------------------------------------- 48 | 49 | /** 50 | * Just an alias for the read-only overload of {@link atom} to signify we expect a an async read 51 | * function here and avoid typing the {@link Promise} type when the value type is typed out 52 | * explicitly for documentation purposes. 53 | */ 54 | export function asyncAtom(read: (get: Getter) => Promise): Atom> { 55 | return atom(read) 56 | } 57 | 58 | // ------------------------------------------------------------------------------------------------- 59 | 60 | /** 61 | * Just an alias for the read-write overload of {@link atom} to signify we expect a an async read 62 | * function here and avoid typing the {@link Promise} type when the value type is typed out 63 | * explicitly for documentation purposes. 64 | */ 65 | export function asyncWriteableAtom( 66 | read: (get: Getter) => Promise, 67 | write: (get: Getter, set: Setter, value: Value) => void 68 | ): WritableAtom, [Value], void> { 69 | return atom(read, write) 70 | } 71 | 72 | // ================================================================================================= 73 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/throttledFetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cf. {@link throttledFetch} 3 | * 4 | * @module throttledFetch 5 | */ 6 | 7 | // ================================================================================================= 8 | 9 | import { DEFAULT_THROTTLE_PERIOD } from "src/constants" 10 | 11 | /** Returned by {@link throttledFetch} when a rejected because of throttling. */ 12 | export const THROTTLED = Symbol("THROTTLED") 13 | 14 | /** Returned by {@link throttledFetch} when a fetch is rejected because it is a zombie. */ 15 | export const ZOMBIE = Symbol("ZOMBIE") 16 | 17 | /** 18 | * Type returned by {@link throttledFetch}, which can be the result type, {@link THROTTLED} or 19 | * {@link ZOMBIE}. 20 | */ 21 | export type Fetched = Result | typeof THROTTLED | typeof ZOMBIE 22 | 23 | // ------------------------------------------------------------------------------------------------- 24 | 25 | // NOTE(norswap): Throttling was designed with the idea in mind that we would always emit a fetch 26 | // for the data we need when we don't have it. The architecture has changed a bit and now we only 27 | // fetch the game data, and we only do that when we have a sign that it's needed, or we're retrying, 28 | // or in case of the safety fallback fetch on a timer (not yet implemented). As such, there should 29 | // never be a case when we actually need to throttle anything, and we might consider deleting this 30 | // code. 31 | 32 | /** 33 | * Returns a function wrapping {@link fetchFn} that will be throttled so that if another fetch (from 34 | * another call to the returned function) is in-flight, and less than {@link throttlePeriod} 35 | * milliseconds have elapsed, the fetch will be ignored. 36 | * 37 | * (Note that, unlike lodash's throttle, we do enable back-to-back fetches, as long as a fetch 38 | * request comes in after the previous fetch has completed). 39 | * 40 | * Additionally, fetches are given sequence numbers, so that the result of a "zombie" fetch that was 41 | * initiated befored another fetch that has already completed is ignored. 42 | * 43 | * Throttled and ignored fetches return null. 44 | */ 45 | export function throttledFetch( 46 | fetchFn: (...args: Params) => Promise, 47 | throttlePeriod: number = DEFAULT_THROTTLE_PERIOD 48 | ): (...args: Params) => Promise> { 49 | // Used for throttling 50 | let lastRequestTimestamp = 0 51 | 52 | // used to avoid "zombie" updates: old data overwriting newer game data. 53 | let sequenceNumber = 1 54 | let lastCompletedNumber = 0 55 | 56 | return async (...args: Params) => { 57 | const seqNum = sequenceNumber++ 58 | 59 | // Throttle 60 | const timestamp = Date.now() 61 | if (timestamp - lastRequestTimestamp < throttlePeriod) return THROTTLED // there is a recent-ish refresh in flight 62 | lastRequestTimestamp = timestamp 63 | 64 | let result: Result 65 | let lastCompletedNumberAfterFetch: number 66 | try { 67 | result = await fetchFn(...args) 68 | } catch (e) { 69 | throw e 70 | } finally { 71 | // Bookkeeping for zombie filtering 72 | lastCompletedNumberAfterFetch = lastCompletedNumber 73 | lastCompletedNumber = seqNum 74 | 75 | // Allow another fetch immediately 76 | lastRequestTimestamp = 0 77 | } 78 | 79 | // Filter zombie updates 80 | if (seqNum < lastCompletedNumberAfterFetch) return ZOMBIE 81 | 82 | return result 83 | } 84 | } 85 | 86 | // ================================================================================================= 87 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/ui-utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | /** 5 | * Combines and deduplicates class names using `clsx` and `tailwind-merge`. 6 | * 7 | * This function takes any number of class value inputs, combines them into 8 | * a single string of class names using `clsx`, and then deduplicates 9 | * any Tailwind CSS classes using `tailwind-merge`. This is useful for 10 | * dynamically generating a class string in React components, especially when 11 | * dealing with conditional class names or combining classes from different sources. 12 | * 13 | * @param inputs - An array of class value inputs. Each input can be a string, 14 | * an array, or an object with class names as keys and boolean values as values 15 | * to conditionally include classes. 16 | * 17 | * @returns A string of combined and deduplicated class names. 18 | * 19 | * @example 20 | * cn('text-center', 'py-2', { 'bg-red-500': isError }, ['hover:bg-blue-500']) 21 | * // Returns a string of class names, e.g., 'text-center py-2 bg-red-500 hover:bg-blue-500' 22 | */ 23 | export function cn(...inputs: ClassValue[]) { 24 | return twMerge(clsx(inputs)) 25 | } 26 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/zkproofs/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities to deal with Circom proofs. 3 | * 4 | * @module utils/zkproofs 5 | */ 6 | 7 | // Note: we've needed to split proveInWorker off from proofs.ts, because the worker scripts 8 | // (proofWorker.ts) needs to import from proofs.ts, and if it imports the file that instantiates 9 | // the worker, then webpack complains about circular dependencies (it can't include content hashes 10 | // in chunk identities, or something like that, which apparently can cause caching problems). 11 | 12 | export * from "src/utils/zkproofs/proofs" 13 | export { proveInWorker } from "src/utils/zkproofs/proveInWorker" 14 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/zkproofs/proofWorker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Worker script for computing zero-knowledge proofs from actions. 3 | * 4 | * Used from {@link proveInWorker}. 5 | */ 6 | 7 | import { type ProofInputs, prove } from "src/utils/zkproofs/proofs" 8 | 9 | // ================================================================================================= 10 | 11 | type ProofSpec = { 12 | circuitName: string 13 | inputs: ProofInputs 14 | } 15 | 16 | // ------------------------------------------------------------------------------------------------- 17 | 18 | addEventListener("message", async (event: MessageEvent) => { 19 | try { 20 | const output = await prove(event.data.circuitName, event.data.inputs) 21 | postMessage(output) 22 | } catch (error) { 23 | // This will be a ProofError 24 | postMessage(error) 25 | } 26 | }) 27 | 28 | // ================================================================================================= 29 | -------------------------------------------------------------------------------- /packages/webapp/src/utils/zkproofs/proveInWorker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enables generating a zk proof in a dedicated web worker. 3 | * 4 | * This needs to be split from {@link module:utils/zkproofs/proofs} because of dependencies issue. 5 | * See the comment in `index.ts` for explanations. 6 | * 7 | * Import this from {@link module:utils/zkproofs} instead. 8 | */ 9 | 10 | import { isProofOutput, ProofCancelled, ProofOutput, ProofTimeoutError } from "src/utils/zkproofs/proofs" 11 | 12 | // ================================================================================================= 13 | 14 | /** 15 | * Just like {@link module:utils/zkproofs/proofs#prove}, but runs the proof in a dedicated web 16 | * worker. 17 | * 18 | * A timeout (in seconds) can be supplied, in which case the worker will be terminated if the proof 19 | * takes longer than the timeout. If set to 0 (the default), no timeout is used. 20 | * 21 | * In additiona to the promise, this returns a `cancel` function which can be used to terminate the 22 | * worker (and hence cancel the proof). 23 | */ 24 | export function proveInWorker( 25 | circuitName: string, 26 | inputs: Record, 27 | timeout: number = 0 28 | ): { promise: Promise; cancel: () => void } { 29 | const proofWorker = new Worker(new URL("proofWorker.ts", import.meta.url)) 30 | 31 | let timeoutID: ReturnType | undefined = undefined 32 | let reject: (reason: Error) => void 33 | 34 | const promise = new Promise((resolve, _reject) => { 35 | reject = _reject 36 | 37 | proofWorker.onmessage = (event: MessageEvent) => { 38 | if (isProofOutput(event.data)) resolve(event.data) 39 | else reject(event.data) 40 | } 41 | 42 | if (timeout > 0) 43 | timeoutID = setTimeout(() => { 44 | proofWorker.terminate() 45 | reject(new ProofTimeoutError(`proof timed out after ${timeout}s`)) 46 | }, timeout * 1000) 47 | }) 48 | 49 | proofWorker.postMessage({ circuitName, inputs }) 50 | 51 | return { 52 | promise, 53 | cancel: () => { 54 | if (timeoutID !== undefined) clearTimeout(timeoutID) 55 | proofWorker.terminate() 56 | reject(new ProofCancelled("proof cancelled by user")) 57 | }, 58 | } 59 | } 60 | 61 | // ================================================================================================= 62 | -------------------------------------------------------------------------------- /packages/webapp/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | // darkMode: ["class"], 4 | content: ["./src/**/*.{ts,tsx}"], 5 | prefix: "", 6 | theme: { 7 | container: { 8 | center: true, 9 | padding: "2rem", 10 | screens: { 11 | "2xl": "1400px", 12 | }, 13 | }, 14 | extend: { 15 | fontFamily: { 16 | fable: ["fable", "sans-serif"], 17 | }, 18 | colors: { 19 | border: "hsl(var(--border))", 20 | input: "hsl(var(--input))", 21 | ring: "hsl(var(--ring))", 22 | background: "hsl(var(--background))", 23 | foreground: "hsl(var(--foreground))", 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | destructive: { 33 | DEFAULT: "hsl(var(--destructive))", 34 | foreground: "hsl(var(--destructive-foreground))", 35 | }, 36 | muted: { 37 | DEFAULT: "hsl(var(--muted))", 38 | foreground: "hsl(var(--muted-foreground))", 39 | }, 40 | accent: { 41 | DEFAULT: "hsl(var(--accent))", 42 | foreground: "hsl(var(--accent-foreground))", 43 | }, 44 | popover: { 45 | DEFAULT: "hsl(var(--popover))", 46 | foreground: "hsl(var(--popover-foreground))", 47 | }, 48 | card: { 49 | DEFAULT: "hsl(var(--card))", 50 | foreground: "hsl(var(--card-foreground))", 51 | }, 52 | }, 53 | borderRadius: { 54 | lg: "var(--radius)", 55 | md: "calc(var(--radius) - 2px)", 56 | sm: "calc(var(--radius) - 4px)", 57 | }, 58 | keyframes: { 59 | "accordion-down": { 60 | from: { height: "0" }, 61 | to: { height: "var(--radix-accordion-content-height)" }, 62 | }, 63 | "accordion-up": { 64 | from: { height: "var(--radix-accordion-content-height)" }, 65 | to: { height: "0" }, 66 | }, 67 | }, 68 | animation: { 69 | "accordion-down": "accordion-down 0.2s ease-out", 70 | "accordion-up": "accordion-up 0.2s ease-out", 71 | }, 72 | // Custom box shadow that adds a 'highlight' effect 73 | // For example, add 'shadow-highlight shadow-orange-300' to className 74 | // See: https://tailwindcss.com/docs/box-shadow#customizing-your-theme 75 | boxShadow: { 76 | highlight: "0 0 20px", 77 | }, 78 | }, 79 | }, 80 | plugins: [require("tailwindcss-animate")], 81 | } 82 | -------------------------------------------------------------------------------- /packages/webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "baseUrl": "./src", 5 | "paths": { 6 | "src/*": ["./*"], 7 | "contracts/*": ["../../contracts/*"] 8 | }, 9 | "lib": ["dom", "dom.iterable", "es2022"], 10 | "allowJs": true, 11 | "checkJs": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "alwaysStrict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noEmit": true, 17 | "incremental": true, 18 | "esModuleInterop": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "jsx": "preserve" 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 26 | "exclude": [ 27 | //"node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/webapp/wagmi.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@wagmi/cli" 2 | import { foundry, react } from "@wagmi/cli/plugins" 3 | 4 | export default defineConfig({ 5 | out: "src/generated.ts", 6 | plugins: [ 7 | react(), 8 | foundry({ 9 | project: "../contracts", 10 | include: [ 11 | "CardsCollection.sol/**/*.json", 12 | "Game.sol/**/*.json", 13 | "Inventory.sol/**/*.json", 14 | "InventoryCardsCollection.sol/**/*.json", 15 | "DeckAirdrop.sol/**/*.json", 16 | ], 17 | }), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | --------------------------------------------------------------------------------