├── frontend ├── assets │ ├── heads.png │ ├── tails.png │ ├── favicon.ico │ ├── logo-white.svg │ ├── logo-black.svg │ └── global.css ├── cypress │ ├── cypres.config.js │ └── coin-flip.cy.js ├── start.sh ├── package.json ├── index.html ├── index.js └── near-wallet.js ├── contract ├── build.sh ├── deploy.sh ├── tsconfig.json ├── package.json ├── src │ └── contract.ts └── README.md ├── .gitignore ├── integration-tests ├── ava.config.cjs ├── package.json └── src │ └── main.ava.ts ├── .github └── workflows │ └── tests.yml ├── package.json ├── LICENSE └── README.md /frontend/assets/heads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailisp/coin-flip-js/main/frontend/assets/heads.png -------------------------------------------------------------------------------- /frontend/assets/tails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailisp/coin-flip-js/main/frontend/assets/tails.png -------------------------------------------------------------------------------- /frontend/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailisp/coin-flip-js/main/frontend/assets/favicon.ico -------------------------------------------------------------------------------- /contract/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo ">> Building contract" 4 | 5 | near-sdk-js build src/contract.ts build/contract.wasm -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | neardev 4 | yarn.lock 5 | .parcel-cache/ 6 | dist 7 | **/videos 8 | **/screenshots 9 | .idea 10 | -------------------------------------------------------------------------------- /contract/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # build the contract 4 | npm run build 5 | 6 | # deploy the contract 7 | near dev-deploy --wasmFile build/contract.wasm -------------------------------------------------------------------------------- /contract/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es5", 5 | "noEmit": true 6 | }, 7 | "exclude": [ 8 | "node_modules" 9 | ], 10 | } -------------------------------------------------------------------------------- /integration-tests/ava.config.cjs: -------------------------------------------------------------------------------- 1 | require("util").inspect.defaultOptions.depth = 5; // Increase AVA's printing depth 2 | 3 | module.exports = { 4 | timeout: "300000", 5 | files: ["src/*.ava.ts"], 6 | failWithoutAssertions: false, 7 | extensions: ["ts"], 8 | require: ["ts-node/register"], 9 | }; 10 | -------------------------------------------------------------------------------- /integration-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration-tests", 3 | "version": "1.0.0", 4 | "license": "(MIT AND Apache-2.0)", 5 | "scripts": { 6 | "test": "ava" 7 | }, 8 | "devDependencies": { 9 | "@types/bn.js": "^5.1.0", 10 | "@types/node": "^18.6.2", 11 | "ava": "^4.2.0", 12 | "near-workspaces": "^3.2.1", 13 | "ts-node": "^10.8.0", 14 | "typescript": "^4.7.2" 15 | }, 16 | "dependencies": {} 17 | } 18 | -------------------------------------------------------------------------------- /contract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contract", 3 | "version": "1.0.0", 4 | "license": "(MIT AND Apache-2.0)", 5 | "type": "module", 6 | "scripts": { 7 | "build": "./build.sh", 8 | "deploy": "./deploy.sh", 9 | "test": "echo no unit testing" 10 | }, 11 | "dependencies": { 12 | "near-cli": "^3.4.0", 13 | "near-sdk-js": "0.7.0" 14 | }, 15 | "devDependencies": { 16 | "typescript": "^4.8.4", 17 | "ts-morph": "^16.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/cypress/cypres.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:1234', 6 | specPattern: 'cypress/**/*.cy.{js,jsx,ts,tsx}', 7 | supportFile: false, 8 | chromeWebSecurity: false, 9 | experimentalSessionAndOrigin: true, 10 | testIsolation: 'on', 11 | defaultCommandTimeout: 30000, 12 | env: { 13 | seed: 'give laugh youth nice fossil common neutral since best biology swift unhappy', 14 | }, 15 | excludeSpecPattern: [ 16 | '**/__snapshots__/*', 17 | '**/__image_snapshots__/*' 18 | ] 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | 4 | concurrency: 5 | group: ${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | workflows: 10 | strategy: 11 | matrix: 12 | platform: [ubuntu-latest] #, windows-latest, macos-latest] 13 | runs-on: ${{ matrix.platform }} 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: "16" 19 | - name: Install modules 20 | run: yarn 21 | - name: Deploy Contract 22 | run: printf 'y\n' | yarn deploy 23 | - name: Run tests 24 | run: yarn test 25 | -------------------------------------------------------------------------------- /frontend/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | GREEN='\033[1;32m' 4 | NC='\033[0m' # No Color 5 | 6 | CONTRACT_DIRECTORY=../contract 7 | DEV_ACCOUNT_FILE="${CONTRACT_DIRECTORY}/neardev/dev-account.env" 8 | 9 | start () { 10 | echo The app is starting! 11 | env-cmd -f $DEV_ACCOUNT_FILE parcel index.html --open 12 | } 13 | 14 | alert () { 15 | echo "======================================================" 16 | echo "It looks like you forgot to deploy your contract" 17 | echo ">> Run ${GREEN}'npm run deploy'${NC} from the 'root' directory" 18 | echo "======================================================" 19 | } 20 | 21 | if [ -f "$DEV_ACCOUNT_FILE" ]; then 22 | start 23 | else 24 | alert 25 | fi 26 | -------------------------------------------------------------------------------- /frontend/assets/logo-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/assets/logo-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coin-flip-js", 3 | "version": "1.0.0", 4 | "license": "(MIT AND Apache-2.0)", 5 | "scripts": { 6 | "start": "cd frontend && npm run start", 7 | "deploy": "cd contract && npm run deploy", 8 | "build": "npm run build:contract && npm run build:web", 9 | "build:web": "cd frontend && npm run build", 10 | "build:contract": "cd contract && npm run build", 11 | "test": "npm run build:contract && npm run test:unit && npm run test:integration && npm run test:e2e", 12 | "test:unit": "cd contract && npm test", 13 | "test:integration": "cd integration-tests && npm test -- -- \"./contract/build/contract.wasm\"", 14 | "test:e2e": "cd frontend && npm run test:e2e", 15 | "postinstall": "cd frontend && npm install && cd .. && cd integration-tests && npm install && cd .. && cd contract && npm install" 16 | }, 17 | "devDependencies": { 18 | "near-cli": "^3.3.0" 19 | }, 20 | "dependencies": {} 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Examples for building on the NEAR blockchain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-near-app", 3 | "version": "1.0.0", 4 | "license": "(MIT AND Apache-2.0)", 5 | "scripts": { 6 | "start": "./start.sh", 7 | "start:headless": "env-cmd -f '../contract/neardev/dev-account.env' parcel index.html", 8 | "build": "parcel build index.html --public-url ./", 9 | "test:e2e": "npm run start:headless & npm run cypress:run", 10 | "cypress:run": "cypress run --config-file cypress/cypres.config.js", 11 | "cypress:open": "cypress open --browser chromium --config-file cypress/cypres.config.js" 12 | }, 13 | "devDependencies": { 14 | "cypress": "^11.2.0", 15 | "env-cmd": "^10.1.0", 16 | "events": "^3.3.0", 17 | "nodemon": "^2.0.16", 18 | "parcel": "^2.7.0", 19 | "process": "^0.11.10" 20 | }, 21 | "dependencies": { 22 | "@near-wallet-selector/core": "^7.0.0", 23 | "@near-wallet-selector/ledger": "^7.0.0", 24 | "@near-wallet-selector/math-wallet": "^7.0.0", 25 | "@near-wallet-selector/meteor-wallet": "^7.0.0", 26 | "@near-wallet-selector/modal-ui": "^7.0.0", 27 | "@near-wallet-selector/my-near-wallet": "^7.0.0", 28 | "@near-wallet-selector/near-wallet": "^7.0.0", 29 | "@near-wallet-selector/nightly": "^7.0.0", 30 | "@near-wallet-selector/nightly-connect": "^7.0.0", 31 | "@near-wallet-selector/sender": "^7.0.0", 32 | "@near-wallet-selector/wallet-connect": "^7.0.0", 33 | "near-api-js": "^0.44.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /integration-tests/src/main.ava.ts: -------------------------------------------------------------------------------- 1 | import { Worker, NearAccount } from 'near-workspaces'; 2 | import anyTest, { TestFn } from 'ava'; 3 | 4 | const test = anyTest as TestFn<{ 5 | worker: Worker; 6 | accounts: Record; 7 | }>; 8 | 9 | test.beforeEach(async (t) => { 10 | // Init the worker and start a Sandbox server 11 | const worker = await Worker.init(); 12 | 13 | // Deploy contract 14 | const root = worker.rootAccount; 15 | const contract = await root.createSubAccount('test-account'); 16 | 17 | // Get wasm file path from package.json test script in folder above 18 | await contract.deploy(process.argv[2]); 19 | 20 | // Save state for test runs, it is unique for each test 21 | t.context.worker = worker; 22 | t.context.accounts = { root, contract }; 23 | }); 24 | 25 | test.afterEach(async (t) => { 26 | // Stop Sandbox server 27 | await t.context.worker.tearDown().catch((error) => { 28 | console.log('Failed to stop the Sandbox:', error); 29 | }); 30 | }); 31 | 32 | test('by default the user has no points', async (t) => { 33 | const { root, contract } = t.context.accounts; 34 | const points: number = await contract.view('points_of', { player: root.accountId }); 35 | t.is(points, 0); 36 | }); 37 | 38 | test('the points are correctly computed', async (t) => { 39 | const { root, contract } = t.context.accounts; 40 | 41 | let counter: {[key:string]:number} = { 'heads': 0, 'tails': 0 } 42 | let expected_points = 0; 43 | 44 | for(let i=0; i<10; i++){ 45 | const res = await root.call(contract, 'flip_coin', { 'player_guess': 'heads' }) 46 | counter[res as string] += 1; 47 | expected_points += res == 'heads' ? 1 : -1; 48 | expected_points = Math.max(expected_points, 0); 49 | } 50 | 51 | // A binomial(10, 1/2) has a P(x>2) ~ 0.98% 52 | t.true(counter['heads'] >= 2); 53 | 54 | const points: number = await contract.view('points_of', { 'player': root.accountId }); 55 | t.is(points, expected_points); 56 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coin Flip 🪙 2 | [![](https://img.shields.io/badge/⋈%20Examples-Basics-green)](https://docs.near.org/tutorials/welcome) 3 | [![](https://img.shields.io/badge/Gitpod-Ready-orange)](https://gitpod.io/#/https://github.com/near-examples/coin-flip-js) 4 | [![](https://img.shields.io/badge/Contract-js-yellow)](https://docs.near.org/develop/contracts/anatomy) 5 | [![](https://img.shields.io/badge/Frontend-JS-yellow)](https://docs.near.org/develop/integrate/frontend) 6 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fnear-examples%2Fcoin-flip-js%2Fbadge%3Fref%3Dmain&style=flat&label=Tests&ref=main)](https://actions-badge.atrox.dev/near-examples/coin-flip-js/goto?ref=main) 7 | 8 | Coin Flip is a game were the player tries to guess the outcome of a coin flip. It is one of the simplest contracts implementing random numbers. 9 | 10 | 11 | # What This Example Shows 12 | 13 | 1. How to store and retrieve information in the NEAR network. 14 | 2. How to integrate a smart contract in a web frontend. 15 | 3. How to generate random numbers in a contract. 16 | 17 |
18 | 19 | # Quickstart 20 | 21 | Clone this repository locally or [**open it in gitpod**](https://gitpod.io/#/https://github.com/near-examples/coin-flip-js). Then follow these steps: 22 | 23 | ### 1. Install Dependencies 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | ### 2. Test the Contract 29 | Deploy your contract in a sandbox and simulate interactions from users. 30 | 31 | ```bash 32 | npm test 33 | ``` 34 | 35 | ### 3. Deploy the Contract 36 | Build the contract and deploy it in a testnet account 37 | ```bash 38 | npm run deploy 39 | ``` 40 | 41 | ### 4. Start the Frontend 42 | Start the web application to interact with your smart contract 43 | ```bash 44 | npm start 45 | ``` 46 | 47 | --- 48 | 49 | # Learn More 50 | 1. Learn more about the contract through its [README](./contract/README.md). 51 | 2. Check [**our documentation**](https://docs.near.org/develop/welcome). 52 | -------------------------------------------------------------------------------- /contract/src/contract.ts: -------------------------------------------------------------------------------- 1 | import { NearBindgen, near, call, view, UnorderedMap } from 'near-sdk-js'; 2 | import { AccountId } from 'near-sdk-js/lib/types'; 3 | 4 | type Side = 'heads' | 'tails' 5 | 6 | function simulateCoinFlip(): Side { 7 | // randomSeed creates a random string, learn more about it in the README 8 | const randomString: string = near.randomSeed().toString(); 9 | 10 | // If the last charCode is even we choose heads, otherwise tails 11 | return randomString.charCodeAt(0) % 2 ? 'heads' : 'tails'; 12 | } 13 | 14 | 15 | @NearBindgen({}) 16 | class CoinFlip { 17 | points: UnorderedMap = new UnorderedMap("points"); 18 | 19 | /* 20 | Flip a coin. Pass in the side (heads or tails) and a random number will be chosen 21 | indicating whether the flip was heads or tails. If you got it right, you get a point. 22 | */ 23 | @call({}) 24 | flip_coin({ player_guess }: { player_guess: Side }): Side { 25 | // Check who called the method 26 | const player: AccountId = near.predecessorAccountId(); 27 | near.log(`${player} chose ${player_guess}`); 28 | 29 | // Simulate a Coin Flip 30 | const outcome = simulateCoinFlip(); 31 | 32 | // Get the current player points 33 | let player_points: number = this.points.get(player, { defaultValue: 0 }) 34 | 35 | // Check if their guess was right and modify the points accordingly 36 | if (player_guess == outcome) { 37 | near.log(`The result was ${outcome}, you get a point!`); 38 | player_points += 1; 39 | } else { 40 | near.log(`The result was ${outcome}, you lost a point`); 41 | player_points = player_points ? player_points - 1 : 0; 42 | } 43 | 44 | // Store the new points 45 | this.points.set(player, player_points) 46 | 47 | return outcome 48 | } 49 | 50 | // View how many points a specific player has 51 | @view({}) 52 | points_of({ player }: { player: AccountId }): number { 53 | const points = this.points.get(player, {defaultValue: 0}) 54 | near.log(`Points for ${player}: ${points}`) 55 | return points 56 | } 57 | } -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Welcome to NEAR 10 | 11 | 12 | 13 |
14 | 17 |
18 |
19 |

Welcome! Login to Play

20 |

Welcome !

21 | 22 |
23 |
24 | Coin's tail 25 |
26 |
27 | Coin's head 28 |
29 |
30 | 31 |
32 | 33 |

34 | 35 |

36 | 37 |
38 | 39 |
40 | 41 | 42 | 43 | 44 | 48 | 49 |

What do you think is coming next?

45 | 46 | 47 |
50 |

Status: Waiting user input

51 |

52 | Your points so far: 53 |

54 |
55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /frontend/cypress/coin-flip.cy.js: -------------------------------------------------------------------------------- 1 | Cypress.on('uncaught:exception', (err, runnable) => { 2 | return false; 3 | }) 4 | 5 | const SEED = Cypress.env('seed') 6 | 7 | context('coin flip example', () => { 8 | beforeEach(() => { 9 | cy.visit('/') 10 | }) 11 | 12 | it('test flow', () => { 13 | 14 | cy.get('button#sign-in-button').should('be.visible'); 15 | cy.contains('button', 'Sign in with NEAR Wallet').click(); 16 | cy.contains('div', 'MyNearWallet').click(); 17 | cy.contains('button', 'Import Existing Account').click(); 18 | cy.contains('button', 'Recover Account').click(); 19 | cy.get('input').type(SEED); 20 | cy.contains('button', 'Find My Account').click(); 21 | cy.contains('button', 'Next').click(); 22 | cy.contains('button', 'Connect').click(); 23 | cy.contains('button', 'Sign out').should('be.visible'); 24 | cy.wait(5000); 25 | cy.get('.points').then($points => { 26 | let currentPoints = Number($points.text()); 27 | for (let i = 0; i < 5; i ++) { 28 | cy.get('.points').then($points => { 29 | cy.wait(5000); 30 | const p = Number($points.text()); 31 | if (p !== currentPoints) { 32 | throw new Error(`(1) expected points: ${currentPoints} actual: ${p}`); 33 | } 34 | cy.contains('button', 'Tails').click(); 35 | cy.get('.status').should('contain.text', 'Status: Asking the contract to flip a coin'); 36 | cy.get('.status').should('not.contain.text', 'Status: Asking the contract to flip a coin'); 37 | cy.wait(5000); 38 | cy.get('.points').then($points => { 39 | const p = Number($points.text()); 40 | if (p !== currentPoints + 1 && p !== currentPoints - 1) { 41 | throw new Error(`(2) expected points: ${currentPoints}+-1 actual: ${p}`); 42 | } 43 | currentPoints = p; 44 | cy.contains('button', 'Heads').click(); 45 | cy.get('.status').should('contain.text', 'Status: Asking the contract to flip a coin'); 46 | cy.get('.status').should('not.contain.text', 'Status: Asking the contract to flip a coin'); 47 | cy.wait(5000); 48 | cy.get('.points').then($points => { 49 | const p = Number($points.text()); 50 | if (p !== currentPoints + 1 && p !== currentPoints - 1) { 51 | throw new Error(`(3) expected points: ${currentPoints}+-1 actual: ${p}`); 52 | } 53 | currentPoints = p; 54 | }) 55 | }) 56 | }) 57 | } 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /frontend/assets/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | --bg: #efefef; 7 | --fg: #1e1e1e; 8 | --gray: #555; 9 | --light-gray: #ccc; 10 | --shadow: #e6e6e6; 11 | --success: rgb(90, 206, 132); 12 | --primary: #FF585D; 13 | --secondary: #0072CE; 14 | 15 | background-color: var(--bg); 16 | color: var(--fg); 17 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; 18 | font-size: calc(0.9em + 0.5vw); 19 | line-height: 1.3; 20 | } 21 | 22 | body { 23 | margin: 0; 24 | padding: 1em; 25 | } 26 | 27 | main { 28 | margin: 0 auto; 29 | max-width: 26em; 30 | } 31 | 32 | h1 { 33 | background-image: url(./logo-black.svg); 34 | background-position: center 1em; 35 | background-repeat: no-repeat; 36 | background-size: auto 1.5em; 37 | margin-top: 0; 38 | padding: 3.5em 0 0; 39 | text-align: center; 40 | font-size: 1.5em; 41 | } 42 | 43 | h2 { 44 | text-align: center; 45 | } 46 | 47 | table { 48 | margin-top: 1em; 49 | } 50 | 51 | .status { 52 | font-size: small; 53 | font-family: monospace; 54 | text-align: center; 55 | } 56 | 57 | .please-wait .change { 58 | pointer-events: none; 59 | } 60 | 61 | 62 | a, 63 | .link { 64 | color: var(--primary); 65 | text-decoration: none; 66 | } 67 | 68 | a:hover, 69 | a:focus, 70 | .link:hover, 71 | .link:focus { 72 | text-decoration: underline; 73 | } 74 | 75 | a:active, 76 | .link:active { 77 | color: var(--secondary); 78 | } 79 | 80 | button, 81 | input { 82 | font: inherit; 83 | outline: none; 84 | } 85 | 86 | button { 87 | background-color: var(--secondary); 88 | border-radius: 5px; 89 | border: none; 90 | color: #efefef; 91 | cursor: pointer; 92 | padding: 0.3em 0.75em; 93 | transition: transform 30ms; 94 | } 95 | 96 | button:focus { 97 | border: 2px solid rgb(48, 188, 244); 98 | } 99 | 100 | button:hover { 101 | box-shadow: 0 0 10em rgba(255, 255, 255, 0.2) inset; 102 | } 103 | 104 | @media (prefers-color-scheme: dark) { 105 | html { 106 | --bg: #1e1e1e; 107 | --fg: #efefef; 108 | --gray: #aaa; 109 | --shadow: #2a2a2a; 110 | --light-gray: #444; 111 | } 112 | 113 | h1 { 114 | background-image: url(./logo-white.svg); 115 | } 116 | 117 | input:focus { 118 | box-shadow: 0 0 10em rgba(255, 255, 255, 0.02) inset; 119 | } 120 | } 121 | 122 | /* coin flip animations: https://rezabaharvand.dev/blog/coin-flip-javascript */ 123 | 124 | #coin { 125 | height: 150px; 126 | width: 150px; 127 | margin: 2em auto 0 auto; 128 | transform-style: preserve-3d; 129 | } 130 | 131 | #coin img { 132 | width: 150px; 133 | } 134 | 135 | .heads { 136 | transform: rotateY(180deg); 137 | } 138 | 139 | .tails, 140 | .heads { 141 | position: absolute; 142 | width: 100%; 143 | height: 100%; 144 | backface-visibility: hidden; 145 | } 146 | 147 | @keyframes flip-heads { 148 | 100% { 149 | transform: rotateY(540deg); 150 | } 151 | } 152 | 153 | @keyframes flip-tails { 154 | 100% { 155 | transform: rotateY(360deg); 156 | } 157 | } 158 | 159 | @keyframes flip { 160 | 0% { 161 | transform: rotateY(0); 162 | } 163 | 164 | 100% { 165 | transform: rotateY(2160deg); 166 | } 167 | } -------------------------------------------------------------------------------- /contract/README.md: -------------------------------------------------------------------------------- 1 | # Coin Flip Contract 2 | 3 | The smart contract implements a flip coin game in which the player tries to guess the next outcome. 4 | The player gets a point on each correct guess, and losses one for each wrong one. 5 | 6 | ```ts 7 | function simulateCoinFlip(): Side { 8 | const randomString: string = near.randomSeed(); 9 | return randomString.charCodeAt(0) % 2 ? 'heads' : 'tails'; 10 | } 11 | 12 | flip_coin({ player_guess }: { player_guess: Side }): Side { 13 | // Check who called the method 14 | const player = near.predecessorAccountId(); 15 | near.log(`${player} chose ${player_guess}`); 16 | 17 | // Simulate a Coin Flip 18 | const outcome = simulateCoinFlip(); 19 | 20 | // Get the current player points 21 | let player_points: number = (this.points.get(player) || 0) as number 22 | 23 | // Check if their guess was right and modify the points accordingly 24 | if(player_guess == outcome) { 25 | near.log(`The result was ${outcome}, you get a point!`); 26 | player_points += 1; 27 | } else { 28 | near.log(`The result was ${outcome}, you lost a point`); 29 | player_points = player_points? player_points - 1 : 0; 30 | } 31 | 32 | // Store the new points 33 | this.points.set(player, player_points) 34 | 35 | return outcome 36 | } 37 | ``` 38 | 39 |
40 | 41 | # Quickstart 42 | 43 | 1. Make sure you have installed [node.js](https://nodejs.org/en/download/package-manager/) >= 16. 44 | 2. Install the [`NEAR CLI`](https://github.com/near/near-cli#setup) 45 | 46 |
47 | 48 | ## 1. Build and Deploy the Contract 49 | You can automatically compile and deploy the contract in the NEAR testnet by running: 50 | 51 | ```bash 52 | npm run deploy 53 | ``` 54 | 55 | Once finished, check the `neardev/dev-account` file to find the address in which the contract was deployed: 56 | 57 | ```bash 58 | cat ./neardev/dev-account 59 | # e.g. dev-1659899566943-21539992274727 60 | ``` 61 | 62 |
63 | 64 | ## 2. Get the Score 65 | `points_of` performs read-only operations, therefore it is a `view` method. 66 | 67 | `View` methods can be called for **free** by anyone, even people **without a NEAR account**! 68 | 69 | ```bash 70 | # Use near-cli to get the points 71 | near view points_of '{"player": ""}' 72 | ``` 73 | 74 |
75 | 76 | ## 3. Flip a Coin and Try to Guess the Outcome 77 | `flip_coin` takes a guess ("heads" or "tails"), simulates a coin flip and gives/removes points to the player. 78 | 79 | It changes the contract's state, for which it is a `call` method. 80 | 81 | `Call` methods can only be invoked using a NEAR account, since the account needs to pay GAS for the transaction. 82 | 83 | ```bash 84 | # Use near-cli to play 85 | near call flip_coin '{"player_guess":"tails"}' --accountId 86 | ``` 87 | 88 | **Tip:** If you would like to call `flip_coin` using your own account, first login into NEAR using: 89 | 90 | ```bash 91 | # Use near-cli to login your NEAR account 92 | near login 93 | ``` 94 | 95 | and then use the logged account to sign the transaction: `--accountId `. 96 | 97 | ## A Note on Random Numbers 98 | Generating random numbers in an adversarial environment such as a blockchain is very difficult. This spawns from 99 | the fact that everything is public on the network. 100 | 101 | Please check our documentation to learn more about handling [random numbers in a blockchain](https://docs.near.org/develop/contracts/security/storage). -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | import {Wallet} from './near-wallet'; 3 | 4 | // When creating the wallet you can optionally ask to create an access key 5 | // Having the key enables to call non-payable methods without interrupting the user to sign 6 | const CONTRACT_ADDRESS = process.env.CONTRACT_NAME 7 | const wallet = new Wallet({ createAccessKeyFor: CONTRACT_ADDRESS }) 8 | 9 | // Setup on page load 10 | window.onload = async () => { 11 | let isSignedIn = await wallet.startUp(); 12 | 13 | if (isSignedIn) { 14 | signedInFlow(); 15 | } else { 16 | signedOutFlow(); 17 | } 18 | }; 19 | 20 | // Button clicks 21 | const $get = (e) => document.querySelector(e); 22 | $get('#sign-in-button').addEventListener('click', () => wallet.signIn()); 23 | $get('#sign-out-button').addEventListener('click', () => wallet.signOut()); 24 | $get('#choose-heads').addEventListener('click', () => player_choose('heads')); 25 | $get('#choose-tails').addEventListener('click', () => player_choose('tails')); 26 | 27 | // Executed when the player chooses a side 28 | async function player_choose (side) { 29 | reset_buttons() 30 | start_flip_animation() 31 | set_status("Asking the contract to flip a coin") 32 | 33 | // Call the smart contract asking to flip a coin 34 | let outcome = await wallet.callMethod({ 35 | contractId: CONTRACT_ADDRESS, 36 | method: 'flip_coin', 37 | args: { player_guess: side } 38 | }); 39 | 40 | // UI 41 | set_status(`The outcome was ${outcome}`) 42 | stop_flip_animation_in(outcome) 43 | 44 | if (outcome === side) { 45 | set_status("You were right, you win a point!") 46 | $get(`#choose-${side}`).style.backgroundColor = "green" 47 | } else { 48 | set_status("You were wrong, you lost a point") 49 | $get(`#choose-${side}`).style.backgroundColor = "red" 50 | } 51 | 52 | // Fetch the new score 53 | fetchScore(); 54 | } 55 | 56 | async function fetchScore () { 57 | console.log(wallet.accountId) 58 | const score = await wallet.viewMethod({ 59 | contractId: CONTRACT_ADDRESS, 60 | method: 'points_of', 61 | args: { player: wallet.accountId } 62 | }); 63 | 64 | document.querySelectorAll('[data-behavior=points]').forEach(el => { 65 | el.innerText = score; 66 | }); 67 | } 68 | 69 | // Display the signed-out-flow container 70 | function signedOutFlow () { 71 | document.querySelectorAll('#signed-in-flow').forEach(el => { 72 | el.style.display = 'none'; 73 | }); 74 | 75 | document.querySelectorAll('#signed-out-flow').forEach(el => { 76 | el.style.display = 'block'; 77 | }); 78 | 79 | // animate the coin 80 | $get('#coin').style.animation = "flip 3.5s ease 0.5s"; 81 | } 82 | 83 | // Displaying the signed in flow container and fill in account-specific data 84 | function signedInFlow () { 85 | document.querySelectorAll('#signed-in-flow').forEach(el => { 86 | el.style.display = 'block'; 87 | }); 88 | document.querySelectorAll('#signed-out-flow').forEach(el => { 89 | el.style.display = 'none'; 90 | }); 91 | document.querySelectorAll('[data-behavior=account-id]').forEach(el => { 92 | el.innerText = wallet.accountId; 93 | }); 94 | 95 | fetchScore() 96 | } 97 | 98 | // Aux methods to simplify handling the interface 99 | function set_status (message) { 100 | document.querySelectorAll('[data-behavior=status]').forEach(el => { 101 | el.innerText = message; 102 | }); 103 | } 104 | 105 | function reset_buttons () { 106 | $get(`#choose-heads`).style.backgroundColor = "var(--secondary)" 107 | $get(`#choose-tails`).style.backgroundColor = "var(--secondary)" 108 | } 109 | 110 | function start_flip_animation () { 111 | $get('#coin').style.animation = 'flip 2s linear 0s infinite'; 112 | } 113 | 114 | function stop_flip_animation_in (side) { 115 | $get('#coin').style.animation = `flip-${side} 1s linear 0s 1 forwards`; 116 | } 117 | -------------------------------------------------------------------------------- /frontend/near-wallet.js: -------------------------------------------------------------------------------- 1 | /* A helper file that simplifies using the wallet selector */ 2 | 3 | // near api js 4 | import { providers } from 'near-api-js'; 5 | 6 | // wallet selector UI 7 | import '@near-wallet-selector/modal-ui/styles.css'; 8 | import { setupModal } from '@near-wallet-selector/modal-ui'; 9 | import LedgerIconUrl from '@near-wallet-selector/ledger/assets/ledger-icon.png'; 10 | import MyNearIconUrl from '@near-wallet-selector/my-near-wallet/assets/my-near-wallet-icon.png'; 11 | 12 | // wallet selector options 13 | import { setupWalletSelector } from '@near-wallet-selector/core'; 14 | import { setupLedger } from '@near-wallet-selector/ledger'; 15 | import { setupMyNearWallet } from '@near-wallet-selector/my-near-wallet'; 16 | 17 | const THIRTY_TGAS = '30000000000000'; 18 | const NO_DEPOSIT = '0'; 19 | 20 | // Wallet that simplifies using the wallet selector 21 | export class Wallet { 22 | walletSelector; 23 | wallet; 24 | network; 25 | createAccessKeyFor; 26 | 27 | constructor({ createAccessKeyFor = undefined, network = 'testnet' }) { 28 | // Login to a wallet passing a contractId will create a local 29 | // key, so the user skips signing non-payable transactions. 30 | // Omitting the accountId will result in the user being 31 | // asked to sign all transactions. 32 | this.createAccessKeyFor = createAccessKeyFor 33 | this.network = network 34 | } 35 | 36 | // To be called when the website loads 37 | async startUp() { 38 | this.walletSelector = await setupWalletSelector({ 39 | network: this.network, 40 | modules: [setupMyNearWallet({ iconUrl: MyNearIconUrl }), 41 | setupLedger({ iconUrl: LedgerIconUrl })], 42 | }); 43 | 44 | const isSignedIn = this.walletSelector.isSignedIn(); 45 | 46 | if (isSignedIn) { 47 | this.wallet = await this.walletSelector.wallet(); 48 | this.accountId = this.walletSelector.store.getState().accounts[0].accountId; 49 | } 50 | 51 | return isSignedIn; 52 | } 53 | 54 | // Sign-in method 55 | signIn() { 56 | const description = 'Please select a wallet to sign in.'; 57 | const modal = setupModal(this.walletSelector, { contractId: this.createAccessKeyFor, description }); 58 | modal.show(); 59 | } 60 | 61 | // Sign-out method 62 | signOut() { 63 | this.wallet.signOut(); 64 | this.wallet = this.accountId = this.createAccessKeyFor = null; 65 | window.location.replace(window.location.origin + window.location.pathname); 66 | } 67 | 68 | // Make a read-only call to retrieve information from the network 69 | async viewMethod({ contractId, method, args = {} }) { 70 | const { network } = this.walletSelector.options; 71 | const provider = new providers.JsonRpcProvider({ url: network.nodeUrl }); 72 | 73 | let res = await provider.query({ 74 | request_type: 'call_function', 75 | account_id: contractId, 76 | method_name: method, 77 | args_base64: Buffer.from(JSON.stringify(args)).toString('base64'), 78 | finality: 'optimistic', 79 | }); 80 | return JSON.parse(Buffer.from(res.result).toString()); 81 | } 82 | 83 | // Call a method that changes the contract's state 84 | async callMethod({ contractId, method, args = {}, gas = THIRTY_TGAS, deposit = NO_DEPOSIT }) { 85 | // Sign a transaction with the "FunctionCall" action 86 | const outcome = await this.wallet.signAndSendTransaction({ 87 | signerId: this.accountId, 88 | receiverId: contractId, 89 | actions: [ 90 | { 91 | type: 'FunctionCall', 92 | params: { 93 | methodName: method, 94 | args, 95 | gas, 96 | deposit, 97 | }, 98 | }, 99 | ], 100 | }); 101 | 102 | return providers.getTransactionLastResult(outcome) 103 | } 104 | 105 | // Get transaction result from the network 106 | async getTransactionResult(txhash) { 107 | const { network } = this.walletSelector.options; 108 | const provider = new providers.JsonRpcProvider({ url: network.nodeUrl }); 109 | 110 | // Retrieve transaction result from the network 111 | const transaction = await provider.txStatus(txhash, 'unnused'); 112 | return providers.getTransactionLastResult(transaction); 113 | } 114 | } --------------------------------------------------------------------------------