├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .npmrc ├── .prettierrc.yaml ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── bpf-c-programs └── messagefeed │ ├── .gitignore │ ├── makefile │ └── src │ └── messagefeed │ └── messagefeed.c ├── bpf-rust-programs ├── messagefeed │ ├── .gitignore │ ├── Cargo.toml │ ├── Xargo.toml │ ├── do.sh │ └── src │ │ └── lib.rs └── prediction-poll │ ├── .gitignore │ ├── Cargo.toml │ ├── program │ ├── Cargo.toml │ ├── Xargo.toml │ ├── do.sh │ └── src │ │ ├── lib.rs │ │ ├── program │ │ ├── collection.rs │ │ ├── mod.rs │ │ ├── poll.rs │ │ └── tally.rs │ │ ├── result.rs │ │ └── util.rs │ ├── program_data │ ├── Cargo.toml │ └── src │ │ ├── clock.rs │ │ ├── collection.rs │ │ ├── command.rs │ │ ├── lib.rs │ │ ├── poll │ │ ├── data.rs │ │ ├── instruction.rs │ │ └── mod.rs │ │ └── tally.rs │ └── wasm_bindings │ ├── Cargo.toml │ └── src │ ├── clock.rs │ ├── collection.rs │ ├── command.rs │ ├── init_poll.rs │ ├── lib.rs │ ├── poll.rs │ └── tally.rs ├── flow-typed ├── bs58.js ├── buffer-layout.js ├── cbor.js ├── event-emitter.js ├── json-to-pretty-yaml.js ├── mkdirp-promise_vx.x.x.js ├── npm │ └── mz_vx.x.x.js └── readline-promise.js ├── package-lock.json ├── package.json ├── src ├── cli │ ├── bootstrap-poll.js │ ├── message-feed.js │ └── prediction-poll.js ├── client.js ├── programs │ ├── message-feed.js │ └── prediction-poll.js ├── server │ ├── bootstrap.js │ ├── index.js │ ├── message-feed.js │ └── prediction-poll.js ├── util │ ├── new-system-account-with-airdrop.js │ ├── publickey-to-name.js │ └── sleep.js └── webapp │ ├── api │ ├── clock.js │ ├── index.js │ ├── message-feed.js │ └── prediction-poll.js │ ├── bootstrap.js │ ├── components │ ├── create-poll.js │ ├── message-list.js │ ├── poll-grid.js │ ├── poll-option.js │ ├── poll.js │ └── toolbar.js │ └── webapp.js ├── static ├── img │ └── solana-logo-horizontal.svg └── index.html ├── urls.js ├── webpack.cli.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-flow", 5 | "@babel/preset-react" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/transform-runtime" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | wasm/ 3 | node_modules/ 4 | flow-typed/ 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // eslint-disable-line import/no-commonjs 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | plugins: ['react'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | 'plugin:react/recommended', 14 | 'plugin:prettier/recommended', 15 | ], 16 | parser: 'babel-eslint', 17 | parserOptions: { 18 | sourceType: 'module', 19 | ecmaVersion: 8, 20 | }, 21 | rules: { 22 | 'no-trailing-spaces': ['error'], 23 | 'import/first': ['error'], 24 | 'import/no-commonjs': ['error'], 25 | 'import/order': [ 26 | 'error', 27 | { 28 | groups: [ 29 | ['internal', 'external', 'builtin'], 30 | ['index', 'sibling', 'parent'], 31 | ], 32 | 'newlines-between': 'always', 33 | }, 34 | ], 35 | indent: [ 36 | 'error', 37 | 2, 38 | { 39 | MemberExpression: 1, 40 | SwitchCase: 1, 41 | }, 42 | ], 43 | 'linebreak-style': ['error', 'unix'], 44 | 'no-console': [0], 45 | quotes: [ 46 | 'error', 47 | 'single', 48 | {avoidEscape: true, allowTemplateLiterals: true}, 49 | ], 50 | 'prettier/prettier': 'error', 51 | 'require-await': ['error'], 52 | semi: ['error', 'always'], 53 | }, 54 | settings: { 55 | react: { 56 | version: 'detect', 57 | }, 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | /node_modules/* 3 | 4 | [include] 5 | 6 | [libs] 7 | node_modules/@solana/web3.js/module.flow.js 8 | flow-typed/ 9 | 10 | [options] 11 | 12 | emoji=true 13 | esproposal.class_instance_fields=enable 14 | esproposal.class_static_fields=enable 15 | esproposal.decorators=ignore 16 | esproposal.export_star_as=enable 17 | module.system.node.resolve_dirname=./src 18 | module.use_strict=true 19 | experimental.const_params=true 20 | include_warnings=true 21 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 22 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.sw[po] 3 | /dist 4 | /.cargo 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | arrowParens: "avoid" 2 | bracketSpacing: false 3 | jsxBracketSameLine: false 4 | semi: true 5 | singleQuote: true 6 | tabWidth: 2 7 | trailingComma: "all" 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | sudo: required 3 | language: rust 4 | services: 5 | - docker 6 | cache: 7 | cargo: true 8 | directories: 9 | - "~/.npm" 10 | notifications: 11 | email: false 12 | 13 | install: 14 | - cargo --version 15 | - docker --version 16 | - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - 17 | - sudo apt-add-repository "deb http://apt.llvm.org/bionic/ llvm-toolchain-bionic-10 main" 18 | - sudo apt-get update 19 | - sudo apt-get install -y clang-7 --allow-unauthenticated 20 | - sudo apt-get install -y openssl --allow-unauthenticated 21 | - sudo apt-get install -y libssl-dev --allow-unauthenticated 22 | - sudo apt-get install -y libssl1.1 --allow-unauthenticated 23 | - clang-7 --version 24 | - curl https://sh.rustup.rs -sSf | sh -s -- -y 25 | - cargo install wasm-pack --force 26 | - PATH=$HOME/.cargo/bin:$PATH 27 | - rustup --version 28 | - nvm install node 29 | - node --version 30 | - npm install 31 | 32 | script: 33 | - npm run build:bpf-c 34 | - npm run build:bpf-rust 35 | - npm run test 36 | 37 | before_deploy: 38 | - git add -f dist/programs/*.so 39 | - git commit -m 'deploy programs' 40 | 41 | deploy: 42 | - provider: heroku 43 | api_key: 44 | secure: C7feMlvjO8YFZoJr2u+qdqsMOCbFSFuFnDhztqoksL2tNsqAF4uHUG3O589hQUlG0hsn3AKvR1fdIT+3EU2HAUJgkJ20snywBEUCK9kPcePqQJpsOzBpIahhT7PE2iVrG0lbrvGFwQ3ET+5yYwwZb74iZfMm7SlgusNnvumqo9TAdJdNvPTifsv+1f888OCpgJTG06nnhjA4nUTpglDqb5KiQBuATyk18eBoDi4t8BcOckrPMOZQn9noL3JZ1nQTxb/Rhyw7y0kwxFSQ4hOGWOr2i3VqlZ+jqsErFs7hUXuM2TtgEnBTT8ablDqbi4KXbU9VfHLMMEP0aIi4LE+iWSTlGZ/h328BkUO47VI3F91BHRJ4pYHczaH+E5nuVQuxS7W1oJRba6UUqsEG5C8jqegdyaPF2ruMgNIFi8aLHa3KtAQepCj6JRDX3bK7hapu741sqlzMuKXhFFyZipSS+tzXglNjHOPsZPRxJJi9hIqRswlU1TTQk1SZZ/o/9/x+A2PmhXAOYHbaLEeMKz/Hzk6wlMKf+6a4aUkwbmFGnZwuDkW+Ad/1bfx2CXWY9bx5p75qFDgLo7kToW7/PGS1X8lHH+UY6DuHJTmNnT1e5nXkCkz93dYt3IDEyx0dX4Ee57Bedep0RAdzFmyLybpfMlv7Y8IUQ61X7JAn1E30mos= 45 | app: solana-example-messagefeed 46 | strategy: git 47 | on: 48 | repo: solana-labs/example-messagefeed 49 | branch: v1.1 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Solana Labs, Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status][travis-image]][travis-url] 2 | 3 | [travis-image]: https://travis-ci.org/solana-labs/example-messagefeed.svg?branch=v1.1 4 | [travis-url]: https://travis-ci.org/solana-labs/example-messagefeed 5 | 6 | # Solana Feed 7 | 8 | This project demonstrates how to use the [Solana Javascript API](https://github.com/solana-labs/solana-web3.js) 9 | to build, deploy, and interact with programs on the Solana blockchain, 10 | implementing a simple feed of messages and prediction polls. 11 | To see it running go to https://solana-example-messagefeed.herokuapp.com/ 12 | 13 | ## Table of Contents 14 | - [Solana Feed](#solana-feed) 15 | - [Table of Contents](#table-of-contents) 16 | - [Overview](#overview) 17 | - [Message Feed](#message-feed) 18 | - [User Login](#user-login) 19 | - [Posting a message](#posting-a-message) 20 | - [Banning a user](#banning-a-user) 21 | - [Learn about Solana](#learn-about-solana) 22 | - [Prediction Polls](#prediction-polls) 23 | - [Creating a poll](#creating-a-poll) 24 | - [Voting](#voting) 25 | - [Claim winnings](#claim-winnings) 26 | - [Limitations](#limitations) 27 | - [Getting Started](#getting-started) 28 | - [Select a Network](#select-a-network) 29 | - [Build the BPF program](#build-the-bpf-program) 30 | - [Start the web server](#start-the-web-server) 31 | - [Run the Command-Line Front End](#run-the-command-line-front-end) 32 | - [Run the WebApp Front End](#run-the-webapp-front-end) 33 | 34 | 35 | ## Overview 36 | 37 | This project uses two Solana programs and a Node server to power a single webapp. 38 | The [Message Feed](#message-feed) program allows users to post messages and ban 39 | each other for bad behavior. The [Prediction Poll](#prediction-polls) program 40 | allows users to create wager-able polls that reward the winning side. 41 | 42 | ## Message Feed 43 | 44 | Messages are represented as a singly-linked list of Solana accounts. 45 | 46 | Each Message account contains the message text, public key of the next message, 47 | and the public key of the User Account who posted it. 48 | 49 | To post a new message, a User Account is required. The only way to obtain a 50 | User Account is to present credentials to the Https Server that created the 51 | first message in the chain. 52 | 53 | A User Account contains a bit which indicates if they have been banned by another user. 54 | 55 | ### User Login 56 | Only the mechanism to obtain User Accounts is centralized. This will ensure 57 | each User Account is associated with a real person using whatever user 58 | authentication system (such as Google) is preferred. The only requirement on 59 | the authentication system is that that each authenticated user have a unique 60 | user id. 61 | 62 | 1. Https Server loads the Message Feed program on Solana and posts the first message 63 | 1. New User authenticates against Google and receives a JWT 64 | 1. New User sends JWT to Https Server 65 | 1. Https Server verifies the JWT contents and extracts their unique user id 66 | 1. Https Server creates a User Account on behalf of New User if not already created 67 | 1. Https Server returns the private key for the User Account to the New User, and the public key of the first message 68 | 69 | Note: presently the JWT workflow is not fully implemented. No technical reason, just work that hasn't been done yet :) 70 | 71 | ### Posting a message 72 | 1. With the public key of the first message and a User Account, the user uses 73 | the RPC APIs of a Solana fullnode to fetch the latest message in the chain. 74 | 1. The user constructs a new Transaction with the user message, the public key 75 | of their User Account and the public key of the latest message. The 76 | Transaction is signed with their User Account private key, then submitted to 77 | the Solana cluster. 78 | 1. The Message Feed program processes the Transaction on-chain, confirms the user is not 79 | banned and links the new message to the chain. 80 | 81 | ### Banning a user 82 | Any user can construct a Transaction that bans any other user. All messages 83 | posted by a user contains the public key of their User Account, so it's easy to 84 | identify the origin of each post. 85 | 86 | 1. The ban Transaction includes the public key of the User Account to ban, the 87 | public key of the banning user's User Account, and a message to include with 88 | the ban. The ban Transaction is signed with the banning user's User Account 89 | private key, then submitted to the Solana cluster. 90 | 1. The Message Feed program processes the Transaction on chain, confirms the banning user 91 | is not also banned, and then sets the banned bit on the target user. 92 | 93 | ## Learn about Solana 94 | 95 | More information about how Solana works is available in the [Book](https://docs.solana.com/book/) 96 | 97 | ## Prediction Polls 98 | 99 | Polls propose a question and 2 option to choose from. They allow users to wager 100 | tokens on which answer will be the most popular. The winning side gets to split 101 | up the losers' wagers! 102 | 103 | Polls are stored in a single Collection account on Solana. The Collection account 104 | contains a list of public keys for the Poll accounts. 105 | 106 | In addition to the display text, each poll also has an expiration block height 107 | and 2 tally keys for tracking wagers. 108 | 109 | Tally Accounts record wagers for a particular poll option. When the poll 110 | expires, they are used to distribute winnings 111 | 112 | ### Creating a poll 113 | To create a new poll, a User Account is required. Similar to posting messages, 114 | the user account is retrieved from the server. 115 | 116 | 1. The user signs in and fetches the prediction poll program id and the current 117 | collection key. 118 | 1. The user inputs the poll header and options as well as a block timeout which 119 | will be added to the current block height to compute the poll expiration. 120 | 1. A Transaction is constructed with instructions for creating the poll account 121 | and 2 tally accounts and an instruction for initializing the poll with the text 122 | and timeout. 123 | 1. Solana then creates the accounts and the prediction poll program processes 124 | the poll initialization instruction to set the poll account data. 125 | 126 | ### Voting 127 | Voting on a poll involves a token wager which will be transferred to the poll 128 | account and recorded in the poll account data. 129 | 130 | 1. A user selects a poll option and chooses an appropriate token wager 131 | 1. A Transaction is constructed with instructions to create a one-off account 132 | with a balance equal to the token wager and submit a vote. 133 | 1. The prediction poll program then drains the one-off account balance and 134 | records the wager in the poll account and the selected option tally account. 135 | 136 | ### Claim winnings 137 | Once the poll expires, anyone can trigger the distribution of the winnings. 138 | 139 | 1. Transaction is created which references all of the winning wager keys in a 140 | claim instruction. 141 | 1. The prediction poll program verifies that the poll has expired and then 142 | drains the poll account balance and proportionally distributes the tokens to the 143 | winners according to their wagers. 144 | 145 | ### Limitations 146 | - The number of polls in a collection is limited to the size of the Collection 147 | account data 148 | - The number of participants in a tally are limited by the size of the Tally 149 | account data as well as the maximum size of a transaction. Serialized 150 | transactions must fit inside the MTU size of 1280 bytes. 151 | 152 | ## Getting Started 153 | 154 | The following dependencies are required to build and run this example, 155 | depending on your OS they may already be installed: 156 | 157 | ```sh 158 | $ npm --version 159 | $ docker -v 160 | $ wget --version 161 | $ rustc --version 162 | ``` 163 | 164 | Next fetch the npm dependencies, including `@solana/web3.js`, by running: 165 | ```sh 166 | $ npm install 167 | ``` 168 | 169 | ### Select a Network 170 | The example connects to a local Solana cluster by default. 171 | 172 | To enable on-chain program logs, set the `RUST_LOG` environment variable: 173 | ```sh 174 | $ export RUST_LOG=solana_runtime::system_instruction_processor=trace,solana_runtime::message_processor=info,solana_bpf_loader=debug,solana_rbpf=debug 175 | ``` 176 | 177 | To start a local Solana cluster run: 178 | ```sh 179 | $ npm run localnet:update 180 | $ npm run localnet:up 181 | ``` 182 | 183 | Solana cluster logs are available with: 184 | ```sh 185 | $ npm run localnet:logs 186 | ``` 187 | 188 | To stop the local solana cluster run: 189 | ```sh 190 | $ npm run localnet:down 191 | ``` 192 | 193 | For more details on working with a local cluster, see the [full instructions](https://github.com/solana-labs/solana-web3.js#local-network). 194 | 195 | ### Build the BPF program 196 | The prediction poll program is only written in Rust. The build command will 197 | produce a BPF ELF shared object called `dist/programs/prediction_poll.so`. 198 | 199 | ```sh 200 | $ npm run build:bpf-rust 201 | ``` 202 | or 203 | ```sh 204 | $ npm run build:bpf-c 205 | ``` 206 | 207 | ### Start the web server 208 | The message feed and prediction poll programs are deployed by the web server at `src/server/index.js`, 209 | so start it first: 210 | ```sh 211 | $ npm run dev-server 212 | ``` 213 | 214 | ### Run the Command-Line Front End 215 | After building the program and starting the web server, you can view the current 216 | message feed by running 217 | 218 | ```sh 219 | $ npm run message-cli 220 | ``` 221 | 222 | and post a new message with: 223 | ```sh 224 | $ npm run message-cli -- "This is a message" 225 | ``` 226 | 227 | and can create a test poll by running: 228 | ```sh 229 | $ npm run poll-cli 230 | ``` 231 | 232 | ### Run the WebApp Front End 233 | After building the program and starting the web server, start the webapp 234 | locally by running: 235 | ```sh 236 | $ npm run dev 237 | ``` 238 | then go to http://localhost:8080/ in your browser. 239 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": [ 3 | 4 | ], 5 | "buildpacks": [ 6 | { 7 | "url": "heroku/nodejs" 8 | } 9 | ], 10 | "env": { 11 | "CLUSTER": { 12 | "required": true 13 | }, 14 | "LIVE": { 15 | "required": true 16 | } 17 | }, 18 | "formation": { 19 | "web": { 20 | "quantity": 1 21 | } 22 | }, 23 | "name": "example-messagefeed", 24 | "scripts": { 25 | }, 26 | "stack": "heroku-18" 27 | } 28 | -------------------------------------------------------------------------------- /bpf-c-programs/messagefeed/.gitignore: -------------------------------------------------------------------------------- 1 | /out/ 2 | -------------------------------------------------------------------------------- /bpf-c-programs/messagefeed/makefile: -------------------------------------------------------------------------------- 1 | OUT_DIR := ../../dist/programs 2 | include ../../node_modules/@solana/web3.js/bpf-sdk/c/bpf.mk 3 | 4 | all: 5 | rm -f ../../dist/config.json 6 | -------------------------------------------------------------------------------- /bpf-c-programs/messagefeed/src/messagefeed/messagefeed.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #define FAILURE 1 4 | 5 | typedef struct { 6 | uint8_t banned; 7 | SolPubkey creator; // Who created this account 8 | } UserAccountData; 9 | 10 | typedef struct { 11 | SolPubkey next_message; // Next message in the feed 12 | SolPubkey from; // The UserAccountData that posted this message 13 | SolPubkey creator; // Who created this feed 14 | uint8_t text[0]; 15 | } MessageAccountData; 16 | 17 | SOL_FN_PREFIX bool deserialize_user_account_data(SolAccountInfo *ka, UserAccountData **data) { 18 | if (ka->data_len != sizeof(UserAccountData)) { 19 | sol_log("Error: invalid user account data_len"); 20 | sol_log_64(ka->data_len, sizeof(UserAccountData), 0, 0, 0); 21 | return false; 22 | } 23 | *data = (UserAccountData *) ka->data; 24 | return true; 25 | } 26 | 27 | SOL_FN_PREFIX bool deserialize_message_account_data(SolAccountInfo *ka, MessageAccountData **data) { 28 | if (ka->data_len < sizeof(MessageAccountData)) { 29 | sol_log("Error: invalid message account data_len"); 30 | sol_log_64(ka->data_len, sizeof(MessageAccountData), 0, 0, 0); 31 | return false; 32 | } 33 | *data = (MessageAccountData *) ka->data; 34 | return true; 35 | } 36 | 37 | SOL_FN_PREFIX bool SolPubkey_default(const SolPubkey *pubkey) { 38 | for (int i = 0; i < sizeof(*pubkey); i++) { 39 | if (pubkey->x[i]) { 40 | return false; 41 | } 42 | } 43 | return true; 44 | } 45 | 46 | extern uint64_t entrypoint(const uint8_t *input) { 47 | SolAccountInfo ka[5]; 48 | SolParameters params = (SolParameters) { .ka = ka }; 49 | 50 | sol_log("message feed entrypoint"); 51 | 52 | if (!sol_deserialize(input, ¶ms, SOL_ARRAY_SIZE(ka))) { 53 | sol_log("Error: deserialize failed"); 54 | return FAILURE; 55 | } 56 | 57 | if (!params.ka[0].is_signer) { 58 | sol_log("Error: not signed by key 0"); 59 | return FAILURE; 60 | } 61 | if (!params.ka[1].is_signer) { 62 | sol_log("Error: not signed by key 1"); 63 | return FAILURE; 64 | } 65 | 66 | UserAccountData *user_data = NULL; 67 | if (!deserialize_user_account_data(¶ms.ka[0], &user_data)) { 68 | sol_log("Error: unable to deserialize account 0 state"); 69 | return FAILURE; 70 | } 71 | 72 | if (user_data->banned) { 73 | sol_log("Error: user is banned"); 74 | return FAILURE; 75 | } 76 | 77 | MessageAccountData *new_message_data = NULL; 78 | if (!deserialize_message_account_data(¶ms.ka[1], &new_message_data)) { 79 | sol_log("Error: unable to deserialize account 1 state"); 80 | return FAILURE; 81 | } 82 | 83 | // No instruction data means that a new user account should be initialized 84 | if (params.data_len == 0) { 85 | sol_memcpy(&user_data->creator, params.ka[1].key, sizeof(SolPubkey)); 86 | return SUCCESS; 87 | } 88 | 89 | // Write the message text into new_message_data 90 | sol_memcpy(new_message_data->text, params.data, params.data_len); 91 | 92 | // Save the pubkey of who posted the message 93 | sol_memcpy(&new_message_data->from, params.ka[0].key, sizeof(SolPubkey)); 94 | 95 | if (params.ka_num > 2) { 96 | MessageAccountData *existing_message_data = NULL; 97 | if (!deserialize_message_account_data(¶ms.ka[2], &existing_message_data)) { 98 | sol_log("Error: unable to deserialize account 1 state"); 99 | return FAILURE; 100 | } 101 | 102 | if (!SolPubkey_default(&existing_message_data->next_message)) { 103 | sol_log("Error: account 1 already has a next_message"); 104 | return FAILURE; 105 | } 106 | 107 | // Link the new_message to the existing_message 108 | sol_memcpy(&existing_message_data->next_message, 109 | params.ka[1].key, 110 | sizeof(SolPubkey) 111 | ); 112 | 113 | // Propagate the chain creator to the new message 114 | sol_memcpy(&new_message_data->creator, &existing_message_data->creator, sizeof(SolPubkey)); 115 | } else { 116 | // This is the first message in the chain, it is the "creator" 117 | sol_memcpy(&new_message_data->creator, params.ka[1].key, sizeof(SolPubkey)); 118 | } 119 | 120 | if (!SolPubkey_same(&user_data->creator, &new_message_data->creator)) { 121 | sol_log("user_data/new_message_data creator mismatch"); 122 | return FAILURE; 123 | } 124 | 125 | // Check if a user should be banned 126 | if (params.ka_num > 3) { 127 | UserAccountData *ban_user_data = NULL; 128 | if (!deserialize_user_account_data(¶ms.ka[3], &ban_user_data)) { 129 | sol_log("Error: unable to deserialize account 3 state"); 130 | return FAILURE; 131 | } 132 | 133 | ban_user_data->banned = true; 134 | } 135 | 136 | return SUCCESS; 137 | } 138 | -------------------------------------------------------------------------------- /bpf-rust-programs/messagefeed/.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /bpf-rust-programs/messagefeed/Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | # Note: This crate must be built using build.sh 3 | 4 | [package] 5 | name = "messagefeed" 6 | version = "0.16.0" 7 | description = "Messagefeed program written in Rust" 8 | authors = ["Solana Maintainers "] 9 | repository = "https://github.com/solana-labs/solana" 10 | license = "Apache-2.0" 11 | homepage = "https://solana.com/" 12 | edition = "2018" 13 | 14 | [dependencies] 15 | arrayref = "0.3.5" 16 | num-derive = "0.2" 17 | num-traits = "0.2" 18 | solana-sdk = { version = "=1.1.1", default-features = false } 19 | thiserror = "1.0" 20 | 21 | [features] 22 | program = ["solana-sdk/program"] 23 | default = ["program"] 24 | 25 | [workspace] 26 | members = [] 27 | 28 | [lib] 29 | name = "messagefeed" 30 | crate-type = ["cdylib"] 31 | -------------------------------------------------------------------------------- /bpf-rust-programs/messagefeed/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] -------------------------------------------------------------------------------- /bpf-rust-programs/messagefeed/do.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | usage() { 6 | cat < 9 | 10 | If relative_project_path is ommitted then action will 11 | be performed on all projects 12 | 13 | Supported actions: 14 | build 15 | clean 16 | test 17 | clippy 18 | fmt 19 | 20 | EOF 21 | } 22 | 23 | sdkDir=../../node_modules/@solana/web3.js/bpf-sdk 24 | targetDir="$PWD"/target 25 | distDir=../../dist/programs 26 | profile=bpfel-unknown-unknown/release 27 | 28 | perform_action() { 29 | set -e 30 | case "$1" in 31 | build) 32 | "$sdkDir"/rust/build.sh "$PWD" 33 | 34 | so_path="$targetDir/$profile" 35 | so_name="messagefeed" 36 | if [ -f "$so_path/${so_name}.so" ]; then 37 | cp "$so_path/${so_name}.so" "$so_path/${so_name}_debug.so" 38 | "$sdkDir"/dependencies/llvm-native/bin/llvm-objcopy --strip-all "$so_path/${so_name}.so" "$so_path/$so_name.so" 39 | fi 40 | 41 | mkdir -p $distDir 42 | cp "$so_path/${so_name}.so" $distDir 43 | ;; 44 | clean) 45 | "$sdkDir"/rust/clean.sh "$PWD" 46 | ;; 47 | test) 48 | echo "test" 49 | cargo +nightly test 50 | ;; 51 | clippy) 52 | echo "clippy" 53 | cargo +nightly clippy 54 | ;; 55 | fmt) 56 | echo "formatting" 57 | cargo fmt 58 | ;; 59 | help) 60 | usage 61 | exit 62 | ;; 63 | *) 64 | echo "Error: Unknown command" 65 | usage 66 | exit 67 | ;; 68 | esac 69 | } 70 | 71 | set -e 72 | 73 | perform_action "$1" 74 | -------------------------------------------------------------------------------- /bpf-rust-programs/messagefeed/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! @brief Example message feed app 2 | 3 | use arrayref::array_mut_ref; 4 | use num_derive::FromPrimitive; 5 | use solana_sdk::{ 6 | account_info::AccountInfo, 7 | entrypoint, 8 | entrypoint::ProgramResult, 9 | info, 10 | program_error::ProgramError, 11 | program_utils::{next_account_info, DecodeError}, 12 | pubkey::Pubkey, 13 | }; 14 | use std::mem::size_of; 15 | use thiserror::Error; 16 | 17 | #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] 18 | pub enum MessageFeedError { 19 | #[error("User is banned")] 20 | BannedUser, 21 | #[error("Next message already exists")] 22 | NextMessageExists, 23 | #[error("Creator mismatch")] 24 | CreatorMismatch, 25 | } 26 | impl From for ProgramError { 27 | fn from(e: MessageFeedError) -> Self { 28 | ProgramError::CustomError(e as u32) 29 | } 30 | } 31 | impl DecodeError for MessageFeedError { 32 | fn type_of() -> &'static str { 33 | "MessageFeedError" 34 | } 35 | } 36 | 37 | type PubkeyData = [u8; 32]; 38 | 39 | struct UserAccountData<'a> { 40 | pub banned: &'a mut bool, 41 | pub creator: &'a mut PubkeyData, 42 | } 43 | impl<'a> UserAccountData<'a> { 44 | fn new(data: &'a mut [u8]) -> Self { 45 | let (banned, creator) = data.split_at_mut(1); 46 | Self { 47 | banned: unsafe { &mut *(&mut banned[0] as *mut u8 as *mut bool) }, 48 | creator: array_mut_ref!(creator, 0, size_of::()), 49 | } 50 | } 51 | } 52 | 53 | struct MessageAccountData<'a> { 54 | pub next_message: &'a mut PubkeyData, 55 | pub from: &'a mut PubkeyData, 56 | pub creator: &'a mut PubkeyData, 57 | pub text: &'a mut [u8], 58 | } 59 | impl<'a> MessageAccountData<'a> { 60 | fn new(data: &'a mut [u8]) -> Self { 61 | let (next_message, rest) = data.split_at_mut(size_of::()); 62 | let (from, rest) = rest.split_at_mut(size_of::()); 63 | let (creator, text) = rest.split_at_mut(size_of::()); 64 | Self { 65 | next_message: array_mut_ref!(next_message, 0, size_of::()), 66 | from: array_mut_ref!(from, 0, size_of::()), 67 | creator: array_mut_ref!(creator, 0, size_of::()), 68 | text, 69 | } 70 | } 71 | } 72 | 73 | entrypoint!(process_instruction); 74 | fn process_instruction( 75 | _program_id: &Pubkey, 76 | accounts: &[AccountInfo], 77 | instruction_data: &[u8], 78 | ) -> ProgramResult { 79 | info!("message feed entrypoint"); 80 | 81 | let account_info_iter = &mut accounts.iter(); 82 | 83 | let user_account = next_account_info(account_info_iter)?; 84 | let mut user_data = user_account.data.borrow_mut(); 85 | let user_data = UserAccountData::new(&mut user_data); 86 | 87 | let message_account = next_account_info(account_info_iter)?; 88 | let mut new_message_data = message_account.data.borrow_mut(); 89 | let new_message_data = MessageAccountData::new(&mut new_message_data); 90 | 91 | if !user_account.is_signer { 92 | info!("Error: not signed by key 0"); 93 | return Err(ProgramError::MissingRequiredSignature); 94 | } 95 | if !message_account.is_signer { 96 | info!("Error: not signed by key 1"); 97 | return Err(ProgramError::MissingRequiredSignature); 98 | } 99 | 100 | if *user_data.banned { 101 | info!("Error: user is banned"); 102 | return Err(MessageFeedError::BannedUser.into()); 103 | } 104 | 105 | // No instruction data means that a new user account should be initialized 106 | if instruction_data.is_empty() { 107 | user_data 108 | .creator 109 | .clone_from_slice(message_account.key.as_ref()); 110 | } else { 111 | // Write the message text into new_message_data 112 | new_message_data.text.clone_from_slice(instruction_data); 113 | 114 | // Save the pubkey of who posted the message 115 | new_message_data 116 | .from 117 | .clone_from_slice(user_account.key.as_ref()); 118 | 119 | if let Ok(existing_message_account) = next_account_info(account_info_iter) { 120 | let mut existing_message_data = existing_message_account.data.borrow_mut(); 121 | let existing_message_data = MessageAccountData::new(&mut existing_message_data); 122 | 123 | if existing_message_data.next_message != &[0; size_of::()] { 124 | info!("Error: account 1 already has a next_message"); 125 | return Err(MessageFeedError::NextMessageExists.into()); 126 | } 127 | 128 | // Link the new_message to the existing_message 129 | existing_message_data 130 | .next_message 131 | .clone_from_slice(message_account.key.as_ref()); 132 | 133 | // Check if a user should be banned 134 | if let Ok(ban_user_account) = next_account_info(account_info_iter) { 135 | let mut ban_user_data = ban_user_account.data.borrow_mut(); 136 | let ban_user_data = UserAccountData::new(&mut ban_user_data); 137 | *ban_user_data.banned = true; 138 | } 139 | 140 | // Propagate the chain creator to the new message 141 | new_message_data 142 | .creator 143 | .clone_from_slice(existing_message_data.creator.as_ref()); 144 | } else { 145 | // This is the first message in the chain, it is the "creator" 146 | new_message_data 147 | .creator 148 | .clone_from_slice(message_account.key.as_ref()); 149 | } 150 | 151 | if user_data.creator != new_message_data.creator { 152 | info!("user_data/new_message_data creator mismatch"); 153 | return Err(MessageFeedError::CreatorMismatch.into()); 154 | } 155 | } 156 | 157 | info!("Success"); 158 | Ok(()) 159 | } 160 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["program", "program_data", "wasm_bindings"] 3 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | # Note: This crate must be built using build.sh 3 | 4 | [package] 5 | name = "prediction-poll" 6 | version = "0.16.0" 7 | description = "Poll feed program written in Rust" 8 | authors = ["Solana Maintainers "] 9 | repository = "https://github.com/solana-labs/solana" 10 | license = "Apache-2.0" 11 | homepage = "https://solana.com/" 12 | edition = "2018" 13 | 14 | [dependencies] 15 | num-derive = "0.2" 16 | num-traits = "0.2" 17 | prediction-poll-data = { path = "../program_data" } 18 | solana-sdk = { version = "=1.1.1", default-features = false } 19 | solana-sdk-bpf-test = { path = "../../../node_modules/@solana/web3.js/bpf-sdk/rust/test" } 20 | thiserror = "1.0" 21 | 22 | [features] 23 | program = ["solana-sdk/program"] 24 | default = ["program"] 25 | 26 | [lib] 27 | name = "prediction_poll" 28 | crate-type = ["cdylib"] 29 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/do.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | usage() { 6 | cat < 9 | 10 | If relative_project_path is ommitted then action will 11 | be performed on all projects 12 | 13 | Supported actions: 14 | build 15 | clean 16 | test 17 | clippy 18 | fmt 19 | 20 | EOF 21 | } 22 | 23 | sdkDir=../../../node_modules/@solana/web3.js/bpf-sdk 24 | targetDir="$PWD"/../target 25 | distDir=../../../dist/programs 26 | profile=bpfel-unknown-unknown/release 27 | 28 | perform_action() { 29 | set -e 30 | case "$1" in 31 | build) 32 | "$sdkDir"/rust/build.sh "$PWD" 33 | 34 | so_path="$targetDir/$profile" 35 | so_name="prediction_poll" 36 | if [ -f "$so_path/${so_name}.so" ]; then 37 | cp "$so_path/${so_name}.so" "$so_path/${so_name}_debug.so" 38 | "$sdkDir"/dependencies/llvm-native/bin/llvm-objcopy --strip-all "$so_path/${so_name}.so" "$so_path/$so_name.so" 39 | fi 40 | 41 | mkdir -p $distDir 42 | cp "$so_path/${so_name}.so" $distDir 43 | ;; 44 | clean) 45 | "$sdkDir"/rust/clean.sh "$PWD" 46 | ;; 47 | test) 48 | echo "test" 49 | cargo +nightly test 50 | ;; 51 | clippy) 52 | echo "clippy" 53 | cargo +nightly clippy 54 | ;; 55 | fmt) 56 | echo "formatting" 57 | cargo fmt 58 | ;; 59 | help) 60 | usage 61 | exit 62 | ;; 63 | *) 64 | echo "Error: Unknown command" 65 | usage 66 | exit 67 | ;; 68 | esac 69 | } 70 | 71 | set -e 72 | 73 | perform_action "$1" 74 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! @brief Example prediction poll app 2 | 3 | extern crate alloc; 4 | extern crate solana_sdk; 5 | 6 | mod program; 7 | mod result; 8 | mod util; 9 | 10 | use program::process_instruction; 11 | use result::PollError; 12 | use solana_sdk::{ 13 | account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, 14 | program_error::PrintProgramError, pubkey::Pubkey, 15 | }; 16 | 17 | entrypoint!(_entrypoint); 18 | fn _entrypoint( 19 | program_id: &Pubkey, 20 | accounts: &[AccountInfo], 21 | instruction_data: &[u8], 22 | ) -> ProgramResult { 23 | if let Err(error) = process_instruction(program_id, accounts, instruction_data) { 24 | // catch the error so we can print it 25 | error.print::(); 26 | return Err(error); 27 | } 28 | Ok(()) 29 | } 30 | 31 | // Pulls in the stubs required for `info!()` 32 | #[cfg(not(target_arch = "bpf"))] 33 | solana_sdk_bpf_test::stubs!(); 34 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/program/collection.rs: -------------------------------------------------------------------------------- 1 | use crate::result::PollError; 2 | use prediction_poll_data::CollectionData; 3 | use solana_sdk::{entrypoint::ProgramResult, pubkey::Pubkey}; 4 | 5 | pub fn add_poll(collection: &mut CollectionData, poll_pubkey: &Pubkey) -> ProgramResult { 6 | if collection.len() >= collection.capacity() { 7 | Err(PollError::MaxPollCapacity.into()) 8 | } else if collection.contains(poll_pubkey) { 9 | Err(PollError::PollAlreadyCreated.into()) 10 | } else { 11 | collection.add_poll(poll_pubkey); 12 | Ok(()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/program/mod.rs: -------------------------------------------------------------------------------- 1 | mod collection; 2 | mod poll; 3 | mod tally; 4 | 5 | use crate::result::PollError; 6 | use crate::util::{ 7 | expect_data_type, expect_gt, expect_key, expect_min_size, expect_new_account, expect_owned_by, 8 | expect_signed, 9 | }; 10 | use core::convert::TryFrom; 11 | use prediction_poll_data::{ 12 | ClockData, CollectionData, CommandData, DataType, InitPollData, PollData, TallyData, 13 | MIN_COLLECTION_SIZE, MIN_TALLY_SIZE, 14 | }; 15 | use solana_sdk::{ 16 | account_info::AccountInfo, entrypoint::ProgramResult, info, program_utils::next_account_info, 17 | pubkey::Pubkey, sysvar::clock, 18 | }; 19 | 20 | pub fn process_instruction( 21 | program_id: &Pubkey, 22 | accounts: &[AccountInfo], 23 | instruction_data: &[u8], 24 | ) -> ProgramResult { 25 | let (command, data) = instruction_data.split_at(1); 26 | let command = 27 | CommandData::try_from(command[0].to_le()).map_err(|_| PollError::InvalidCommand)?; 28 | match command { 29 | CommandData::InitCollection => init_collection(program_id, accounts), 30 | CommandData::InitPoll => init_poll(program_id, accounts, data), 31 | CommandData::SubmitVote => submit_vote(program_id, accounts), 32 | CommandData::SubmitClaim => submit_claim(program_id, accounts), 33 | } 34 | } 35 | 36 | fn init_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { 37 | info!("init collection"); 38 | let account_info_iter = &mut accounts.iter(); 39 | 40 | let collection_account = next_account_info(account_info_iter)?; 41 | expect_signed(collection_account)?; 42 | expect_owned_by(collection_account, program_id)?; 43 | expect_min_size(&collection_account.data.borrow(), MIN_COLLECTION_SIZE)?; 44 | expect_new_account(collection_account)?; 45 | 46 | collection_account.data.borrow_mut()[0] = DataType::Collection as u8; 47 | 48 | Ok(()) 49 | } 50 | 51 | fn init_poll(program_id: &Pubkey, accounts: &[AccountInfo], init_data: &[u8]) -> ProgramResult { 52 | info!("init poll"); 53 | let account_info_iter = &mut accounts.iter(); 54 | 55 | let creator_account = next_account_info(account_info_iter)?; 56 | expect_signed(creator_account)?; 57 | 58 | let poll_account = next_account_info(account_info_iter)?; 59 | expect_signed(poll_account)?; 60 | expect_owned_by(poll_account, program_id)?; 61 | expect_new_account(poll_account)?; 62 | 63 | let collection_account = next_account_info(account_info_iter)?; 64 | expect_owned_by(collection_account, program_id)?; 65 | expect_data_type(collection_account, DataType::Collection)?; 66 | 67 | let tally_a_account = next_account_info(account_info_iter)?; 68 | expect_signed(tally_a_account)?; 69 | expect_owned_by(tally_a_account, program_id)?; 70 | expect_min_size(&tally_a_account.data.borrow(), MIN_TALLY_SIZE)?; 71 | expect_new_account(tally_a_account)?; 72 | 73 | let tally_b_account = next_account_info(account_info_iter)?; 74 | expect_signed(tally_b_account)?; 75 | expect_owned_by(tally_b_account, program_id)?; 76 | expect_min_size(&tally_b_account.data.borrow(), MIN_TALLY_SIZE)?; 77 | expect_new_account(tally_b_account)?; 78 | 79 | let clock_account = next_account_info(account_info_iter)?; 80 | expect_key(clock_account, &clock::id())?; 81 | 82 | let mut collection_account_data = collection_account.data.borrow_mut(); 83 | let mut collection = CollectionData::from_bytes(&mut collection_account_data); 84 | let clock = ClockData::from_bytes(&clock_account.data.borrow()); 85 | let init_poll = InitPollData::from_bytes(init_data); 86 | expect_gt(init_poll.header_len, 0)?; 87 | expect_gt(init_poll.option_a_len, 0)?; 88 | expect_gt(init_poll.option_b_len, 0)?; 89 | 90 | collection::add_poll(&mut collection, poll_account.key)?; 91 | let mut poll_account_data = poll_account.data.borrow_mut(); 92 | PollData::copy_to_bytes( 93 | &mut poll_account_data, 94 | init_poll, 95 | creator_account.key, 96 | tally_a_account.key, 97 | tally_b_account.key, 98 | clock.slot, 99 | ); 100 | 101 | tally_a_account.data.borrow_mut()[0] = DataType::Tally as u8; 102 | tally_b_account.data.borrow_mut()[0] = DataType::Tally as u8; 103 | 104 | Ok(()) 105 | } 106 | 107 | fn submit_vote(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { 108 | info!("submit vote"); 109 | let account_info_iter = &mut accounts.iter(); 110 | 111 | let user_account = next_account_info(account_info_iter)?; 112 | expect_signed(user_account)?; 113 | expect_owned_by(user_account, program_id)?; 114 | 115 | let poll_account = next_account_info(account_info_iter)?; 116 | expect_owned_by(poll_account, program_id)?; 117 | expect_data_type(poll_account, DataType::Poll)?; 118 | 119 | let tally_account = next_account_info(account_info_iter)?; 120 | expect_owned_by(tally_account, program_id)?; 121 | expect_data_type(tally_account, DataType::Tally)?; 122 | 123 | let payout_account = next_account_info(account_info_iter)?; 124 | let clock_account = next_account_info(account_info_iter)?; 125 | expect_key(clock_account, &clock::id())?; 126 | 127 | info!(0, 0, 0, 0, line!()); 128 | 129 | let clock_data = clock_account.data.borrow_mut(); 130 | info!(0, 0, 0, 0, line!()); 131 | let clock = ClockData::from_bytes(&clock_data); 132 | info!(0, 0, 0, 0, line!()); 133 | let mut poll_data = poll_account.data.borrow_mut(); 134 | info!(0, 0, 0, 0, line!()); 135 | let mut poll = PollData::from_bytes(&mut poll_data); 136 | info!(0, 0, 0, 0, line!()); 137 | let mut tally_data = tally_account.data.borrow_mut(); 138 | let mut tally = TallyData::from_bytes(&mut tally_data); 139 | 140 | info!(0, 0, 0, 0, line!()); 141 | if poll.last_block < clock.slot { 142 | return Err(PollError::PollAlreadyFinished.into()); 143 | } 144 | 145 | if user_account.lamports() == 0 { 146 | return Err(PollError::WagerHasNoFunds.into()); 147 | } 148 | info!(0, 0, 0, 0, line!()); 149 | 150 | let wager = user_account.lamports(); 151 | poll::record_wager(&mut poll, tally_account.key, wager)?; 152 | tally::record_wager(&mut tally, payout_account.key, wager)?; 153 | info!(0, 0, 0, 0, line!()); 154 | 155 | **poll_account.lamports.borrow_mut() += wager; 156 | **user_account.lamports.borrow_mut() = 0; 157 | info!(0, 0, 0, 0, line!()); 158 | 159 | Ok(()) 160 | } 161 | 162 | fn submit_claim(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { 163 | info!("submit claim"); 164 | // No signer needed 165 | let account_info_iter = &mut accounts.iter(); 166 | 167 | let poll_account = next_account_info(account_info_iter)?; 168 | expect_owned_by(poll_account, program_id)?; 169 | expect_data_type(poll_account, DataType::Poll)?; 170 | 171 | let tally_account = next_account_info(account_info_iter)?; 172 | expect_owned_by(tally_account, program_id)?; 173 | expect_data_type(tally_account, DataType::Tally)?; 174 | 175 | let clock_account = next_account_info(account_info_iter)?; 176 | expect_key(clock_account, &clock::id())?; 177 | 178 | if poll_account.lamports() <= 1 { 179 | return Err(PollError::PollHasNoFunds.into()); 180 | } 181 | 182 | let clock_data = clock_account.data.borrow_mut(); 183 | let clock = ClockData::from_bytes(&clock_data); 184 | let mut poll_data = poll_account.data.borrow_mut(); 185 | let poll = PollData::from_bytes(&mut poll_data); 186 | let mut tally_data = tally_account.data.borrow_mut(); 187 | let tally = TallyData::from_bytes(&mut tally_data); 188 | 189 | if poll.last_block > clock.slot { 190 | return Err(PollError::PollNotFinished.into()); 191 | } 192 | 193 | let pot = **poll_account.lamports.borrow_mut() - 1; 194 | **poll_account.lamports.borrow_mut() = 1; 195 | 196 | let winning_quantity = poll::check_winning_tally(&poll, tally_account.key)?; 197 | tally::payout(&tally, account_info_iter.as_slice(), winning_quantity, pot)?; 198 | 199 | Ok(()) 200 | } 201 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/program/poll.rs: -------------------------------------------------------------------------------- 1 | use crate::result::PollError; 2 | use prediction_poll_data::PollData; 3 | use solana_sdk::{entrypoint::ProgramResult, program_error::ProgramError, pubkey::Pubkey}; 4 | 5 | pub fn record_wager(poll: &mut PollData, tally_pubkey: &Pubkey, wager: u64) -> ProgramResult { 6 | let (selected, unselected) = if poll.option_a.tally_key == *tally_pubkey { 7 | (&mut poll.option_a, &mut poll.option_b) 8 | } else if poll.option_b.tally_key == *tally_pubkey { 9 | (&mut poll.option_b, &mut poll.option_a) 10 | } else { 11 | return Err(PollError::InvalidTallyKey.into()); 12 | }; 13 | 14 | if *selected.quantity + wager == *unselected.quantity { 15 | return Err(PollError::PollCannotBeEven.into()); 16 | } 17 | 18 | *selected.quantity += wager; 19 | Ok(()) 20 | } 21 | 22 | pub fn check_winning_tally(poll: &PollData, tally_pubkey: &Pubkey) -> Result { 23 | if poll.option_a.tally_key == *tally_pubkey { 24 | if *poll.option_a.quantity > *poll.option_b.quantity { 25 | Ok(*poll.option_a.quantity) 26 | } else { 27 | Err(PollError::CannotPayoutToLosers.into()) 28 | } 29 | } else if poll.option_b.tally_key == *tally_pubkey { 30 | if *poll.option_b.quantity > *poll.option_a.quantity { 31 | Ok(*poll.option_b.quantity) 32 | } else { 33 | Err(PollError::CannotPayoutToLosers.into()) 34 | } 35 | } else { 36 | Err(PollError::InvalidTallyKey.into()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/program/tally.rs: -------------------------------------------------------------------------------- 1 | use crate::result::PollError; 2 | use prediction_poll_data::TallyData; 3 | use solana_sdk::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; 4 | use std::convert::TryFrom; 5 | 6 | pub fn record_wager(tally: &mut TallyData, user_pubkey: &Pubkey, wager: u64) -> ProgramResult { 7 | if let Some(wager_mut_ref) = tally.get_wager_mut(user_pubkey) { 8 | let value = u64::from_le_bytes(*wager_mut_ref); 9 | *wager_mut_ref = (value + wager).to_le_bytes(); 10 | return Ok(()); 11 | } 12 | 13 | if tally.len() >= tally.capacity() { 14 | Err(PollError::MaxTallyCapacity.into()) 15 | } else { 16 | tally.add_tally(user_pubkey, wager); 17 | Ok(()) 18 | } 19 | } 20 | 21 | pub fn payout( 22 | tally: &TallyData, 23 | accounts: &[AccountInfo], 24 | winning_quantity: u64, 25 | pot: u64, 26 | ) -> ProgramResult { 27 | if tally.len() != accounts.len() { 28 | return Err(PollError::InvalidPayoutList.into()); 29 | } 30 | 31 | let mut remaining = pot; 32 | let pot = u128::from(pot); 33 | let winning_quantity = u128::from(winning_quantity); 34 | for (index, (key, wager)) in tally.iter().enumerate() { 35 | if key != *accounts[index].key { 36 | return Err(PollError::InvalidPayoutList.into()); 37 | } 38 | 39 | let mut portion = u64::try_from(pot * u128::from(wager) / winning_quantity).unwrap(); 40 | remaining -= portion; 41 | if index == accounts.len() - 1 { 42 | portion += remaining; // last voter gets the rounding error 43 | } 44 | **accounts[index].lamports.borrow_mut() += portion; 45 | } 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/result.rs: -------------------------------------------------------------------------------- 1 | use num_derive::FromPrimitive; 2 | use num_traits::FromPrimitive; 3 | use solana_sdk::{ 4 | info, 5 | program_error::{PrintProgramError, ProgramError}, 6 | program_utils::DecodeError, 7 | }; 8 | use thiserror::Error; 9 | 10 | #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] 11 | pub enum PollError { 12 | #[error("todo")] 13 | AccountNotNew, 14 | #[error("todo")] 15 | CannotPayoutToLosers, 16 | #[error("todo")] 17 | InvalidAccount, 18 | #[error("todo")] 19 | InvalidDataType, 20 | #[error("todo")] 21 | InvalidInput, 22 | #[error("todo")] 23 | InvalidKey, 24 | #[error("todo")] 25 | InvalidCommand, 26 | #[error("todo")] 27 | InvalidTallyKey, 28 | #[error("todo")] 29 | InvalidPayoutList, 30 | #[error("todo")] 31 | MaxPollCapacity, 32 | #[error("todo")] 33 | MaxTallyCapacity, 34 | #[error("todo")] 35 | PollAlreadyCreated, 36 | #[error("todo")] 37 | PollAlreadyFinished, 38 | #[error("todo")] 39 | PollNotFinished, 40 | #[error("todo")] 41 | PollHasNoFunds, 42 | #[error("todo")] 43 | PollCannotBeEven, 44 | #[error("todo")] 45 | WagerHasNoFunds, 46 | } 47 | impl From for ProgramError { 48 | fn from(e: PollError) -> Self { 49 | ProgramError::CustomError(e as u32) 50 | } 51 | } 52 | impl DecodeError for PollError { 53 | fn type_of() -> &'static str { 54 | "ProgramError" 55 | } 56 | } 57 | impl PrintProgramError for PollError { 58 | fn print(&self) 59 | where 60 | E: 'static + std::error::Error + DecodeError + PrintProgramError + FromPrimitive, 61 | { 62 | match self { 63 | PollError::AccountNotNew => info!("Error: todo"), 64 | PollError::CannotPayoutToLosers => info!("Error: todo"), 65 | PollError::InvalidAccount => info!("Error: todo"), 66 | PollError::InvalidDataType => info!("Error: todo"), 67 | PollError::InvalidInput => info!("Error: todo"), 68 | PollError::InvalidKey => info!("Error: todo"), 69 | PollError::InvalidCommand => info!("Error: todo"), 70 | PollError::InvalidTallyKey => info!("Error: todo"), 71 | PollError::InvalidPayoutList => info!("Error: todo"), 72 | PollError::MaxPollCapacity => info!("Error: todo"), 73 | PollError::MaxTallyCapacity => info!("Error: todo"), 74 | PollError::PollAlreadyCreated => info!("Error: todo"), 75 | PollError::PollAlreadyFinished => info!("Error: todo"), 76 | PollError::PollNotFinished => info!("Error: todo"), 77 | PollError::PollHasNoFunds => info!("Error: todo"), 78 | PollError::PollCannotBeEven => info!("Error: todo"), 79 | PollError::WagerHasNoFunds => info!("Error: todo"), 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program/src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::result::PollError; 2 | use prediction_poll_data::DataType; 3 | use solana_sdk::{ 4 | account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, 5 | pubkey::Pubkey, 6 | }; 7 | 8 | pub fn expect_signed(account: &AccountInfo) -> ProgramResult { 9 | if !account.is_signer { 10 | return Err(ProgramError::MissingRequiredSignature); 11 | } 12 | Ok(()) 13 | } 14 | 15 | pub fn expect_owned_by(account: &AccountInfo, key: &Pubkey) -> ProgramResult { 16 | if account.owner != key { 17 | return Err(PollError::InvalidAccount.into()); 18 | } 19 | Ok(()) 20 | } 21 | 22 | pub fn expect_data_type(account: &AccountInfo, data_type: DataType) -> ProgramResult { 23 | if DataType::from(account.data.borrow()[0]) as u8 != data_type as u8 { 24 | return Err(PollError::InvalidDataType.into()); 25 | } 26 | Ok(()) 27 | } 28 | 29 | pub fn expect_new_account(account: &AccountInfo) -> ProgramResult { 30 | expect_data_type(account, DataType::Unset).map_err(|_| PollError::AccountNotNew.into()) 31 | } 32 | 33 | pub fn expect_key(account: &AccountInfo, key: &Pubkey) -> ProgramResult { 34 | if account.key != key { 35 | return Err(PollError::InvalidKey.into()); 36 | } 37 | Ok(()) 38 | } 39 | 40 | pub fn expect_min_size(data: &[u8], min_size: usize) -> ProgramResult { 41 | if data.len() < min_size { 42 | return Err(ProgramError::AccountDataTooSmall); 43 | } 44 | Ok(()) 45 | } 46 | 47 | pub fn expect_gt(left: T, right: T) -> ProgramResult { 48 | if left <= right { 49 | return Err(PollError::InvalidInput.into()); 50 | } 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prediction-poll-data" 3 | version = "0.16.0" 4 | description = "Poll feed program data" 5 | authors = ["Solana Maintainers "] 6 | repository = "https://github.com/solana-labs/solana" 7 | license = "Apache-2.0" 8 | homepage = "https://solana.com/" 9 | edition = "2018" 10 | 11 | [dependencies] 12 | arrayref = "0.3.5" 13 | solana-sdk = { version = "=1.1.1", default-features = false } 14 | wasm-bindgen = { version = "0.2", optional = true } 15 | 16 | [features] 17 | program = ["solana-sdk/program"] 18 | wasm = ["wasm-bindgen"] 19 | default = ["program"] 20 | 21 | [lib] 22 | name = "prediction_poll_data" 23 | crate-type = ["rlib"] 24 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/clock.rs: -------------------------------------------------------------------------------- 1 | pub struct ClockData { 2 | pub slot: u64, 3 | } 4 | 5 | impl ClockData { 6 | pub fn from_bytes(data: &[u8]) -> Self { 7 | Self { 8 | slot: u64::from_le_bytes(*array_ref!(data, 0, 8)), 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/collection.rs: -------------------------------------------------------------------------------- 1 | use crate::DataType; 2 | use alloc::slice::from_raw_parts_mut; 3 | #[cfg(any(test, feature = "wasm"))] 4 | use alloc::vec::Vec; 5 | use solana_sdk::pubkey::Pubkey; 6 | 7 | type PubkeyData = [u8; 32]; 8 | 9 | /// Min data size for a poll collection 10 | /// Breakdown: data_type (1) + poll_count (4) + one poll (32) 11 | pub const MIN_COLLECTION_SIZE: usize = 1 + 4 + 32; 12 | 13 | pub struct CollectionData<'a> { 14 | pub data_type: DataType, 15 | poll_count: &'a mut u32, 16 | polls: &'a mut [PubkeyData], 17 | } 18 | 19 | impl<'a> CollectionData<'a> { 20 | pub fn from_bytes(data: &'a mut [u8]) -> Self { 21 | let (data_type, data) = data.split_at_mut(1); 22 | let (poll_count, data) = data.split_at_mut(4); 23 | #[allow(clippy::cast_ptr_alignment)] 24 | let poll_count = unsafe { &mut *(&mut poll_count[0] as *mut u8 as *mut u32) }; 25 | Self { 26 | data_type: DataType::from(data_type[0]), 27 | poll_count, 28 | polls: unsafe { 29 | from_raw_parts_mut(&mut data[0] as *mut u8 as *mut _, data.len() / 32) 30 | }, 31 | } 32 | } 33 | } 34 | 35 | impl CollectionData<'_> { 36 | pub fn contains(&self, poll: &Pubkey) -> bool { 37 | let poll_key_data = array_ref!(poll.as_ref(), 0, 32); 38 | for i in 0..self.len() { 39 | if self.polls[i] == *poll_key_data { 40 | return true; 41 | } 42 | } 43 | false 44 | } 45 | 46 | pub fn capacity(&self) -> usize { 47 | self.polls.len() 48 | } 49 | 50 | pub fn is_empty(&self) -> bool { 51 | self.len() == 0 52 | } 53 | 54 | pub fn len(&self) -> usize { 55 | *self.poll_count as usize 56 | } 57 | 58 | pub fn add_poll(&mut self, poll: &Pubkey) { 59 | self.polls[self.len()].copy_from_slice(poll.as_ref()); 60 | *self.poll_count += 1; 61 | } 62 | 63 | #[cfg(any(test, feature = "wasm"))] 64 | pub fn to_vec(&self) -> Vec { 65 | self.polls[..self.len()] 66 | .iter() 67 | .map(|poll| Pubkey::new(&poll[..])) 68 | .collect() 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod test { 74 | use super::*; 75 | 76 | #[test] 77 | pub fn add_poll() { 78 | let poll_key = Pubkey::new(&[0; 32]); 79 | let mut data = vec![0; MIN_COLLECTION_SIZE]; 80 | let mut collection = CollectionData::from_bytes(&mut data[..]); 81 | 82 | assert_eq!(collection.len(), 0); 83 | assert_eq!(collection.capacity(), 1); 84 | assert!(!collection.contains(&poll_key)); 85 | assert_eq!(collection.to_vec().len(), 0); 86 | 87 | collection.add_poll(&poll_key); 88 | 89 | assert_eq!(collection.len(), 1); 90 | assert_eq!(collection.capacity(), 1); 91 | assert!(collection.contains(&poll_key)); 92 | assert_eq!(collection.to_vec()[0], poll_key); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/command.rs: -------------------------------------------------------------------------------- 1 | use core::convert::TryFrom; 2 | 3 | #[repr(u8)] 4 | pub enum CommandData { 5 | InitCollection, 6 | InitPoll, 7 | SubmitVote, 8 | SubmitClaim, 9 | } 10 | 11 | impl TryFrom for CommandData { 12 | type Error = (); 13 | 14 | fn try_from(value: u8) -> Result { 15 | match value { 16 | 0 => Ok(CommandData::InitCollection), 17 | 1 => Ok(CommandData::InitPoll), 18 | 2 => Ok(CommandData::SubmitVote), 19 | 3 => Ok(CommandData::SubmitClaim), 20 | _ => Err(()), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![no_std] 2 | 3 | #[cfg_attr(test, macro_use)] 4 | extern crate alloc; 5 | #[macro_use] 6 | extern crate arrayref; 7 | #[cfg(feature = "wasm")] 8 | extern crate wasm_bindgen; 9 | 10 | mod clock; 11 | mod collection; 12 | mod command; 13 | mod poll; 14 | mod tally; 15 | 16 | pub use clock::*; 17 | pub use collection::*; 18 | pub use command::*; 19 | pub use poll::*; 20 | pub use tally::*; 21 | 22 | #[repr(u8)] 23 | #[derive(Copy, Clone)] 24 | #[cfg_attr(test, derive(PartialEq, Debug))] 25 | pub enum DataType { 26 | Unset, 27 | Collection, 28 | Poll, 29 | Tally, 30 | Invalid, 31 | } 32 | 33 | impl From for DataType { 34 | fn from(value: u8) -> Self { 35 | match value { 36 | 0 => DataType::Unset, 37 | 1 => DataType::Collection, 38 | 2 => DataType::Poll, 39 | 3 => DataType::Tally, 40 | _ => DataType::Invalid, 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/poll/data.rs: -------------------------------------------------------------------------------- 1 | use super::InitPollData; 2 | use crate::DataType; 3 | #[cfg(test)] 4 | use alloc::vec::Vec; 5 | use solana_sdk::pubkey::Pubkey; 6 | 7 | #[cfg_attr(test, derive(PartialEq, Debug))] 8 | pub struct PollData<'a> { 9 | pub data_type: DataType, 10 | pub creator_key: Pubkey, 11 | pub last_block: u64, 12 | pub header_len: u32, 13 | pub header: &'a [u8], 14 | pub option_a: PollOptionData<'a>, 15 | pub option_b: PollOptionData<'a>, 16 | } 17 | 18 | impl<'a> PollData<'a> { 19 | #[cfg(test)] 20 | pub fn length(&self) -> usize { 21 | (1 + 32 + 8 + 4 + self.header_len) as usize 22 | + self.option_a.length() 23 | + self.option_b.length() 24 | } 25 | 26 | #[cfg(test)] 27 | pub fn to_bytes(&self) -> Vec { 28 | let mut bytes = Vec::with_capacity(self.length()); 29 | bytes.push(self.data_type as u8); 30 | bytes.extend_from_slice(self.creator_key.as_ref()); 31 | bytes.extend_from_slice(&self.last_block.to_le_bytes()); 32 | bytes.extend_from_slice(&self.header_len.to_le_bytes()); 33 | bytes.extend_from_slice(self.header); 34 | bytes.extend(self.option_a.to_bytes().into_iter()); 35 | bytes.extend(self.option_b.to_bytes().into_iter()); 36 | bytes 37 | } 38 | 39 | pub fn copy_to_bytes( 40 | dst: &'a mut [u8], 41 | init: InitPollData<'a>, 42 | creator_key: &'a Pubkey, 43 | tally_a_key: &'a Pubkey, 44 | tally_b_key: &'a Pubkey, 45 | slot: u64, 46 | ) { 47 | let (data_type, dst) = dst.split_at_mut(1); 48 | data_type[0] = DataType::Poll as u8; 49 | 50 | let (dst_creator_key, dst) = dst.split_at_mut(32); 51 | dst_creator_key.copy_from_slice(creator_key.as_ref()); 52 | 53 | let last_block = slot + u64::from(init.timeout); 54 | let (dst_last_block, dst) = dst.split_at_mut(8); 55 | dst_last_block.copy_from_slice(&last_block.to_le_bytes()); 56 | 57 | let (header_len, dst) = dst.split_at_mut(4); 58 | header_len.copy_from_slice(&init.header_len.to_le_bytes()); 59 | let (header, dst) = dst.split_at_mut(init.header_len as usize); 60 | header.copy_from_slice(&init.header); 61 | 62 | let dst = PollOptionData::copy_to_bytes(dst, init.option_a, tally_a_key, 0); 63 | PollOptionData::copy_to_bytes(dst, init.option_b, tally_b_key, 0); 64 | } 65 | 66 | pub fn from_bytes(data: &'a mut [u8]) -> Self { 67 | let (data_type, data) = data.split_at_mut(1); 68 | let data_type = DataType::from(data_type[0]); 69 | 70 | let (creator_key, data) = data.split_at_mut(32); 71 | let creator_key = Pubkey::new(creator_key); 72 | 73 | let (last_block, data) = data.split_at_mut(8); 74 | let last_block = u64::from_le_bytes(*array_ref!(last_block, 0, 8)); 75 | 76 | let (header_len, data) = data.split_at_mut(4); 77 | let header_len = u32::from_le_bytes(*array_ref!(header_len, 0, 4)); 78 | let (header, data) = data.split_at_mut(header_len as usize); 79 | 80 | let (option_a, data) = PollOptionData::from_bytes(data); 81 | let (option_b, _) = PollOptionData::from_bytes(data); 82 | 83 | Self { 84 | data_type, 85 | creator_key, 86 | last_block, 87 | header_len, 88 | header, 89 | option_a, 90 | option_b, 91 | } 92 | } 93 | } 94 | 95 | #[cfg_attr(test, derive(PartialEq, Debug))] 96 | pub struct PollOptionData<'a> { 97 | pub text_len: u32, 98 | pub text: &'a [u8], 99 | pub tally_key: Pubkey, 100 | pub quantity: &'a mut u64, 101 | } 102 | 103 | impl<'a> PollOptionData<'a> { 104 | #[cfg(test)] 105 | pub fn length(&self) -> usize { 106 | (4 + self.text_len + 32 + 8) as usize 107 | } 108 | 109 | #[cfg(test)] 110 | pub fn to_bytes(&self) -> Vec { 111 | let mut bytes = Vec::with_capacity(self.length()); 112 | bytes.extend_from_slice(&self.text_len.to_le_bytes()); 113 | bytes.extend_from_slice(self.text); 114 | bytes.extend_from_slice(self.tally_key.as_ref()); 115 | bytes.extend_from_slice(&self.quantity.to_le_bytes()); 116 | bytes 117 | } 118 | 119 | pub fn copy_to_bytes( 120 | dst: &'a mut [u8], 121 | text: &'a [u8], 122 | tally_key: &'a Pubkey, 123 | quantity: u64, 124 | ) -> &'a mut [u8] { 125 | let text_len = text.len() as u32; 126 | let (dst_text_len, dst) = dst.split_at_mut(4); 127 | dst_text_len.copy_from_slice(&text_len.to_le_bytes()); 128 | 129 | let (dst_text, dst) = dst.split_at_mut(text.len()); 130 | dst_text.copy_from_slice(text); 131 | 132 | let (dst_tally_key, dst) = dst.split_at_mut(32); 133 | dst_tally_key.copy_from_slice(tally_key.as_ref()); 134 | 135 | let (dst_quantity, dst) = dst.split_at_mut(8); 136 | dst_quantity.copy_from_slice(&quantity.to_le_bytes()); 137 | dst 138 | } 139 | 140 | pub fn from_bytes(data: &'a mut [u8]) -> (Self, &'a mut [u8]) { 141 | let (text_len, data) = data.split_at_mut(4); 142 | let text_len = u32::from_le_bytes(*array_ref!(text_len, 0, 4)); 143 | let (text, data) = data.split_at_mut(text_len as usize); 144 | 145 | let (tally_key, data) = data.split_at_mut(32); 146 | let tally_key = Pubkey::new(tally_key); 147 | 148 | let (quantity, data) = data.split_at_mut(8); 149 | #[allow(clippy::cast_ptr_alignment)] 150 | let quantity = unsafe { &mut *(&mut quantity[0] as *mut u8 as *mut u64) }; 151 | 152 | ( 153 | Self { 154 | text_len, 155 | text, 156 | tally_key, 157 | quantity, 158 | }, 159 | data, 160 | ) 161 | } 162 | } 163 | 164 | #[cfg(test)] 165 | mod test { 166 | use super::*; 167 | 168 | #[test] 169 | pub fn poll_serialization() { 170 | let creator_key = Pubkey::new(&[0; 32]); 171 | let header = "poll".as_bytes(); 172 | let option_a = "first option".as_bytes(); 173 | let option_a_key = Pubkey::new(&[1; 32]); 174 | let mut quantity_a = 100; 175 | let option_b = "second option".as_bytes(); 176 | let option_b_key = Pubkey::new(&[2; 32]); 177 | let mut quantity_b = 101; 178 | 179 | let data = PollData { 180 | data_type: DataType::Poll, 181 | creator_key, 182 | last_block: 999, 183 | header_len: header.len() as u32, 184 | header, 185 | option_a: PollOptionData { 186 | text_len: option_a.len() as u32, 187 | text: option_a, 188 | tally_key: option_a_key, 189 | quantity: &mut quantity_a, 190 | }, 191 | option_b: PollOptionData { 192 | text_len: option_b.len() as u32, 193 | text: option_b, 194 | tally_key: option_b_key, 195 | quantity: &mut quantity_b, 196 | }, 197 | }; 198 | 199 | let mut bytes = data.to_bytes(); 200 | let data_copy = PollData::from_bytes(&mut bytes[..]); 201 | 202 | assert_eq!(data, data_copy); 203 | assert_eq!(data.length(), bytes.len()); 204 | } 205 | 206 | #[test] 207 | pub fn option_serialization() { 208 | let key = Pubkey::new(&[0; 32]); 209 | let text = "option text".as_bytes(); 210 | let mut quantity = 100; 211 | let data = PollOptionData { 212 | text_len: text.len() as u32, 213 | text, 214 | tally_key: key, 215 | quantity: &mut quantity, 216 | }; 217 | 218 | let mut bytes = data.to_bytes(); 219 | let (data_copy, _) = PollOptionData::from_bytes(&mut bytes[..]); 220 | 221 | assert_eq!(data, data_copy); 222 | assert_eq!(data.length(), bytes.len()); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/poll/instruction.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(test, feature = "wasm"))] 2 | use alloc::vec::Vec; 3 | 4 | #[cfg_attr(test, derive(PartialEq, Debug))] 5 | pub struct InitPollData<'a> { 6 | pub timeout: u32, // block height 7 | pub header_len: u32, 8 | pub header: &'a [u8], 9 | pub option_a_len: u32, 10 | pub option_a: &'a [u8], 11 | pub option_b_len: u32, 12 | pub option_b: &'a [u8], 13 | } 14 | 15 | impl<'a> InitPollData<'a> { 16 | #[cfg(any(test, feature = "wasm"))] 17 | pub fn length(&self) -> usize { 18 | (4 + 4 + self.header_len + 4 + self.option_a_len + 4 + self.option_b_len) as usize 19 | } 20 | 21 | #[cfg(any(test, feature = "wasm"))] 22 | pub fn to_bytes(&self) -> Vec { 23 | let mut bytes = Vec::with_capacity(self.length()); 24 | bytes.extend_from_slice(&self.timeout.to_le_bytes()); 25 | bytes.extend_from_slice(&self.header_len.to_le_bytes()); 26 | bytes.extend_from_slice(self.header); 27 | bytes.extend_from_slice(&self.option_a_len.to_le_bytes()); 28 | bytes.extend_from_slice(self.option_a); 29 | bytes.extend_from_slice(&self.option_b_len.to_le_bytes()); 30 | bytes.extend_from_slice(self.option_b); 31 | bytes 32 | } 33 | 34 | pub fn from_bytes(data: &'a [u8]) -> Self { 35 | let (timeout, data) = data.split_at(4); 36 | let timeout = u32::from_le_bytes(*array_ref!(timeout, 0, 4)); 37 | 38 | let (header_len, data) = data.split_at(4); 39 | let header_len = u32::from_le_bytes(*array_ref!(header_len, 0, 4)); 40 | let (header, data) = data.split_at(header_len as usize); 41 | 42 | let (option_a_len, data) = data.split_at(4); 43 | let option_a_len = u32::from_le_bytes(*array_ref!(option_a_len, 0, 4)); 44 | let (option_a, data) = data.split_at(option_a_len as usize); 45 | 46 | let (option_b_len, data) = data.split_at(4); 47 | let option_b_len = u32::from_le_bytes(*array_ref!(option_b_len, 0, 4)); 48 | let (option_b, _data) = data.split_at(option_b_len as usize); 49 | 50 | InitPollData { 51 | timeout, 52 | header_len, 53 | header, 54 | option_a_len, 55 | option_a, 56 | option_b_len, 57 | option_b, 58 | } 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod test { 64 | use super::*; 65 | 66 | #[test] 67 | pub fn serialization() { 68 | let header = "poll".as_bytes(); 69 | let option_a = "first option".as_bytes(); 70 | let option_b = "second option".as_bytes(); 71 | let data = InitPollData { 72 | timeout: 100, 73 | header_len: header.len() as u32, 74 | header, 75 | option_a_len: option_a.len() as u32, 76 | option_a, 77 | option_b_len: option_b.len() as u32, 78 | option_b, 79 | }; 80 | 81 | let bytes = data.to_bytes(); 82 | let data_copy = InitPollData::from_bytes(&bytes[..]); 83 | 84 | assert_eq!(data, data_copy); 85 | assert_eq!(data.length(), bytes.len()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/poll/mod.rs: -------------------------------------------------------------------------------- 1 | mod data; 2 | mod instruction; 3 | 4 | pub use data::*; 5 | pub use instruction::*; 6 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/program_data/src/tally.rs: -------------------------------------------------------------------------------- 1 | use crate::DataType; 2 | use alloc::slice::from_raw_parts_mut; 3 | use solana_sdk::pubkey::Pubkey; 4 | 5 | type Tally = [u8; 40]; // Pubkey, u64 6 | 7 | /// Min data size for a tally 8 | /// Breakdown: data_type (1) + tally_count (4) + one tally (40) 9 | pub const MIN_TALLY_SIZE: usize = 1 + 4 + 40; 10 | 11 | pub struct TallyData<'a> { 12 | pub data_type: DataType, 13 | pub tally_count: &'a mut u32, 14 | pub tallies: &'a mut [Tally], 15 | } 16 | 17 | impl<'a> TallyData<'a> { 18 | pub fn from_bytes(data: &'a mut [u8]) -> Self { 19 | let (data_type, data) = data.split_at_mut(1); 20 | let (tally_count, data) = data.split_at_mut(4); 21 | #[allow(clippy::cast_ptr_alignment)] 22 | let tally_count = unsafe { &mut *(&mut tally_count[0] as *mut u8 as *mut u32) }; 23 | Self { 24 | data_type: DataType::from(data_type[0]), 25 | tally_count, 26 | tallies: unsafe { 27 | from_raw_parts_mut(&mut data[0] as *mut u8 as *mut _, data.len() / 40) 28 | }, 29 | } 30 | } 31 | } 32 | 33 | impl TallyData<'_> { 34 | pub fn get_wager_mut(&mut self, user_key: &Pubkey) -> Option<&mut [u8; 8]> { 35 | for t in 0..self.len() { 36 | let key = Pubkey::new(array_ref!(self.tallies[t], 0, 32)); 37 | if key == *user_key { 38 | return Some(array_mut_ref!(self.tallies[t], 32, 8)); 39 | } 40 | } 41 | None 42 | } 43 | 44 | pub fn capacity(&self) -> usize { 45 | self.tallies.len() 46 | } 47 | 48 | pub fn is_empty(&self) -> bool { 49 | self.len() == 0 50 | } 51 | 52 | pub fn len(&self) -> usize { 53 | *self.tally_count as usize 54 | } 55 | 56 | pub fn add_tally(&mut self, user_key: &Pubkey, wager: u64) { 57 | let next_tally = self.len(); 58 | self.tallies[next_tally][..32].copy_from_slice(user_key.as_ref()); 59 | self.tallies[next_tally][32..].copy_from_slice(&wager.to_le_bytes()); 60 | *self.tally_count += 1; 61 | } 62 | 63 | pub fn iter(&self) -> impl Iterator + '_ { 64 | self.tallies[..self.len()].iter().map(|tally| { 65 | let key = Pubkey::new(&tally[..32]); 66 | let wager = u64::from_le_bytes(*array_ref!(tally, 32, 8)); 67 | (key, wager) 68 | }) 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod test { 74 | use super::*; 75 | 76 | #[test] 77 | pub fn add_tally() { 78 | let user_key = Pubkey::new(&[0; 32]); 79 | let wager = 100; 80 | let mut data = vec![0; MIN_TALLY_SIZE]; 81 | let mut tally = TallyData::from_bytes(&mut data[..]); 82 | 83 | assert_eq!(tally.len(), 0); 84 | assert_eq!(tally.capacity(), 1); 85 | assert_eq!(tally.get_wager_mut(&user_key), None); 86 | assert_eq!(tally.iter().next(), None); 87 | 88 | tally.add_tally(&user_key, wager); 89 | 90 | assert_eq!(tally.len(), 1); 91 | assert_eq!(tally.capacity(), 1); 92 | assert_eq!( 93 | tally.get_wager_mut(&user_key).copied(), 94 | Some(wager.to_le_bytes()) 95 | ); 96 | 97 | let mut tally_iter = tally.iter(); 98 | assert_eq!(tally_iter.next(), Some((user_key, wager))); 99 | assert_eq!(tally_iter.next(), None); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prediction-poll-wasm-bindings" 3 | version = "0.16.0" 4 | description = "Poll feed wasm bindings" 5 | authors = ["Solana Maintainers "] 6 | repository = "https://github.com/solana-labs/solana" 7 | license = "Apache-2.0" 8 | homepage = "https://solana.com/" 9 | edition = "2018" 10 | 11 | [dependencies] 12 | arrayref = "0.3.5" 13 | console_error_panic_hook = "0.1" 14 | js-sys = "0.3" 15 | solana-sdk = { version = "=1.1.1", default-features = false } 16 | prediction-poll-data = { path = "../program_data", default-features = false, features = ["wasm"] } 17 | wasm-bindgen = "0.2" 18 | 19 | [features] 20 | program = ["solana-sdk/program"] 21 | default = ["program"] 22 | 23 | [lib] 24 | name = "prediction_poll_wasm_bindings" 25 | crate-type = ["cdylib", "rlib"] 26 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/clock.rs: -------------------------------------------------------------------------------- 1 | use core::convert::TryFrom; 2 | use prediction_poll_data::ClockData; 3 | use wasm_bindgen::prelude::*; 4 | 5 | #[wasm_bindgen] 6 | pub struct Clock { 7 | pub slot: u32, // u64, https://caniuse.com/#feat=bigint 8 | } 9 | 10 | #[wasm_bindgen] 11 | impl Clock { 12 | #[wasm_bindgen(js_name = fromData)] 13 | pub fn from_data(val: &[u8]) -> Self { 14 | console_error_panic_hook::set_once(); 15 | Clock { 16 | slot: u32::try_from(ClockData::from_bytes(val).slot).unwrap(), 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/collection.rs: -------------------------------------------------------------------------------- 1 | use alloc::boxed::Box; 2 | use alloc::vec::Vec; 3 | use js_sys::Uint8Array; 4 | use prediction_poll_data::CollectionData; 5 | use solana_sdk::pubkey::Pubkey; 6 | use wasm_bindgen::prelude::*; 7 | 8 | #[wasm_bindgen] 9 | pub struct Collection { 10 | polls: Vec, 11 | } 12 | 13 | impl From> for Collection { 14 | fn from(collection_data: CollectionData) -> Self { 15 | Collection { 16 | polls: collection_data.to_vec(), 17 | } 18 | } 19 | } 20 | 21 | #[wasm_bindgen] 22 | impl Collection { 23 | #[wasm_bindgen(js_name = getPolls)] 24 | pub fn get_polls(&self) -> Box<[JsValue]> { 25 | let js_polls: Vec<_> = self 26 | .polls 27 | .iter() 28 | .map(|k| Uint8Array::from(k.as_ref()).into()) 29 | .collect(); 30 | js_polls.into_boxed_slice() 31 | } 32 | 33 | #[wasm_bindgen(js_name = getPollCount)] 34 | pub fn get_poll_count(&self) -> usize { 35 | self.polls.len() 36 | } 37 | 38 | #[wasm_bindgen(js_name = fromData)] 39 | pub fn from_data(val: &mut [u8]) -> Self { 40 | console_error_panic_hook::set_once(); 41 | CollectionData::from_bytes(val).into() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::InitPoll; 2 | use alloc::boxed::Box; 3 | use prediction_poll_data::CommandData; 4 | use wasm_bindgen::prelude::*; 5 | 6 | #[wasm_bindgen] 7 | pub struct Command; 8 | 9 | #[wasm_bindgen] 10 | impl Command { 11 | #[wasm_bindgen(js_name = initCollection)] 12 | pub fn init_collection() -> Box<[u8]> { 13 | vec![(CommandData::InitCollection as u8).to_le()].into_boxed_slice() 14 | } 15 | 16 | #[wasm_bindgen(js_name = initPoll)] 17 | pub fn init_poll(init_poll: InitPoll) -> Box<[u8]> { 18 | let mut bytes = init_poll.to_data().to_bytes(); 19 | bytes.insert(0, (CommandData::InitPoll as u8).to_le()); 20 | bytes.into_boxed_slice() 21 | } 22 | 23 | #[wasm_bindgen(js_name = submitClaim)] 24 | pub fn submit_claim() -> Box<[u8]> { 25 | vec![(CommandData::SubmitClaim as u8).to_le()].into_boxed_slice() 26 | } 27 | 28 | #[wasm_bindgen(js_name = submitVote)] 29 | pub fn submit_vote() -> Box<[u8]> { 30 | vec![(CommandData::SubmitVote as u8).to_le()].into_boxed_slice() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/init_poll.rs: -------------------------------------------------------------------------------- 1 | use alloc::boxed::Box; 2 | use alloc::string::String; 3 | use prediction_poll_data::InitPollData; 4 | use wasm_bindgen::prelude::*; 5 | 6 | #[wasm_bindgen] 7 | pub struct InitPoll { 8 | header: String, 9 | option_a: String, 10 | option_b: String, 11 | timeout: u32, 12 | } 13 | 14 | #[wasm_bindgen] 15 | impl InitPoll { 16 | #[wasm_bindgen(constructor)] 17 | pub fn new(header: String, option_a: String, option_b: String, timeout: u32) -> Self { 18 | Self { 19 | header, 20 | option_a, 21 | option_b, 22 | timeout, 23 | } 24 | } 25 | 26 | pub(crate) fn to_data(&self) -> InitPollData<'_> { 27 | let timeout = self.timeout; 28 | 29 | let header = self.header.as_bytes(); 30 | let header_len = header.len() as u32; 31 | 32 | let option_a = self.option_a.as_bytes(); 33 | let option_a_len = option_a.len() as u32; 34 | 35 | let option_b = self.option_b.as_bytes(); 36 | let option_b_len = option_b.len() as u32; 37 | 38 | InitPollData { 39 | timeout, 40 | header_len, 41 | header, 42 | option_a_len, 43 | option_a, 44 | option_b_len, 45 | option_b, 46 | } 47 | } 48 | 49 | #[wasm_bindgen(js_name = toBytes)] 50 | pub fn to_bytes(&self) -> Box<[u8]> { 51 | self.to_data().to_bytes().into_boxed_slice() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![no_std] 2 | 3 | #[macro_use] 4 | extern crate alloc; 5 | extern crate arrayref; 6 | extern crate console_error_panic_hook; 7 | 8 | mod clock; 9 | mod collection; 10 | pub mod command; 11 | mod init_poll; 12 | mod poll; 13 | mod tally; 14 | 15 | pub use clock::*; 16 | pub use collection::*; 17 | pub use init_poll::*; 18 | pub use poll::*; 19 | pub use tally::*; 20 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/poll.rs: -------------------------------------------------------------------------------- 1 | use alloc::borrow::ToOwned; 2 | use alloc::string::{String, ToString}; 3 | use core::convert::TryFrom; 4 | use core::str::from_utf8; 5 | use js_sys::Uint8Array; 6 | use prediction_poll_data::PollData; 7 | use solana_sdk::pubkey::Pubkey; 8 | use wasm_bindgen::prelude::*; 9 | 10 | #[wasm_bindgen] 11 | pub struct Poll { 12 | creator_key: Pubkey, 13 | header: String, 14 | option_a: PollOption, 15 | option_b: PollOption, 16 | pub last_block: u32, // u64, https://caniuse.com/#feat=bigint 17 | } 18 | 19 | #[wasm_bindgen] 20 | #[derive(Clone)] 21 | pub struct PollOption { 22 | text: String, 23 | pub quantity: u32, // u64, https://caniuse.com/#feat=bigint 24 | tally_key: Pubkey, 25 | } 26 | 27 | impl From> for Poll { 28 | fn from(poll_data: PollData) -> Self { 29 | Self { 30 | creator_key: poll_data.creator_key.to_owned(), 31 | header: from_utf8(poll_data.header).unwrap().to_string(), 32 | option_a: PollOption { 33 | text: from_utf8(poll_data.option_a.text).unwrap().to_string(), 34 | quantity: u32::try_from(poll_data.option_a.quantity.to_owned()).unwrap(), 35 | tally_key: poll_data.option_a.tally_key.to_owned(), 36 | }, 37 | option_b: PollOption { 38 | text: from_utf8(poll_data.option_b.text).unwrap().to_string(), 39 | quantity: u32::try_from(poll_data.option_b.quantity.to_owned()).unwrap(), 40 | tally_key: poll_data.option_b.tally_key.to_owned(), 41 | }, 42 | last_block: u32::try_from(poll_data.last_block.to_owned()).unwrap(), 43 | } 44 | } 45 | } 46 | 47 | #[wasm_bindgen] 48 | impl Poll { 49 | #[wasm_bindgen(js_name = fromData)] 50 | pub fn from_data(val: &mut [u8]) -> Self { 51 | console_error_panic_hook::set_once(); 52 | PollData::from_bytes(val).into() 53 | } 54 | 55 | #[wasm_bindgen(method, getter, js_name = creatorKey)] 56 | pub fn creator_key(&self) -> JsValue { 57 | Uint8Array::from(&self.creator_key.as_ref()[..]).into() 58 | } 59 | 60 | #[wasm_bindgen(method, getter)] 61 | pub fn header(&self) -> String { 62 | self.header.clone() 63 | } 64 | 65 | #[wasm_bindgen(method, getter, js_name = optionA)] 66 | pub fn option_a(&self) -> PollOption { 67 | self.option_a.clone() 68 | } 69 | 70 | #[wasm_bindgen(method, getter, js_name = optionB)] 71 | pub fn option_b(&self) -> PollOption { 72 | self.option_b.clone() 73 | } 74 | } 75 | 76 | #[wasm_bindgen] 77 | impl PollOption { 78 | #[wasm_bindgen(method, getter)] 79 | pub fn text(&self) -> String { 80 | self.text.clone() 81 | } 82 | 83 | #[wasm_bindgen(method, getter, js_name = tallyKey)] 84 | pub fn tally_key(&self) -> JsValue { 85 | Uint8Array::from(&self.tally_key.as_ref()[..]).into() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /bpf-rust-programs/prediction-poll/wasm_bindings/src/tally.rs: -------------------------------------------------------------------------------- 1 | use alloc::boxed::Box; 2 | use alloc::vec::Vec; 3 | use core::convert::TryFrom; 4 | use js_sys::Uint8Array; 5 | use prediction_poll_data::TallyData; 6 | use solana_sdk::pubkey::Pubkey; 7 | use wasm_bindgen::prelude::*; 8 | 9 | #[wasm_bindgen] 10 | pub struct Tally { 11 | tallies: Vec<(Pubkey, u32)>, // u64, https://caniuse.com/#feat=bigint 12 | } 13 | 14 | impl From> for Tally { 15 | fn from(tally_data: TallyData) -> Self { 16 | Self { 17 | tallies: tally_data 18 | .iter() 19 | .map(|(k, w)| (k, u32::try_from(w).unwrap())) 20 | .collect(), 21 | } 22 | } 23 | } 24 | 25 | #[wasm_bindgen] 26 | impl Tally { 27 | #[wasm_bindgen(js_name = fromData)] 28 | pub fn from_data(val: &mut [u8]) -> Self { 29 | console_error_panic_hook::set_once(); 30 | TallyData::from_bytes(val).into() 31 | } 32 | 33 | #[wasm_bindgen(method, getter)] 34 | pub fn keys(&self) -> Box<[JsValue]> { 35 | let js_keys: Vec<_> = self 36 | .tallies 37 | .iter() 38 | .map(|(key, _)| Uint8Array::from(&key.as_ref()[..]).into()) 39 | .collect(); 40 | js_keys.into_boxed_slice() 41 | } 42 | 43 | #[wasm_bindgen(method, getter)] 44 | pub fn wagers(&self) -> Box<[u32]> { 45 | let js_wagers: Vec<_> = self.tallies.iter().map(|(_, wager)| *wager).collect(); 46 | js_wagers.into_boxed_slice() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /flow-typed/bs58.js: -------------------------------------------------------------------------------- 1 | declare module 'bs58' { 2 | declare module.exports: { 3 | encode(input: Buffer): string; 4 | decode(input: string): Buffer; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/buffer-layout.js: -------------------------------------------------------------------------------- 1 | declare module 'buffer-layout' { 2 | // TODO: Fill in types 3 | declare module.exports: any; 4 | } 5 | -------------------------------------------------------------------------------- /flow-typed/cbor.js: -------------------------------------------------------------------------------- 1 | declare module 'cbor' { 2 | declare module.exports: { 3 | decode(input: Buffer): Object; 4 | encode(input: any): Buffer; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/event-emitter.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 4f92d81ee3831cb415b4b216cc0679d9 2 | // flow-typed version: <>/event-emitter_v0.3.5/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'event-emitter' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'event-emitter' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'event-emitter/all-off' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'event-emitter/benchmark/many-on' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'event-emitter/benchmark/single-on' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'event-emitter/emit-error' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'event-emitter/has-listeners' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'event-emitter/pipe' { 46 | declare module.exports: any; 47 | } 48 | 49 | declare module 'event-emitter/test/all-off' { 50 | declare module.exports: any; 51 | } 52 | 53 | declare module 'event-emitter/test/emit-error' { 54 | declare module.exports: any; 55 | } 56 | 57 | declare module 'event-emitter/test/has-listeners' { 58 | declare module.exports: any; 59 | } 60 | 61 | declare module 'event-emitter/test/index' { 62 | declare module.exports: any; 63 | } 64 | 65 | declare module 'event-emitter/test/pipe' { 66 | declare module.exports: any; 67 | } 68 | 69 | declare module 'event-emitter/test/unify' { 70 | declare module.exports: any; 71 | } 72 | 73 | declare module 'event-emitter/unify' { 74 | declare module.exports: any; 75 | } 76 | 77 | // Filename aliases 78 | declare module 'event-emitter/all-off.js' { 79 | declare module.exports: $Exports<'event-emitter/all-off'>; 80 | } 81 | declare module 'event-emitter/benchmark/many-on.js' { 82 | declare module.exports: $Exports<'event-emitter/benchmark/many-on'>; 83 | } 84 | declare module 'event-emitter/benchmark/single-on.js' { 85 | declare module.exports: $Exports<'event-emitter/benchmark/single-on'>; 86 | } 87 | declare module 'event-emitter/emit-error.js' { 88 | declare module.exports: $Exports<'event-emitter/emit-error'>; 89 | } 90 | declare module 'event-emitter/has-listeners.js' { 91 | declare module.exports: $Exports<'event-emitter/has-listeners'>; 92 | } 93 | declare module 'event-emitter/index' { 94 | declare module.exports: $Exports<'event-emitter'>; 95 | } 96 | declare module 'event-emitter/index.js' { 97 | declare module.exports: $Exports<'event-emitter'>; 98 | } 99 | declare module 'event-emitter/pipe.js' { 100 | declare module.exports: $Exports<'event-emitter/pipe'>; 101 | } 102 | declare module 'event-emitter/test/all-off.js' { 103 | declare module.exports: $Exports<'event-emitter/test/all-off'>; 104 | } 105 | declare module 'event-emitter/test/emit-error.js' { 106 | declare module.exports: $Exports<'event-emitter/test/emit-error'>; 107 | } 108 | declare module 'event-emitter/test/has-listeners.js' { 109 | declare module.exports: $Exports<'event-emitter/test/has-listeners'>; 110 | } 111 | declare module 'event-emitter/test/index.js' { 112 | declare module.exports: $Exports<'event-emitter/test/index'>; 113 | } 114 | declare module 'event-emitter/test/pipe.js' { 115 | declare module.exports: $Exports<'event-emitter/test/pipe'>; 116 | } 117 | declare module 'event-emitter/test/unify.js' { 118 | declare module.exports: $Exports<'event-emitter/test/unify'>; 119 | } 120 | declare module 'event-emitter/unify.js' { 121 | declare module.exports: $Exports<'event-emitter/unify'>; 122 | } 123 | -------------------------------------------------------------------------------- /flow-typed/json-to-pretty-yaml.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: a65f8ee05f35bc382c3b0f8740bc609d 2 | // flow-typed version: <>/json-to-pretty-yaml_v1.2.2/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'json-to-pretty-yaml' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'json-to-pretty-yaml' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'json-to-pretty-yaml/index.test' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'json-to-pretty-yaml/index' { 31 | declare module.exports: $Exports<'json-to-pretty-yaml'>; 32 | } 33 | declare module 'json-to-pretty-yaml/index.js' { 34 | declare module.exports: $Exports<'json-to-pretty-yaml'>; 35 | } 36 | declare module 'json-to-pretty-yaml/index.test.js' { 37 | declare module.exports: $Exports<'json-to-pretty-yaml/index.test'>; 38 | } 39 | -------------------------------------------------------------------------------- /flow-typed/mkdirp-promise_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 65e18196703cbb222ea294226e99826d 2 | // flow-typed version: <>/mkdirp-promise_v5.0.1/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'mkdirp-promise' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'mkdirp-promise' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'mkdirp-promise/lib/index' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'mkdirp-promise/lib/index.js' { 31 | declare module.exports: $Exports<'mkdirp-promise/lib/index'>; 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/mz_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: ed29f42bf4f4916e4f3ba1f5e7343c9d 2 | // flow-typed version: <>/mz_v2.7.0/flow_v0.81.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'mz' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'mz' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'mz/child_process' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'mz/crypto' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'mz/dns' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'mz/fs' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'mz/readline' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'mz/zlib' { 46 | declare module.exports: any; 47 | } 48 | 49 | // Filename aliases 50 | declare module 'mz/child_process.js' { 51 | declare module.exports: $Exports<'mz/child_process'>; 52 | } 53 | declare module 'mz/crypto.js' { 54 | declare module.exports: $Exports<'mz/crypto'>; 55 | } 56 | declare module 'mz/dns.js' { 57 | declare module.exports: $Exports<'mz/dns'>; 58 | } 59 | declare module 'mz/fs.js' { 60 | declare module.exports: $Exports<'mz/fs'>; 61 | } 62 | declare module 'mz/index' { 63 | declare module.exports: $Exports<'mz'>; 64 | } 65 | declare module 'mz/index.js' { 66 | declare module.exports: $Exports<'mz'>; 67 | } 68 | declare module 'mz/readline.js' { 69 | declare module.exports: $Exports<'mz/readline'>; 70 | } 71 | declare module 'mz/zlib.js' { 72 | declare module.exports: $Exports<'mz/zlib'>; 73 | } 74 | -------------------------------------------------------------------------------- /flow-typed/readline-promise.js: -------------------------------------------------------------------------------- 1 | declare module 'readline-promise' { 2 | 3 | declare class ReadLine { 4 | questionAsync(prompt: string): Promise; 5 | write(text: string): void; 6 | } 7 | 8 | declare module.exports: { 9 | createInterface({ 10 | input: Object, 11 | output: Object, 12 | terminal: boolean 13 | }): ReadLine; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messagefeed", 3 | "version": "0.0.1", 4 | "description": "", 5 | "testnetDefaultChannel": "v1.1.1", 6 | "scripts": { 7 | "message-cli": "babel-node src/cli/message-feed.js", 8 | "poll-cli": "npm run build:poll-cli && node dist/cli/prediction-poll.js", 9 | "lint": "npm run pretty && eslint .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "flow": "flow", 12 | "server": "node dist/server.js", 13 | "dev-server": "npm run builddev && node dist/server.js", 14 | "flow:watch": "watch 'flow' . --wait=1 --ignoreDirectoryPattern=/doc/", 15 | "lint:watch": "watch 'npm run lint:fix' . --wait=1", 16 | "test": "npm run lint && echo TODO enable: npm run flow", 17 | "bpf-sdk:update": "solana-bpf-sdk-install node_modules/@solana/web3.js", 18 | "build": "webpack --mode=production", 19 | "builddev": "webpack --mode=development", 20 | "dev": "webpack-dev-server --config ./webpack.config.js --mode development", 21 | "build:poll-cli": "webpack --mode development --config webpack.cli.js", 22 | "build:bpf-c": "V=1 make -C bpf-c-programs/messagefeed", 23 | "clean:bpf-c": "make -C bpf-c-programs/messagefeed clean", 24 | "build:bpf-rust": "npm run build:bpf-rust-mf && npm run build:bpf-rust-pp", 25 | "clean:bpf-rust": "npm run clean:bpf-rust-mf && npm run clean:bpf-rust-pp", 26 | "build:bpf-rust-mf": "./bpf-rust-programs/messagefeed/do.sh build", 27 | "clean:bpf-rust-mf": "./bpf-rust-programs/messagefeed/do.sh clean", 28 | "build:bpf-rust-pp": "./bpf-rust-programs/prediction-poll/program/do.sh build", 29 | "clean:bpf-rust-pp": "./bpf-rust-programs/prediction-poll/program/do.sh clean", 30 | "localnet:update": "solana-localnet update", 31 | "localnet:up": "set -x; solana-localnet down; set -e; solana-localnet up", 32 | "localnet:down": "solana-localnet down", 33 | "localnet:logs": "solana-localnet logs -f", 34 | "pretty": "prettier --write '{,src/**/}*.js'", 35 | "postinstall": "npm run build; npm run bpf-sdk:update" 36 | }, 37 | "keywords": [], 38 | "author": "", 39 | "license": "MIT", 40 | "devDependencies": { 41 | "@babel/core": "^7.8.3", 42 | "@babel/node": "^7.8.7", 43 | "@babel/plugin-proposal-class-properties": "^7.8.3", 44 | "@babel/plugin-transform-runtime": "^7.8.3", 45 | "@babel/preset-env": "^7.8.7", 46 | "@babel/preset-flow": "^7.8.3", 47 | "@babel/preset-react": "^7.8.3", 48 | "@wasm-tool/node": "^0.1.0", 49 | "@wasm-tool/wasm-pack-plugin": "^1.2.0", 50 | "babel-eslint": "^10.1.0", 51 | "babel-loader": "^8.0.6", 52 | "copy-webpack-plugin": "^5.1.1", 53 | "css-loader": "^3.4.2", 54 | "eslint": "^6.8.0", 55 | "eslint-loader": "^3.0.0", 56 | "eslint-plugin-import": "^2.20.1", 57 | "eslint-plugin-react": "^7.19.0", 58 | "eslint-config-prettier": "^6.10.0", 59 | "eslint-plugin-prettier": "^3.1.2", 60 | "flow-bin": "0.120.1", 61 | "flow-typed": "^3.0.0", 62 | "prettier": "^1.14.3", 63 | "watch": "^1.0.2", 64 | "webpack": "^4.42.0", 65 | "webpack-cli": "^3.3.11", 66 | "webpack-dev-server": "^3.10.3", 67 | "webpack-filter-warnings-plugin": "^1.2.1" 68 | }, 69 | "dependencies": { 70 | "@material-ui/core": "^4.9.9", 71 | "@material-ui/icons": "^4.9.1", 72 | "@solana/web3.js": "^0.42.2", 73 | "buffer-layout": "^1.2.0", 74 | "cors": "^2.8.5", 75 | "event-emitter": "^0.3.5", 76 | "express": "^4.17.1", 77 | "haikunator": "^2.1.2", 78 | "http-server": "^0.12.1", 79 | "localforage": "^1.7.3", 80 | "mkdirp-promise": "^5.0.1", 81 | "moment": "^2.22.2", 82 | "mz": "^2.7.0", 83 | "node-fetch": "^2.6.0", 84 | "react": "^16.13.0", 85 | "react-dom": "^16.13.0", 86 | "react-idle-timer": "^4.2.12", 87 | "react-router-dom": "^5.1.2", 88 | "readline-promise": "^1.0.3", 89 | "semver": "^7.1.3", 90 | "superstruct": "^0.8.3" 91 | }, 92 | "engines": { 93 | "node": ">=11" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/cli/bootstrap-poll.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import('./prediction-poll.js').catch(e => 5 | console.error('Error importing `prediction-poll.js`:', e), 6 | ); 7 | -------------------------------------------------------------------------------- /src/cli/message-feed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {Account, Connection} from '@solana/web3.js'; 3 | 4 | import { 5 | refreshMessageFeed, 6 | postMessage, 7 | messageAccountSize, 8 | } from '../programs/message-feed'; 9 | import {getConfig, userLogin} from '../client'; 10 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 11 | import type {Message} from '../programs/message-feed'; 12 | 13 | async function main() { 14 | const text = process.argv.splice(2).join(' '); 15 | 16 | const baseUrl = 'http://localhost:8081'; 17 | const {messageFeed, loginMethod, url, commitment} = await getConfig( 18 | baseUrl + '/config.json', 19 | ); 20 | const {firstMessageKey} = messageFeed; 21 | 22 | console.log('Cluster RPC URL:', url); 23 | const connection = new Connection(url, commitment); 24 | const messages: Array = []; 25 | await refreshMessageFeed(connection, messages, null, firstMessageKey); 26 | 27 | if (text.length > 0) { 28 | if (loginMethod !== 'local') { 29 | throw new Error(`Unsupported login method: ${loginMethod}`); 30 | } 31 | const credentials = {id: new Account().publicKey.toString()}; 32 | const userAccount = await userLogin(baseUrl + '/login', credentials); 33 | const {feeCalculator} = await connection.getRecentBlockhash(); 34 | const postMessageFee = feeCalculator.lamportsPerSignature * 3; // 1 payer and 2 signer keys 35 | const minAccountBalance = await connection.getMinimumBalanceForRentExemption( 36 | messageAccountSize(text), 37 | ); 38 | const payerAccount = await newSystemAccountWithAirdrop( 39 | connection, 40 | postMessageFee + minAccountBalance, 41 | ); 42 | console.log('Posting message:', text); 43 | await postMessage( 44 | connection, 45 | payerAccount, 46 | userAccount, 47 | text, 48 | messages[messages.length - 1].publicKey, 49 | ); 50 | await refreshMessageFeed(connection, messages); 51 | } 52 | 53 | console.log(); 54 | console.log('Message Feed'); 55 | console.log('------------'); 56 | messages.reverse().forEach((message, index) => { 57 | console.log(`Message #${index} from "${message.name}": ${message.text}`); 58 | }); 59 | } 60 | 61 | main() 62 | .catch(err => { 63 | console.error(err); 64 | }) 65 | .then(() => process.exit()); 66 | -------------------------------------------------------------------------------- /src/cli/prediction-poll.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import {Account, Connection, PublicKey} from '@solana/web3.js'; 3 | 4 | import { 5 | refreshPoll, 6 | createPoll, 7 | vote, 8 | claim, 9 | } from '../programs/prediction-poll'; 10 | import {getConfig, userLogin} from '../client'; 11 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 12 | import {sleep} from '../util/sleep'; 13 | 14 | async function main() { 15 | const baseUrl = 'http://localhost:8081'; 16 | const {predictionPoll, url, commitment} = await getConfig( 17 | baseUrl + '/config.json', 18 | ); 19 | const {collection: collectionKey, programId} = predictionPoll; 20 | 21 | console.log('Cluster RPC URL:', url); 22 | const connection = new Connection(url, commitment); 23 | 24 | const credentials = {id: new Account().publicKey.toString()}; 25 | const creatorAccount = await userLogin(baseUrl + '/login', credentials); 26 | const {feeCalculator} = await connection.getRecentBlockhash(); 27 | const wager = 100; 28 | const minAccountBalances = 2000 * 5; // payer + 1 poll + 2 tally + 1 user 29 | const createPollFee = feeCalculator.lamportsPerSignature * 5; // 1 payer + 4 signer keys 30 | const voteFee = feeCalculator.lamportsPerSignature * 2; // 1 payer + 1 signer key 31 | const claimFee = feeCalculator.lamportsPerSignature; // 1 payer 32 | const fees = createPollFee + voteFee + claimFee; 33 | const payerAccount = await newSystemAccountWithAirdrop( 34 | connection, 35 | wager + fees + minAccountBalances, 36 | ); 37 | 38 | console.log(` 39 | Creating new poll... 40 | ------------------------------ 41 | Q. What's your favorite color? 42 | - 1. Green 43 | - 2. Blue 44 | ------------------------------`); 45 | 46 | const [, pollAccount] = await createPoll( 47 | connection, 48 | programId, 49 | collectionKey, 50 | payerAccount, 51 | creatorAccount, 52 | 'What is your favorite color?', 53 | 'Green', 54 | 'Black', 55 | 5, 56 | ); 57 | 58 | let [poll] = await refreshPoll(connection, pollAccount.publicKey); 59 | console.log(`Wagering ${wager} tokens for "${poll.optionA.text}"...`); 60 | await vote( 61 | connection, 62 | programId, 63 | payerAccount, 64 | pollAccount.publicKey, 65 | wager, 66 | new PublicKey(poll.optionA.tallyKey), 67 | ); 68 | await sleep(3000); 69 | 70 | console.log('Refreshing poll...'); 71 | [poll] = await refreshPoll(connection, pollAccount.publicKey); 72 | 73 | console.log('Claiming winnings...'); 74 | await claim(connection, programId, payerAccount, pollAccount.publicKey, poll); 75 | console.log(`You won ${wager} tokens`); 76 | } 77 | 78 | main() 79 | .catch(err => { 80 | console.error(err); 81 | }) 82 | .then(() => process.exit()); 83 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {Account, PublicKey} from '@solana/web3.js'; 3 | import type {Commitment, Cluster} from '@solana/web3.js'; 4 | import fetch from 'node-fetch'; 5 | 6 | import {sleep} from './util/sleep'; 7 | 8 | export type Config = { 9 | messageFeed: MessageFeedConfig, 10 | predictionPoll: PredictionPollConfig, 11 | loginMethod: string, 12 | url: string, 13 | urlTls: string, 14 | cluster: ?Cluster, 15 | walletUrl: string, 16 | commitment: ?Commitment, 17 | }; 18 | 19 | export type MessageFeedConfig = { 20 | programId: PublicKey, 21 | firstMessageKey: PublicKey, 22 | }; 23 | 24 | export type PredictionPollConfig = { 25 | programId: PublicKey, 26 | collection: PublicKey, 27 | }; 28 | 29 | export async function getConfig(configUrl: string): Promise { 30 | for (;;) { 31 | try { 32 | const response = await fetch(configUrl); 33 | const config = await response.json(); 34 | if (!config.loading) { 35 | return { 36 | messageFeed: { 37 | firstMessageKey: new PublicKey(config.messageFeed.firstMessageKey), 38 | programId: new PublicKey(config.messageFeed.programId), 39 | }, 40 | predictionPoll: { 41 | collection: new PublicKey(config.predictionPoll.collection), 42 | programId: new PublicKey(config.predictionPoll.programId), 43 | }, 44 | loginMethod: config.loginMethod, 45 | url: config.url, 46 | urlTls: config.urlTls, 47 | cluster: config.cluster, 48 | walletUrl: config.walletUrl, 49 | commitment: config.commitment, 50 | }; 51 | } 52 | console.log(`Waiting for programs to finish loading...`); 53 | } catch (err) { 54 | console.error(`${err}`); 55 | } 56 | await sleep(1000); 57 | } 58 | } 59 | 60 | export async function userLogin( 61 | loginUrl: string, 62 | credentials: Object, 63 | ): Promise { 64 | const response = await fetch(loginUrl, { 65 | method: 'POST', 66 | headers: {'Content-Type': 'application/json'}, 67 | body: JSON.stringify(credentials), 68 | }); 69 | const json = await response.json(); 70 | return new Account(Uint8Array.from(Buffer.from(json.userAccount, 'hex'))); 71 | } 72 | -------------------------------------------------------------------------------- /src/programs/message-feed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { 3 | Account, 4 | Connection, 5 | SystemProgram, 6 | PublicKey, 7 | Transaction, 8 | sendAndConfirmTransaction, 9 | } from '@solana/web3.js'; 10 | import * as BufferLayout from 'buffer-layout'; 11 | import type {TransactionSignature} from '@solana/web3.js'; 12 | 13 | import {publicKeyToName} from '../util/publickey-to-name'; 14 | 15 | export type Message = { 16 | publicKey: PublicKey, 17 | from: PublicKey, 18 | name: string, 19 | text: string, 20 | }; 21 | 22 | type MessageData = { 23 | nextMessage: PublicKey, 24 | from: PublicKey, 25 | programId: PublicKey, 26 | text: string, 27 | }; 28 | 29 | const publicKeyLayout = (property: string = 'publicKey'): Object => { 30 | return BufferLayout.blob(32, property); 31 | }; 32 | 33 | export const userAccountSize = 1 + 32; // 32 = size of a public key 34 | export function messageAccountSize(text: string): number { 35 | const textBuffer = Buffer.from(text); 36 | return 32 + 32 + 32 + textBuffer.length; // 32 = size of a public key 37 | } 38 | 39 | async function createUserAccount( 40 | connection: Connection, 41 | programId: PublicKey, 42 | payerAccount: Account, 43 | messageAccount: Account, 44 | transaction: Transaction, 45 | ): Promise { 46 | const userAccount = new Account(); 47 | 48 | // Allocate the user account 49 | transaction.add( 50 | SystemProgram.createAccount({ 51 | fromPubkey: payerAccount.publicKey, 52 | newAccountPubkey: userAccount.publicKey, 53 | lamports: await connection.getMinimumBalanceForRentExemption( 54 | userAccountSize, 55 | ), 56 | space: userAccountSize, 57 | programId, 58 | }), 59 | ); 60 | 61 | // Initialize the user account 62 | const keys = [ 63 | {pubkey: userAccount.publicKey, isSigner: true, isWritable: false}, 64 | {pubkey: messageAccount.publicKey, isSigner: true, isWritable: false}, 65 | ]; 66 | transaction.add({ 67 | keys, 68 | programId, 69 | }); 70 | 71 | return userAccount; 72 | } 73 | 74 | export async function createUser( 75 | connection: Connection, 76 | programId: PublicKey, 77 | payerAccount: Account, 78 | messageAccount: Account, 79 | ): Promise { 80 | const transaction = new Transaction(); 81 | const userAccount = await createUserAccount( 82 | connection, 83 | programId, 84 | payerAccount, 85 | messageAccount, 86 | transaction, 87 | ); 88 | await sendAndConfirmTransaction( 89 | connection, 90 | transaction, 91 | payerAccount, 92 | userAccount, 93 | messageAccount, 94 | ); 95 | 96 | return userAccount; 97 | } 98 | 99 | /** 100 | * Checks if a user has been banned 101 | */ 102 | export async function userBanned( 103 | connection: Connection, 104 | user: PublicKey, 105 | ): Promise { 106 | const accountInfo = await connection.getAccountInfo(user); 107 | 108 | const userAccountDataLayout = BufferLayout.struct([ 109 | BufferLayout.u8('banned'), 110 | publicKeyLayout('creator'), 111 | ]); 112 | const userAccountData = userAccountDataLayout.decode(accountInfo.data); 113 | 114 | return userAccountData.banned !== 0; 115 | } 116 | 117 | /** 118 | * Read the contents of a message 119 | */ 120 | async function readMessage( 121 | connection: Connection, 122 | message: PublicKey, 123 | ): Promise { 124 | const accountInfo = await connection.getAccountInfo(message); 125 | 126 | const messageAccountDataLayout = BufferLayout.struct([ 127 | publicKeyLayout('nextMessage'), 128 | publicKeyLayout('from'), 129 | publicKeyLayout('creator'), 130 | BufferLayout.cstr('text'), 131 | ]); 132 | const messageAccountData = messageAccountDataLayout.decode(accountInfo.data); 133 | 134 | return { 135 | nextMessage: new PublicKey(messageAccountData.nextMessage), 136 | from: new PublicKey(messageAccountData.from), 137 | programId: accountInfo.owner, 138 | text: messageAccountData.text, 139 | }; 140 | } 141 | 142 | /** 143 | * Checks a message feed for new messages and loads them into the provided 144 | * messages array. 145 | */ 146 | export async function refreshMessageFeed( 147 | connection: Connection, 148 | messages: Array, 149 | onNewMessage: Function | null, 150 | message: PublicKey | null = null, 151 | ): Promise { 152 | const emptyMessage = new PublicKey(0); 153 | for (;;) { 154 | if (message === null) { 155 | if (messages.length === 0) { 156 | return; 157 | } 158 | const lastMessage = messages[messages.length - 1].publicKey; 159 | const lastMessageData = await readMessage(connection, lastMessage); 160 | message = lastMessageData.nextMessage; 161 | } 162 | 163 | if (message.equals(emptyMessage)) { 164 | return; 165 | } 166 | 167 | console.log(`Loading message ${message}`); 168 | const messageData = await readMessage(connection, message); 169 | messages.push({ 170 | publicKey: message, 171 | from: messageData.from, 172 | name: publicKeyToName(messageData.from), 173 | text: messageData.text, 174 | }); 175 | onNewMessage && onNewMessage(); 176 | message = messageData.nextMessage; 177 | } 178 | } 179 | 180 | /** 181 | * Posts a new message 182 | */ 183 | export async function postMessage( 184 | connection: Connection, 185 | payerAccount: Account, 186 | userAccount: Account, 187 | text: string, 188 | previousMessage: PublicKey, 189 | userToBan: PublicKey | null = null, 190 | ): Promise { 191 | const messageData = await readMessage(connection, previousMessage); 192 | const messageAccount = new Account(); 193 | return postMessageWithProgramId( 194 | connection, 195 | messageData.programId, 196 | payerAccount, 197 | userAccount, 198 | messageAccount, 199 | text, 200 | previousMessage, 201 | userToBan, 202 | ); 203 | } 204 | 205 | export async function postMessageWithProgramId( 206 | connection: Connection, 207 | programId: PublicKey, 208 | payerAccount: Account, 209 | userAccountArg: Account | null, 210 | messageAccount: Account, 211 | text: string, 212 | previousMessagePublicKey: PublicKey | null = null, 213 | userToBan: PublicKey | null = null, 214 | ): Promise { 215 | const transaction = new Transaction(); 216 | const dataSize = messageAccountSize(text); 217 | const textBuffer = Buffer.from(text); 218 | 219 | // Allocate the message account 220 | transaction.add( 221 | SystemProgram.createAccount({ 222 | fromPubkey: payerAccount.publicKey, 223 | newAccountPubkey: messageAccount.publicKey, 224 | lamports: await connection.getMinimumBalanceForRentExemption(dataSize), 225 | space: dataSize, 226 | programId, 227 | }), 228 | ); 229 | 230 | let userAccount = userAccountArg; 231 | if (userAccount === null) { 232 | userAccount = await createUserAccount( 233 | connection, 234 | programId, 235 | payerAccount, 236 | messageAccount, 237 | transaction, 238 | ); 239 | } 240 | 241 | // The second instruction in the transaction posts the message, optionally 242 | // links it to the previous message and optionally bans another user 243 | const keys = [ 244 | {pubkey: userAccount.publicKey, isSigner: true, isWritable: false}, 245 | {pubkey: messageAccount.publicKey, isSigner: true, isWritable: false}, 246 | ]; 247 | if (previousMessagePublicKey) { 248 | keys.push({ 249 | pubkey: previousMessagePublicKey, 250 | isSigner: false, 251 | isWritable: true, 252 | }); 253 | 254 | if (userToBan) { 255 | keys.push({pubkey: userToBan, isSigner: false, isWritable: true}); 256 | } 257 | } 258 | transaction.add({ 259 | keys, 260 | programId, 261 | data: textBuffer, 262 | }); 263 | return await sendAndConfirmTransaction( 264 | connection, 265 | transaction, 266 | payerAccount, 267 | userAccount, 268 | messageAccount, 269 | ); 270 | } 271 | -------------------------------------------------------------------------------- /src/programs/prediction-poll.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { 3 | Account, 4 | Connection, 5 | PublicKey, 6 | SystemProgram, 7 | Transaction, 8 | sendAndConfirmTransaction, 9 | } from '@solana/web3.js'; 10 | import type {TransactionSignature} from '@solana/web3.js'; 11 | 12 | import {Clock, Collection, Command, InitPoll, Poll, Tally} from '../../wasm'; 13 | 14 | /** 15 | * Refreshes a poll collection 16 | */ 17 | export async function refreshCollection( 18 | connection: Connection, 19 | collection: PublicKey, 20 | ): Collection { 21 | const accountInfo = await connection.getAccountInfo(collection); 22 | return Collection.fromData(accountInfo.data); 23 | } 24 | 25 | /** 26 | * Refreshes the clock 27 | */ 28 | export async function refreshClock(connection: Connection): Clock { 29 | const clockKey = getSysvarClockPublicKey(); 30 | const accountInfo = await connection.getAccountInfo(clockKey); 31 | return Clock.fromData(accountInfo.data); 32 | } 33 | 34 | /** 35 | * Fetches poll info 36 | */ 37 | export async function refreshPoll( 38 | connection: Connection, 39 | pollKey: PublicKey, 40 | ): Promise<[Poll, number, [Tally, Tally]]> { 41 | const accountInfo = await connection.getAccountInfo(pollKey); 42 | const poll = Poll.fromData(accountInfo.data); 43 | const [tallyA, tallyB] = await Promise.all( 44 | [poll.optionA, poll.optionB].map(async option => { 45 | const tallyKey = new PublicKey(option.tallyKey); 46 | const tallyInfo = await connection.getAccountInfo(tallyKey); 47 | return Tally.fromData(tallyInfo.data); 48 | }), 49 | ); 50 | return [poll, accountInfo.lamports, [tallyA, tallyB]]; 51 | } 52 | 53 | /** 54 | * Creates a new poll with two options and a block timeout 55 | */ 56 | export async function createPoll( 57 | connection: Connection, 58 | programId: PublicKey, 59 | collectionKey: PublicKey, 60 | payerAccount: Account, 61 | creatorAccount: Account, 62 | header: string, 63 | optionA: string, 64 | optionB: string, 65 | timeout: number, 66 | ): Promise<[TransactionSignature, Account]> { 67 | const transaction = new Transaction(); 68 | 69 | const pollAccount = new Account(); 70 | transaction.add( 71 | SystemProgram.createAccount({ 72 | fromPubkey: payerAccount.publicKey, 73 | newAccountPubkey: pollAccount.publicKey, 74 | lamports: 2000, 75 | space: 1000, // 150 for keys and numbers + 850 for text 76 | programId, 77 | }), 78 | ); 79 | 80 | const tallyAccounts = [new Account(), new Account()]; 81 | for (const tallyAccount of tallyAccounts) { 82 | transaction.add( 83 | SystemProgram.createAccount({ 84 | fromPubkey: payerAccount.publicKey, 85 | newAccountPubkey: tallyAccount.publicKey, 86 | lamports: 2000, 87 | space: 1000, // 30+ votes 88 | programId, 89 | }), 90 | ); 91 | } 92 | 93 | transaction.add({ 94 | keys: [ 95 | {pubkey: creatorAccount.publicKey, isSigner: true, isWritable: false}, 96 | {pubkey: pollAccount.publicKey, isSigner: true, isWritable: true}, 97 | {pubkey: collectionKey, isSigner: false, isWritable: true}, 98 | {pubkey: tallyAccounts[0].publicKey, isSigner: true, isWritable: true}, 99 | {pubkey: tallyAccounts[1].publicKey, isSigner: true, isWritable: true}, 100 | { 101 | pubkey: getSysvarClockPublicKey(), 102 | isSigner: false, 103 | isWritable: false, 104 | }, 105 | ], 106 | programId, 107 | data: Command.initPoll(new InitPoll(header, optionA, optionB, timeout)), 108 | }); 109 | 110 | const signature = await sendAndConfirmTransaction( 111 | connection, 112 | transaction, 113 | payerAccount, 114 | creatorAccount, 115 | pollAccount, 116 | tallyAccounts[0], 117 | tallyAccounts[1], 118 | ); 119 | 120 | return [signature, pollAccount]; 121 | } 122 | 123 | /** 124 | * Submit a vote to a poll 125 | */ 126 | export async function vote( 127 | connection: Connection, 128 | programId: PublicKey, 129 | payerAccount: Account, 130 | poll: PublicKey, 131 | wager: number, 132 | tally: PublicKey, 133 | ): Promise { 134 | const transaction = new Transaction(); 135 | 136 | const userAccount = new Account(); 137 | transaction.add( 138 | SystemProgram.createAccount({ 139 | fromPubkey: payerAccount.publicKey, 140 | newAccountPubkey: userAccount.publicKey, 141 | lamports: wager, 142 | space: 2000, 143 | programId, 144 | }), 145 | ); 146 | 147 | transaction.add({ 148 | keys: [ 149 | {pubkey: userAccount.publicKey, isSigner: true, isWritable: true}, 150 | {pubkey: poll, isSigner: false, isWritable: true}, 151 | {pubkey: tally, isSigner: false, isWritable: true}, 152 | {pubkey: payerAccount.publicKey, isSigner: false, isWritable: false}, 153 | { 154 | pubkey: getSysvarClockPublicKey(), 155 | isSigner: false, 156 | isWritable: false, 157 | }, 158 | ], 159 | programId, 160 | data: Command.submitVote(), 161 | }); 162 | 163 | return await sendAndConfirmTransaction( 164 | connection, 165 | transaction, 166 | payerAccount, 167 | userAccount, 168 | ); 169 | } 170 | 171 | /** 172 | * Submit a claim to an expired poll 173 | */ 174 | export async function claim( 175 | connection: Connection, 176 | programId: PublicKey, 177 | payerAccount: Account, 178 | pollKey: PublicKey, 179 | poll: Poll, 180 | ): Promise { 181 | const tallyKey = 182 | poll.optionA.quantity > poll.optionB.quantity 183 | ? new PublicKey(poll.optionA.tallyKey) 184 | : new PublicKey(poll.optionB.tallyKey); 185 | 186 | const clockKey = getSysvarClockPublicKey(); 187 | const tallyAccount = await connection.getAccountInfo(tallyKey); 188 | const tally = Tally.fromData(tallyAccount.data); 189 | 190 | const transaction = new Transaction(); 191 | const payoutKeys = tally.keys.map(k => { 192 | const pubkey = new PublicKey(k); 193 | return {pubkey, isSigner: false, isWritable: false}; 194 | }); 195 | 196 | transaction.add({ 197 | keys: [ 198 | {pubkey: pollKey, isSigner: false, isWritable: true}, 199 | {pubkey: tallyKey, isSigner: false, isWritable: false}, 200 | {pubkey: clockKey, isSigner: false, isWritable: false}, 201 | ...payoutKeys, 202 | ], 203 | programId, 204 | data: Command.submitClaim(), 205 | }); 206 | 207 | return await sendAndConfirmTransaction(connection, transaction, payerAccount); 208 | } 209 | 210 | /** 211 | * Public key that identifies the Clock Sysvar Account Public Key 212 | */ 213 | export function getSysvarClockPublicKey(): PublicKey { 214 | return new PublicKey('SysvarC1ock11111111111111111111111111111111'); 215 | } 216 | -------------------------------------------------------------------------------- /src/server/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import('./index.js').catch(e => 5 | console.error('Error importing `index.js`:', e), 6 | ); 7 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import path from 'path'; 4 | import gte from 'semver/functions/gte'; 5 | import {Connection} from '@solana/web3.js'; 6 | 7 | import {cluster, url, urlTls, walletUrl} from '../../urls'; 8 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 9 | import MessageController from './message-feed'; 10 | import PollController from './prediction-poll'; 11 | import * as MessageFeedProgram from '../programs/message-feed'; 12 | 13 | const port = process.env.PORT || 8081; 14 | 15 | (async function() { 16 | let commitment; 17 | let connection = new Connection(url); 18 | const version = await connection.getVersion(); 19 | 20 | // commitment params are only supported >= 0.21.0 21 | const solanaCoreVersion = version['solana-core'].split(' ')[0]; 22 | if (gte(solanaCoreVersion, '0.21.0')) { 23 | commitment = 'recent'; 24 | // eslint-disable-next-line require-atomic-updates 25 | connection = new Connection(url, commitment); 26 | } 27 | 28 | const messageController = new MessageController(connection); 29 | const pollController = new PollController(connection); 30 | 31 | const loginMethod = process.env.LOGIN_METHOD || 'local'; 32 | switch (loginMethod) { 33 | case 'local': 34 | case 'google': 35 | break; 36 | default: 37 | throw new Error(`Unknown LOGIN_METHOD: ${loginMethod}`); 38 | } 39 | console.log(`Login method: ${loginMethod}`); 40 | 41 | const app = express(); 42 | app.use(cors()); 43 | app.use(express.json()); // for parsing application/json 44 | app.get('/config.json', async (req, res) => { 45 | const messageMeta = await messageController.getMeta(); 46 | const pollMeta = await pollController.getMeta(); 47 | 48 | const response = { 49 | loading: !messageMeta || !pollMeta, 50 | loginMethod, 51 | url, 52 | urlTls, 53 | walletUrl, 54 | cluster, 55 | }; 56 | 57 | if (commitment) { 58 | Object.assign(response, {commitment}); 59 | } 60 | 61 | if (pollMeta) { 62 | Object.assign(response, { 63 | predictionPoll: { 64 | programId: pollMeta.programId.toString(), 65 | collection: pollMeta.collection.publicKey.toString(), 66 | }, 67 | }); 68 | } 69 | 70 | if (messageMeta) { 71 | Object.assign(response, { 72 | messageFeed: { 73 | programId: messageMeta.programId.toString(), 74 | firstMessageKey: messageMeta.firstMessageAccount.publicKey.toString(), 75 | }, 76 | }); 77 | } 78 | 79 | res.send(JSON.stringify(response)).end(); 80 | }); 81 | 82 | const users = {}; 83 | app.post('/login', async (req, res) => { 84 | const meta = await messageController.getMeta(); 85 | if (!meta) { 86 | res.status(500).send('Loading'); 87 | return; 88 | } 89 | 90 | const credentials = req.body; 91 | console.log('login credentials:', credentials); 92 | let id; 93 | switch (loginMethod) { 94 | case 'google': 95 | throw new Error( 96 | `TODO unimplemented login method: ${this.state.loginMethod}`, 97 | ); 98 | case 'local': { 99 | id = credentials.id; 100 | break; 101 | } 102 | default: 103 | throw new Error(`Unsupported login method: ${this.state.loginMethod}`); 104 | } 105 | 106 | if (id in users) { 107 | console.log(`Account already exists for user ${id}`); 108 | } else { 109 | console.log(`Creating new account for user ${id}`); 110 | const {feeCalculator} = await connection.getRecentBlockhash(); 111 | const fee = feeCalculator.lamportsPerSignature * 3; // 1 payer + 2 signer keys 112 | const minAccountBalance = 1; // 1 user account 113 | 114 | try { 115 | const payerAccount = await newSystemAccountWithAirdrop( 116 | connection, 117 | 100000000 + fee + minAccountBalance, 118 | ); 119 | const userAccount = await MessageFeedProgram.createUser( 120 | connection, 121 | meta.programId, 122 | payerAccount, 123 | meta.firstMessageAccount, 124 | ); 125 | 126 | if (id in users) { 127 | res.status(500).send('Duplicate account'); 128 | return; 129 | } 130 | 131 | // eslint-disable-next-line require-atomic-updates 132 | users[id] = userAccount.secretKey; 133 | } catch (err) { 134 | console.error('Failed to create user', err); 135 | res.status(500).send('Failed to login, try again'); 136 | return; 137 | } 138 | } 139 | res 140 | .send( 141 | JSON.stringify({userAccount: Buffer.from(users[id]).toString('hex')}), 142 | ) 143 | .end(); 144 | }); 145 | 146 | app.use(express.static(path.join(__dirname, '../../dist/static'))); 147 | app.listen(port); 148 | console.log('Cluster RPC URL:', url); 149 | console.log('Listening on port', port); 150 | 151 | // Load the program immediately so the first client doesn't need to wait as long 152 | messageController.reload().catch(err => console.log(err)); 153 | pollController.reload().catch(err => console.log(err)); 154 | })(); 155 | -------------------------------------------------------------------------------- /src/server/message-feed.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import fs from 'mz/fs'; 3 | import path from 'path'; 4 | import {Account, BpfLoader, Connection, PublicKey} from '@solana/web3.js'; 5 | 6 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 7 | import * as Program from '../programs/message-feed'; 8 | 9 | export type MessageFeedMeta = { 10 | programId: PublicKey, 11 | firstMessageAccount: Account, 12 | }; 13 | 14 | /** 15 | * Manages the active instance of a Message Feed program 16 | */ 17 | export default class MessageFeedController { 18 | meta: ?MessageFeedMeta; 19 | loading: boolean; 20 | connection: Connection; 21 | 22 | constructor(connection: Connection) { 23 | this.connection = connection; 24 | } 25 | 26 | async getMeta(): Promise { 27 | if (this.loading) return; 28 | if (this.meta) { 29 | const {firstMessageAccount} = this.meta; 30 | try { 31 | await this.connection.getAccountInfo(firstMessageAccount.publicKey); 32 | return this.meta; 33 | } catch (err) { 34 | console.error( 35 | `getAccountInfo of ${firstMessageAccount.publicKey.toString()} failed: ${err}`, 36 | ); 37 | this.meta = undefined; 38 | } 39 | } 40 | 41 | this.reload(); 42 | } 43 | 44 | async reload() { 45 | this.loading = true; 46 | try { 47 | this.meta = await this.createMessageFeed(); 48 | this.loading = false; 49 | } catch (err) { 50 | console.error(`createMessageFeed failed: ${err}`); 51 | } finally { 52 | this.loading = false; 53 | } 54 | } 55 | 56 | /** 57 | * Creates a new Message Feed. 58 | */ 59 | async createMessageFeed(): Promise { 60 | const programId = await this.loadProgram(); 61 | console.log('Message feed program:', programId.toString()); 62 | console.log('Posting first message...'); 63 | 64 | const firstMessage = 'First post! 💫'; 65 | 66 | const {feeCalculator} = await this.connection.getRecentBlockhash(); 67 | const postMessageFee = 68 | feeCalculator.lamportsPerSignature * 3; /* 1 payer + 2 signer keys */ 69 | const minAccountBalances = 70 | (await this.connection.getMinimumBalanceForRentExemption( 71 | Program.userAccountSize, 72 | )) + 73 | (await this.connection.getMinimumBalanceForRentExemption( 74 | Program.messageAccountSize(firstMessage), 75 | )); 76 | const payerAccount = await newSystemAccountWithAirdrop( 77 | this.connection, 78 | postMessageFee + minAccountBalances, 79 | ); 80 | const firstMessageAccount = new Account(); 81 | await Program.postMessageWithProgramId( 82 | this.connection, 83 | programId, 84 | payerAccount, 85 | null, 86 | firstMessageAccount, 87 | firstMessage, 88 | ); 89 | console.log( 90 | 'First message public key:', 91 | firstMessageAccount.publicKey.toString(), 92 | ); 93 | return { 94 | programId, 95 | firstMessageAccount, 96 | }; 97 | } 98 | 99 | /** 100 | * Load a new instance of the Message Feed program 101 | */ 102 | async loadProgram(): Promise { 103 | const NUM_RETRIES = 100; /* allow some number of retries */ 104 | const elfFile = path.join( 105 | __dirname, 106 | '..', 107 | '..', 108 | 'dist', 109 | 'programs', 110 | 'messagefeed.so', 111 | ); 112 | console.log(`Reading ${elfFile}...`); 113 | const elfData = await fs.readFile(elfFile); 114 | 115 | console.log('Loading Message feed program...'); 116 | const {feeCalculator} = await this.connection.getRecentBlockhash(); 117 | const fees = 118 | feeCalculator.lamportsPerSignature * 119 | (BpfLoader.getMinNumSignatures(elfData.length) + NUM_RETRIES) + 120 | (await this.connection.getMinimumBalanceForRentExemption(elfData.length)); 121 | const loaderAccount = await newSystemAccountWithAirdrop( 122 | this.connection, 123 | fees, 124 | ); 125 | return BpfLoader.load(this.connection, loaderAccount, elfData); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/server/prediction-poll.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import fs from 'mz/fs'; 3 | import path from 'path'; 4 | import { 5 | Account, 6 | BpfLoader, 7 | Connection, 8 | PublicKey, 9 | Transaction, 10 | SystemProgram, 11 | sendAndConfirmTransaction, 12 | } from '@solana/web3.js'; 13 | 14 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 15 | import {Command} from '../../wasm'; 16 | 17 | export type PollMeta = { 18 | programId: PublicKey, 19 | collection: Account, 20 | }; 21 | 22 | /** 23 | * Manages the active instance of a Prediction Poll program 24 | */ 25 | export default class PollController { 26 | meta: ?PollMeta; 27 | loading: boolean; 28 | connection: Connection; 29 | 30 | constructor(connection: Connection) { 31 | this.connection = connection; 32 | } 33 | 34 | async getMeta(): Promise { 35 | if (this.loading) return; 36 | 37 | if (this.meta) { 38 | const {collection} = this.meta; 39 | try { 40 | await this.connection.getAccountInfo(collection.publicKey); 41 | return this.meta; 42 | } catch (err) { 43 | console.error( 44 | `getAccountInfo of programId ${collection.publicKey.toString()} failed: ${err}`, 45 | ); 46 | this.meta = undefined; 47 | } 48 | } 49 | 50 | this.reload(); 51 | } 52 | 53 | async reload() { 54 | this.loading = true; 55 | try { 56 | this.meta = await this.createProgram(); 57 | } catch (err) { 58 | console.error(`create poll program failed: ${err}`); 59 | } finally { 60 | this.loading = false; 61 | } 62 | } 63 | 64 | /** 65 | * Creates a new Prediction Poll collection. 66 | */ 67 | async createProgram(): Promise { 68 | const programId = await this.loadProgram(); 69 | console.log('Prediction Poll program:', programId.toString()); 70 | console.log('Creating collection...'); 71 | 72 | const collection = await this.createCollection(programId); 73 | console.log('Collection public key:', collection.publicKey.toString()); 74 | return {programId, collection}; 75 | } 76 | 77 | /** 78 | * Creates a new Prediction Poll collection. 79 | */ 80 | async createCollection(programId: PublicKey): Promise { 81 | const dataSize = 1500; // 50+ polls 82 | const {feeCalculator} = await this.connection.getRecentBlockhash(); 83 | const fee = feeCalculator.lamportsPerSignature * 2; // 1 payer + 1 signer key 84 | const minimumBalance = await this.connection.getMinimumBalanceForRentExemption( 85 | dataSize, 86 | ); 87 | const programFunds = 2000 * 1000; // 1000 Polls 88 | const payerAccount = await newSystemAccountWithAirdrop( 89 | this.connection, 90 | minimumBalance + programFunds + fee, 91 | ); 92 | 93 | const collectionAccount = new Account(); 94 | const transaction = new Transaction(); 95 | transaction.add( 96 | SystemProgram.createAccount({ 97 | fromPubkey: payerAccount.publicKey, 98 | newAccountPubkey: collectionAccount.publicKey, 99 | lamports: minimumBalance, 100 | space: dataSize, 101 | programId, 102 | }), 103 | ); 104 | 105 | transaction.add({ 106 | keys: [ 107 | { 108 | pubkey: collectionAccount.publicKey, 109 | isSigner: true, 110 | isWritable: true, 111 | }, 112 | ], 113 | programId, 114 | data: Command.initCollection(), 115 | }); 116 | 117 | await sendAndConfirmTransaction( 118 | this.connection, 119 | transaction, 120 | payerAccount, 121 | collectionAccount, 122 | ); 123 | 124 | return collectionAccount; 125 | } 126 | 127 | /** 128 | * Load a new instance of the Prediction Poll program 129 | */ 130 | async loadProgram(): Promise { 131 | const NUM_RETRIES = 500; /* allow some number of retries */ 132 | const elfFile = path.join( 133 | __dirname, 134 | '..', 135 | '..', 136 | 'dist', 137 | 'programs', 138 | 'prediction_poll.so', 139 | ); 140 | console.log(`Reading ${elfFile}...`); 141 | const elfData = await fs.readFile(elfFile); 142 | 143 | const {feeCalculator} = await this.connection.getRecentBlockhash(); 144 | const fees = 145 | feeCalculator.lamportsPerSignature * 146 | (BpfLoader.getMinNumSignatures(elfData.length) + NUM_RETRIES) + 147 | (await this.connection.getMinimumBalanceForRentExemption(elfData.length)); 148 | 149 | console.log('Loading Poll program...'); 150 | const loaderAccount = await newSystemAccountWithAirdrop( 151 | this.connection, 152 | fees, 153 | ); 154 | return BpfLoader.load(this.connection, loaderAccount, elfData); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/util/new-system-account-with-airdrop.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import {Account, Connection} from '@solana/web3.js'; 4 | 5 | /** 6 | * Create a new system account and airdrop it some lamports 7 | * 8 | * @private 9 | */ 10 | export async function newSystemAccountWithAirdrop( 11 | connection: Connection, 12 | lamports: number = 1, 13 | ): Promise { 14 | const account = new Account(); 15 | await connection.requestAirdrop(account.publicKey, lamports); 16 | return account; 17 | } 18 | -------------------------------------------------------------------------------- /src/util/publickey-to-name.js: -------------------------------------------------------------------------------- 1 | import Haikunator from 'haikunator'; 2 | import type {PublicKey} from '@solana/web3.js'; 3 | 4 | export function publicKeyToName(publicKey: PublicKey): string { 5 | const haikunator = new Haikunator({ 6 | seed: publicKey.toString(), 7 | }); 8 | return haikunator.haikunate(); 9 | } 10 | -------------------------------------------------------------------------------- /src/util/sleep.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // zzz 4 | export function sleep(ms: number): Promise { 5 | return new Promise(resolve => setTimeout(resolve, ms)); 6 | } 7 | -------------------------------------------------------------------------------- /src/webapp/api/clock.js: -------------------------------------------------------------------------------- 1 | import {refreshClock} from '../../programs/prediction-poll'; 2 | 3 | export default class ClockApi { 4 | constructor() { 5 | this.clock = 0; 6 | } 7 | 8 | updateConfig(connection) { 9 | this.connection = connection; 10 | } 11 | 12 | subscribe(onClock) { 13 | this.clockCallback = onClock; 14 | if (this.clock > 0) { 15 | this.clockCallback(this.clock); 16 | } 17 | this.pollClock(onClock); 18 | } 19 | 20 | unsubscribe() { 21 | this.clockCallback = null; 22 | } 23 | 24 | async pollClock(callback) { 25 | if (callback !== this.clockCallback) return; 26 | if (this.connection) { 27 | try { 28 | const clock = await refreshClock(this.connection); 29 | this.clock = parseInt(clock.slot.toString()); 30 | if (this.clockCallback) { 31 | this.clockCallback(this.clock); 32 | } 33 | } catch (err) { 34 | console.error(`pollClock error: ${err}`); 35 | } 36 | } 37 | 38 | setTimeout(() => this.pollClock(callback), 250); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/webapp/api/index.js: -------------------------------------------------------------------------------- 1 | import {Account, Connection} from '@solana/web3.js'; 2 | import type {Cluster} from '@solana/web3.js'; 3 | import localforage from 'localforage'; 4 | 5 | import {getConfig, userLogin} from '../../client'; 6 | import ClockApi from './clock'; 7 | import MessageFeedApi from './message-feed'; 8 | import PredictionPollApi from './prediction-poll'; 9 | 10 | export default class Api { 11 | constructor() { 12 | let baseUrl = window.location.origin; 13 | let hostname = window.location.hostname; 14 | switch (hostname) { 15 | case 'localhost': 16 | case '127.0.0.1': 17 | case '0.0.0.0': 18 | baseUrl = `http://${hostname}:${process.env.PORT || 8081}`; 19 | } 20 | 21 | this.clock = new ClockApi(); 22 | this.messageFeed = new MessageFeedApi(); 23 | this.predictionPoll = new PredictionPollApi(); 24 | this.configUrl = baseUrl + '/config.json'; 25 | this.loginUrl = baseUrl + '/login'; 26 | } 27 | 28 | subscribeBalance(onBalance) { 29 | this.balanceCallback = onBalance; 30 | this.pollBalance(onBalance); 31 | } 32 | 33 | subscribeConfig(onConfig) { 34 | this.configCallback = onConfig; 35 | this.pollConfig(onConfig); 36 | } 37 | 38 | subscribeMessages(onMessages) { 39 | this.messageFeed.subscribe(onMessages); 40 | } 41 | 42 | subscribePolls(onPolls) { 43 | this.predictionPoll.subscribe(onPolls); 44 | } 45 | 46 | subscribeClock(onClock) { 47 | this.clock.subscribe(onClock); 48 | } 49 | 50 | unsubscribe() { 51 | this.balanceCallback = null; 52 | this.configCallback = null; 53 | this.clock.unsubscribe(); 54 | this.messageFeed.unsubscribe(); 55 | this.predictionPoll.unsubscribe(); 56 | } 57 | 58 | explorerUrlBuilder(cluster: ?Cluster) { 59 | return path => { 60 | let params; 61 | if (cluster) { 62 | params = `?cluster=${cluster}`; 63 | } else { 64 | params = `?clusterUrl=${this.connectionUrl}`; 65 | } 66 | 67 | return `https://explorer.solana.com/${path}${params}`; 68 | }; 69 | } 70 | 71 | // Periodically polls for a new program id, which indicates either a cluster reset 72 | // or new message feed server deployment 73 | async pollConfig(callback) { 74 | if (callback !== this.configCallback) return; 75 | try { 76 | const { 77 | loginMethod, 78 | messageFeed, 79 | predictionPoll, 80 | cluster, 81 | urlTls, 82 | walletUrl, 83 | commitment, 84 | } = await getConfig(this.configUrl); 85 | 86 | this.connection = new Connection(urlTls, commitment); 87 | this.connectionUrl = urlTls; 88 | this.walletUrl = walletUrl; 89 | this.clock.updateConfig(this.connection); 90 | this.connection.getRecentBlockhash().then(({feeCalculator}) => { 91 | this.feeCalculator = feeCalculator; 92 | }); 93 | const accountStorageOverhead = 128; 94 | this.connection 95 | .getMinimumBalanceForRentExemption(accountStorageOverhead) 96 | .then(minimumBalanceForRentExemption => { 97 | this.minimumBalanceForRentExemption = minimumBalanceForRentExemption; 98 | }); 99 | 100 | const explorerUrlBuilder = this.explorerUrlBuilder(cluster); 101 | const response = {explorerUrlBuilder, loginMethod, walletUrl}; 102 | 103 | try { 104 | Object.assign( 105 | response, 106 | await this.messageFeed.updateConfig(this.connection, messageFeed), 107 | ); 108 | } catch (err) { 109 | console.error('failed to update message feed config', err); 110 | } 111 | 112 | Object.assign( 113 | response, 114 | this.predictionPoll.updateConfig(this.connection, predictionPoll), 115 | ); 116 | 117 | if (this.configCallback) { 118 | this.configCallback(response); 119 | } 120 | } catch (err) { 121 | console.error('config poll error', err); 122 | } 123 | setTimeout(() => this.pollConfig(callback), 10 * 1000); 124 | } 125 | 126 | async pollBalance(callback) { 127 | if (callback !== this.balanceCallback) return; 128 | if (this.connection) { 129 | const payerAccount = await this.getPayerAccount(); 130 | try { 131 | const payerBalance = await this.connection.getBalance( 132 | payerAccount.publicKey, 133 | ); 134 | if (this.balanceCallback) { 135 | this.balanceCallback(payerBalance, payerAccount.publicKey); 136 | } 137 | } catch (err) { 138 | console.error('Failed to refresh balance', err); 139 | } 140 | } 141 | setTimeout(() => this.pollBalance(callback), 1000); 142 | } 143 | 144 | amountToRequest() { 145 | // Request enough to create 100 rent exempt message accounts, that should be plenty 146 | if (this.feeCalculator && this.feeCalculator.lamportsPerSignature) { 147 | return ( 148 | 100 * 149 | (this.feeCalculator.lamportsPerSignature + 150 | this.minimumBalanceForRentExemption) - 151 | this.feeCalculator.lamportsPerSignature 152 | ); 153 | } 154 | // Otherwise some large number 155 | return 10000000 - 5000; // - default fee used to transfer 156 | } 157 | 158 | async requestFunds(callback) { 159 | this.walletCallback = callback; 160 | 161 | const payerAccount = await this.getPayerAccount(); 162 | const windowName = 'wallet'; 163 | const windowOptions = 164 | 'toolbar=no, location=no, status=no, menubar=no, scrollbars=yes, resizable=yes, width=500, height=1200'; 165 | if (!this.walletWindow) { 166 | window.addEventListener('message', e => this.onWalletMessage(e)); 167 | this.walletWindow = window.open( 168 | this.walletUrl, 169 | windowName, 170 | windowOptions, 171 | ); 172 | } else { 173 | if (this.walletWindow.closed) { 174 | this.walletWindow = window.open( 175 | this.walletUrl, 176 | windowName, 177 | windowOptions, 178 | ); 179 | } else { 180 | this.walletWindow.focus(); 181 | this.walletWindow.postMessage( 182 | { 183 | method: 'addFunds', 184 | params: { 185 | pubkey: payerAccount.publicKey.toString(), 186 | amount: this.amountToRequest(), 187 | network: this.connectionUrl, 188 | }, 189 | }, 190 | this.walletUrl, 191 | ); 192 | } 193 | } 194 | } 195 | 196 | async onWalletMessage(e) { 197 | if (e.origin === window.location.origin) return; 198 | 199 | const payerAccount = await this.getPayerAccount(); 200 | if (e.data) { 201 | switch (e.data.method) { 202 | case 'ready': { 203 | this.walletWindow.postMessage( 204 | { 205 | method: 'addFunds', 206 | params: { 207 | pubkey: payerAccount.publicKey.toString(), 208 | amount: this.amountToRequest(), 209 | network: this.connectionUrl, 210 | }, 211 | }, 212 | this.walletUrl, 213 | ); 214 | break; 215 | } 216 | case 'addFundsResponse': { 217 | const params = e.data.params; 218 | const response = {snackMessage: 'Unexpected wallet response'}; 219 | if (params.amount && params.signature) { 220 | Object.assign(response, { 221 | snackMessage: `Received ${params.amount} from wallet`, 222 | transactionSignature: params.signature, 223 | }); 224 | } else if (params.err) { 225 | response.snackMessage = 'Funds request failed'; 226 | } 227 | if (this.walletCallback) { 228 | this.walletCallback(response); 229 | } 230 | break; 231 | } 232 | } 233 | } 234 | } 235 | 236 | async postMessage(newMessage, userToBan) { 237 | const payerAccount = await this.getPayerAccount(); 238 | return await this.messageFeed.postMessage( 239 | payerAccount, 240 | newMessage, 241 | userToBan, 242 | ); 243 | } 244 | 245 | async createPoll(header, optionA, optionB, timeout) { 246 | const payerAccount = await this.getPayerAccount(); 247 | const creatorAccount = this.messageFeed.getUserAccount(); 248 | return await this.predictionPoll.createPoll( 249 | payerAccount, 250 | creatorAccount, 251 | header, 252 | optionA, 253 | optionB, 254 | timeout, 255 | ); 256 | } 257 | 258 | async vote(pollKey, wager, tally) { 259 | const payerAccount = await this.getPayerAccount(); 260 | return await this.predictionPoll.vote(payerAccount, pollKey, wager, tally); 261 | } 262 | 263 | async claim(poll, pollKey) { 264 | const payerAccount = await this.getPayerAccount(); 265 | return await this.predictionPoll.claim(payerAccount, poll, pollKey); 266 | } 267 | 268 | async isUserBanned(userKey) { 269 | return await this.messageFeed.isUserBanned(userKey); 270 | } 271 | 272 | async login(loginMethod) { 273 | if (!this.connection) { 274 | throw new Error('Cannot login while disconnected'); 275 | } 276 | 277 | let userAccount; 278 | switch (loginMethod) { 279 | case 'google': 280 | throw new Error(`TODO unimplemented login method: ${loginMethod}`); 281 | case 'local': { 282 | const credentials = {id: new Account().publicKey.toString()}; 283 | userAccount = await userLogin(this.loginUrl, credentials); 284 | break; 285 | } 286 | default: 287 | throw new Error(`Unsupported login method: ${loginMethod}`); 288 | } 289 | 290 | await this.messageFeed.saveUser(userAccount); 291 | 292 | return userAccount; 293 | } 294 | 295 | async getPayerAccount() { 296 | if (!this.payerAccount) { 297 | this.payerAccount = await this.loadPayerAccount(); 298 | } 299 | return this.payerAccount; 300 | } 301 | 302 | async loadPayerAccount() { 303 | let payerAccount = new Account(); 304 | try { 305 | const savedPayerAccount = await localforage.getItem('payerAccount'); 306 | if (savedPayerAccount !== null) { 307 | payerAccount = new Account(savedPayerAccount); 308 | console.log( 309 | 'Restored payer account:', 310 | payerAccount.publicKey.toString(), 311 | ); 312 | } else { 313 | payerAccount = new Account(); 314 | await localforage.setItem('payerAccount', payerAccount.secretKey); 315 | } 316 | } catch (err) { 317 | console.error(`Unable to load payer account from localforage: ${err}`); 318 | } 319 | 320 | return payerAccount; 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/webapp/api/message-feed.js: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage'; 2 | import {Account, PublicKey} from '@solana/web3.js'; 3 | 4 | import { 5 | postMessage, 6 | refreshMessageFeed, 7 | userBanned, 8 | } from '../../programs/message-feed'; 9 | 10 | export default class MessageFeedApi { 11 | constructor() { 12 | this.messages = []; 13 | this.postCount = 0; 14 | } 15 | 16 | subscribe(onMessages) { 17 | this.messageCallback = onMessages; 18 | if (this.messages.length > 0) { 19 | this.messageCallback(this.messages); 20 | } 21 | this.pollMessages(onMessages); 22 | } 23 | 24 | unsubscribe() { 25 | this.messageCallback = null; 26 | } 27 | 28 | async updateConfig(connection, config) { 29 | this.connection = connection; 30 | const {programId, firstMessageKey} = config; 31 | if (!this.programId || !programId.equals(this.programId)) { 32 | this.programId = programId; 33 | this.firstMessageKey = firstMessageKey; 34 | this.messages = []; 35 | this.userAccount = await this.loadUserAccount(programId); 36 | return { 37 | ...config, 38 | loadingMessages: true, 39 | messages: this.messages, 40 | userAccount: this.userAccount, 41 | }; 42 | } 43 | } 44 | 45 | async saveUser(userAccount) { 46 | this.userAccount = userAccount; 47 | try { 48 | console.log('Saved user account:', userAccount.publicKey.toString()); 49 | await localforage.setItem('programId', this.programId.toString()); 50 | await localforage.setItem('userAccount', userAccount.secretKey); 51 | } catch (err) { 52 | console.error(`Unable to store user account in localforage: ${err}`); 53 | } 54 | } 55 | 56 | async isUserBanned(userKey) { 57 | return await userBanned(this.connection, userKey); 58 | } 59 | 60 | getUserAccount() { 61 | return this.userAccount; 62 | } 63 | 64 | async loadUserAccount(programId) { 65 | try { 66 | const savedProgramId = await localforage.getItem('programId'); 67 | const savedUserAccount = await localforage.getItem('userAccount'); 68 | if ( 69 | savedUserAccount && 70 | savedProgramId && 71 | programId.equals(new PublicKey(savedProgramId)) 72 | ) { 73 | const userAccount = new Account(savedUserAccount); 74 | console.log('Restored user account:', userAccount.publicKey.toString()); 75 | return userAccount; 76 | } 77 | } catch (err) { 78 | console.error(`Unable to load user account from localforage: ${err}`); 79 | } 80 | } 81 | 82 | // Refresh messages. 83 | // TODO: Rewrite this function to use the solana-web3.js websocket pubsub 84 | // instead of polling 85 | async pollMessages(callback) { 86 | const onUpdate = () => { 87 | console.log('updateMessage'); 88 | if (this.messageCallback) { 89 | this.messageCallback(this.messages); 90 | } 91 | }; 92 | 93 | try { 94 | for (;;) { 95 | if (callback !== this.messageCallback) return; 96 | if (!this.connection) break; 97 | const {postCount} = this; 98 | await refreshMessageFeed( 99 | this.connection, 100 | this.messages, 101 | () => onUpdate(), 102 | this.messages.length === 0 ? this.firstMessageKey : null, 103 | ); 104 | if (postCount === this.postCount) { 105 | break; 106 | } 107 | 108 | console.log('Post count increased, refreshing'); 109 | } 110 | } catch (err) { 111 | console.error(`pollMessages error: ${err}`); 112 | } 113 | 114 | setTimeout(() => this.pollMessages(callback), this.posting ? 250 : 1000); 115 | } 116 | 117 | async postMessage(payerAccount, newMessage, userToBan = null) { 118 | this.posting = true; 119 | try { 120 | if (await userBanned(this.connection, this.userAccount.publicKey)) { 121 | return { 122 | snackMessage: 'You are banned', 123 | }; 124 | } 125 | 126 | const lastMessageKey = this.messages[this.messages.length - 1].publicKey; 127 | const transactionSignature = await postMessage( 128 | this.connection, 129 | payerAccount, 130 | this.userAccount, 131 | newMessage, 132 | lastMessageKey, 133 | userToBan, 134 | ); 135 | this.postCount++; 136 | 137 | return { 138 | snackMessage: 'Message posted', 139 | transactionSignature, 140 | }; 141 | } catch (err) { 142 | console.error(`Failed to post message: ${err}`); 143 | return { 144 | snackMessage: 'An error occured when posting the message', 145 | }; 146 | } finally { 147 | this.posting = false; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/webapp/api/prediction-poll.js: -------------------------------------------------------------------------------- 1 | import {PublicKey} from '@solana/web3.js'; 2 | 3 | import { 4 | refreshCollection, 5 | createPoll, 6 | refreshPoll, 7 | claim, 8 | vote, 9 | } from '../../programs/prediction-poll'; 10 | 11 | export default class PredictionPollApi { 12 | constructor() { 13 | this.polls = []; 14 | } 15 | 16 | subscribe(onPolls) { 17 | this.pollCallback = onPolls; 18 | if (this.polls.length > 0) { 19 | this.pollCallback(this.polls); 20 | } 21 | this.pollCollection(onPolls); 22 | } 23 | 24 | unsubscribe() { 25 | this.pollCallback = null; 26 | } 27 | 28 | async pollCollection(callback) { 29 | if (callback !== this.pollCallback) return; 30 | if (this.connection) { 31 | try { 32 | const collection = await refreshCollection( 33 | this.connection, 34 | this.collection, 35 | ); 36 | if (this.pollCallback) { 37 | const pollKeys = collection.getPolls().map(key => new PublicKey(key)); 38 | // remove old polls 39 | this.polls = this.polls.filter(([k]) => { 40 | return pollKeys.some(pollKey => k.equals(pollKey)); 41 | }); 42 | this.pollCallback(this.polls); 43 | this.refreshPolls(pollKeys); 44 | } 45 | } catch (err) { 46 | console.error(`pollCollection error: ${err}`); 47 | } 48 | } 49 | 50 | setTimeout(() => this.pollCollection(callback), this.creating ? 250 : 1000); 51 | } 52 | 53 | async refreshPolls(pollKeys) { 54 | if (this.refreshingPolls) return; 55 | this.refreshingPolls = true; 56 | for (const pollKey of pollKeys) { 57 | if (this.pollCallback) { 58 | try { 59 | const [poll, balance, tallies] = await refreshPoll( 60 | this.connection, 61 | pollKey, 62 | ); 63 | const pollIndex = this.polls.findIndex(([k]) => k.equals(pollKey)); 64 | const pollTuple = [pollKey, poll, balance, tallies]; 65 | if (pollIndex >= 0) { 66 | this.polls.splice(pollIndex, 1, pollTuple); 67 | } else { 68 | this.polls.push(pollTuple); 69 | } 70 | this.pollCallback(this.polls); 71 | } catch (err) { 72 | console.error(`Failed to fetch poll ${pollKey.toString()}`, err); 73 | } 74 | } 75 | } 76 | this.refreshingPolls = false; 77 | } 78 | 79 | updateConfig(connection, config) { 80 | this.connection = connection; 81 | const {programId, collection} = config; 82 | if (!this.programId || !programId.equals(this.programId)) { 83 | this.programId = programId; 84 | this.collection = collection; 85 | this.polls = []; 86 | return { 87 | predictionPoll: { 88 | polls: this.polls, 89 | programId, 90 | collection, 91 | }, 92 | }; 93 | } 94 | } 95 | 96 | async vote(payerAccount, pollKey, wager, tallyKey) { 97 | try { 98 | const transactionSignature = await vote( 99 | this.connection, 100 | this.programId, 101 | payerAccount, 102 | pollKey, 103 | wager, 104 | tallyKey, 105 | ); 106 | 107 | return { 108 | snackMessage: 'Vote submitted', 109 | transactionSignature, 110 | }; 111 | } catch (err) { 112 | console.error(`Failed to vote on poll: ${err}`); 113 | return { 114 | snackMessage: 'An error occured when voting', 115 | }; 116 | } 117 | } 118 | 119 | async claim(payerAccount, poll, pollKey) { 120 | try { 121 | const transactionSignature = await claim( 122 | this.connection, 123 | this.programId, 124 | payerAccount, 125 | pollKey, 126 | poll, 127 | ); 128 | 129 | return { 130 | snackMessage: 'Claim submitted', 131 | transactionSignature, 132 | }; 133 | } catch (err) { 134 | console.error(`Failed to submit claim: ${err}`); 135 | return { 136 | snackMessage: 'An error occured when submitting claim', 137 | }; 138 | } 139 | } 140 | 141 | async createPoll( 142 | payerAccount, 143 | creatorAccount, 144 | header, 145 | optionA, 146 | optionB, 147 | timeout, 148 | ) { 149 | if (!creatorAccount) { 150 | return { 151 | snackMessage: 'Only logged in users can create a poll', 152 | }; 153 | } 154 | 155 | this.creating = true; 156 | try { 157 | const [transactionSignature] = await createPoll( 158 | this.connection, 159 | this.programId, 160 | this.collection, 161 | payerAccount, 162 | creatorAccount, 163 | header, 164 | optionA, 165 | optionB, 166 | timeout, 167 | ); 168 | 169 | return { 170 | snackMessage: 'Poll created', 171 | transactionSignature, 172 | }; 173 | } catch (err) { 174 | console.error(`Failed to create poll: ${err}`); 175 | return { 176 | snackMessage: 'An error occured when creating the poll', 177 | }; 178 | } finally { 179 | this.creating = false; 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/webapp/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import('./webapp.js').catch(e => 5 | console.error('Error importing `index.js`:', e), 6 | ); 7 | -------------------------------------------------------------------------------- /src/webapp/components/create-poll.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Fab from '@material-ui/core/Fab'; 3 | import AddIcon from '@material-ui/icons/Add'; 4 | import Button from '@material-ui/core/Button'; 5 | import TextField from '@material-ui/core/TextField'; 6 | import Dialog from '@material-ui/core/Dialog'; 7 | import DialogActions from '@material-ui/core/DialogActions'; 8 | import DialogContent from '@material-ui/core/DialogContent'; 9 | import DialogTitle from '@material-ui/core/DialogTitle'; 10 | import PropTypes from 'prop-types'; 11 | import Zoom from '@material-ui/core/Zoom'; 12 | import {withStyles} from '@material-ui/core/styles'; 13 | 14 | const styles = theme => ({ 15 | fab: { 16 | position: 'fixed', 17 | zIndex: 100, 18 | bottom: theme.spacing.unit * 2, 19 | right: theme.spacing.unit * 2, 20 | }, 21 | field: { 22 | marginBottom: theme.spacing.unit * 2, 23 | }, 24 | }); 25 | 26 | class CreatePollDialog extends React.Component { 27 | constructor(props) { 28 | super(props); 29 | this.state = { 30 | open: false, 31 | header: '', 32 | optionA: '', 33 | optionB: '', 34 | timeout: 3600, 35 | }; 36 | } 37 | 38 | handleOpen() { 39 | this.setState({open: true}); 40 | } 41 | 42 | handleClose() { 43 | this.setState({open: false}); 44 | } 45 | 46 | async submit() { 47 | const {header, optionA, optionB, timeout} = this.state; 48 | this.handleClose(); 49 | if ( 50 | await this.props.onCreate(header, optionA, optionB, parseInt(timeout)) 51 | ) { 52 | this.setState({ 53 | header: '', 54 | optionA: '', 55 | optionB: '', 56 | }); 57 | } 58 | } 59 | 60 | render() { 61 | const {classes} = this.props; 62 | return ( 63 | 64 | 65 | this.handleOpen()} 70 | > 71 | 72 | 73 | 74 | this.handleClose()} 77 | aria-labelledby="form-dialog-title" 78 | > 79 | Create Poll 80 | 81 | this.setState({header: e.target.value})} 88 | /> 89 | this.setState({optionA: e.target.value})} 96 | /> 97 | this.setState({optionB: e.target.value})} 104 | /> 105 | this.setState({timeout: e.target.value})} 114 | helperText="Cluster processes approx. 3600 slots per hour" 115 | /> 116 | 117 | 118 | 121 | 124 | 125 | 126 | 127 | ); 128 | } 129 | } 130 | 131 | CreatePollDialog.propTypes = { 132 | classes: PropTypes.object.isRequired, 133 | disabled: PropTypes.bool.isRequired, 134 | onCreate: PropTypes.func.isRequired, 135 | }; 136 | 137 | export default withStyles(styles)(CreatePollDialog); 138 | -------------------------------------------------------------------------------- /src/webapp/components/message-list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import PropTypes from 'prop-types'; 9 | import ReportIcon from '@material-ui/icons/Report'; 10 | import {withStyles} from '@material-ui/core/styles'; 11 | 12 | const styles = theme => ({ 13 | message: { 14 | ...theme.mixins.gutters(), 15 | paddingTop: theme.spacing.unit * 2, 16 | paddingBottom: theme.spacing.unit * 2, 17 | position: 'relative', 18 | width: '100%', 19 | }, 20 | }); 21 | 22 | class MessageList extends React.Component { 23 | render() { 24 | const {messages, userAccount} = this.props; 25 | const renderMessages = messages 26 | .map((message, index) => { 27 | const fromUser = 28 | userAccount && message.from.equals(userAccount.publicKey); 29 | return this.renderMessage(message, index, fromUser); 30 | }) 31 | .reverse(); 32 | 33 | return {renderMessages}; 34 | } 35 | 36 | renderMessage(message, index, fromUser) { 37 | const {classes, userAuthenticated, payerBalance} = this.props; 38 | const showReportAction = userAuthenticated && !fromUser && index !== 0; 39 | const reportDisabled = payerBalance === 0; 40 | const reportAction = showReportAction ? ( 41 | 42 | this.props.onBanUser(message)} 46 | disabled={reportDisabled} 47 | > 48 | 49 | 50 | 51 | ) : null; 52 | 53 | const postedBy = 'Posted by ' + message.name + (fromUser ? ' (you)' : ''); 54 | return ( 55 | 56 | 57 | 58 | {reportAction} 59 | 60 | 61 | ); 62 | } 63 | } 64 | 65 | MessageList.propTypes = { 66 | classes: PropTypes.object.isRequired, 67 | messages: PropTypes.array.isRequired, 68 | onBanUser: PropTypes.func.isRequired, 69 | payerBalance: PropTypes.number.isRequired, 70 | userAccount: PropTypes.object, 71 | userAuthenticated: PropTypes.bool.isRequired, 72 | }; 73 | 74 | export default withStyles(styles)(MessageList); 75 | -------------------------------------------------------------------------------- /src/webapp/components/poll-grid.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import PropTypes from 'prop-types'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import {withStyles} from '@material-ui/core/styles'; 6 | 7 | import Poll from './poll'; 8 | import CreatePollDialog from './create-poll'; 9 | 10 | const styles = theme => ({ 11 | root: { 12 | flexGrow: 1, 13 | }, 14 | empty: { 15 | display: 'flex', 16 | alignItems: 'center', 17 | justifyContent: 'center', 18 | margin: theme.spacing.unit * 2, 19 | borderRadius: 4, 20 | textAlign: 'center', 21 | }, 22 | }); 23 | 24 | class PollGrid extends React.Component { 25 | constructor(props) { 26 | super(props); 27 | 28 | this.state = { 29 | selectedOptions: false, 30 | banUserDialogOpen: false, 31 | }; 32 | } 33 | 34 | render() { 35 | const {polls, classes, payerBalance, busy} = this.props; 36 | 37 | const renderPolls = polls 38 | .sort((a, b) => a[1].last_block - b[1].last_block) 39 | .map(([...args]) => this.renderPoll(...args)) 40 | .reverse(); 41 | 42 | return ( 43 |
44 | {polls.length > 0 ? ( 45 | 46 | {renderPolls} 47 | 48 | ) : ( 49 |
50 | 51 | No polls yet, create one with the bottom right button! 52 | 53 |
54 | )} 55 | this.props.onCreate(...args)} 58 | /> 59 |
60 | ); 61 | } 62 | 63 | renderPoll(key, poll, balance, tallies) { 64 | const {clock, payerKey, payerBalance} = this.props; 65 | const onSubmit = (wager, tally) => this.props.onVote(key, wager, tally); 66 | const onClaim = () => this.props.onClaim(poll, key); 67 | return ( 68 | 69 | 79 | 80 | ); 81 | } 82 | } 83 | 84 | PollGrid.propTypes = { 85 | classes: PropTypes.object.isRequired, 86 | polls: PropTypes.array.isRequired, 87 | onVote: PropTypes.func.isRequired, 88 | onClaim: PropTypes.func.isRequired, 89 | onCreate: PropTypes.func.isRequired, 90 | clock: PropTypes.number.isRequired, 91 | busy: PropTypes.bool.isRequired, 92 | payerBalance: PropTypes.number.isRequired, 93 | payerKey: PropTypes.object, 94 | }; 95 | 96 | export default withStyles(styles)(PollGrid); 97 | -------------------------------------------------------------------------------- /src/webapp/components/poll-option.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ListItem from '@material-ui/core/ListItem'; 3 | import ListItemText from '@material-ui/core/ListItemText'; 4 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 5 | import CheckCircleIcon from '@material-ui/icons/CheckCircle'; 6 | import StarsIcon from '@material-ui/icons/Stars'; 7 | import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline'; 8 | import PropTypes from 'prop-types'; 9 | 10 | class PollOption extends React.Component { 11 | render() { 12 | if (this.props.expired) { 13 | return this.renderExpired(); 14 | } else { 15 | return this.renderActive(); 16 | } 17 | } 18 | 19 | renderExpired() { 20 | return {this.renderText()}; 21 | } 22 | 23 | renderActive() { 24 | const {onClick, selected} = this.props; 25 | return ( 26 | 27 | {this.renderIcon()} 28 | {this.renderText()} 29 | 30 | ); 31 | } 32 | 33 | renderIcon() { 34 | const {selected, winner} = this.props; 35 | return ( 36 | 37 | {winner ? ( 38 | 39 | ) : selected ? ( 40 | 41 | ) : ( 42 | 43 | )} 44 | 45 | ); 46 | } 47 | 48 | renderText() { 49 | const {option, wager} = this.props; 50 | let secondary = `Wager total: ${option.quantity}`; 51 | if (wager > 0) { 52 | secondary += `, My wager: ${wager}`; 53 | } 54 | return ; 55 | } 56 | } 57 | 58 | PollOption.propTypes = { 59 | option: PropTypes.object.isRequired, 60 | expired: PropTypes.bool.isRequired, 61 | onClick: PropTypes.func.isRequired, 62 | selected: PropTypes.bool.isRequired, 63 | winner: PropTypes.bool.isRequired, 64 | wager: PropTypes.number.isRequired, 65 | }; 66 | 67 | export default PollOption; 68 | -------------------------------------------------------------------------------- /src/webapp/components/poll.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import List from '@material-ui/core/List'; 3 | import Divider from '@material-ui/core/Divider'; 4 | import Paper from '@material-ui/core/Paper'; 5 | import Button from '@material-ui/core/Button'; 6 | import PropTypes from 'prop-types'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import {withStyles} from '@material-ui/core/styles'; 10 | import {PublicKey} from '@solana/web3.js'; 11 | 12 | import PollOption from './poll-option'; 13 | 14 | const styles = theme => ({ 15 | root: { 16 | flexGrow: 1, 17 | }, 18 | subtitle: { 19 | marginBottom: theme.spacing.unit * 2, 20 | }, 21 | divider: { 22 | marginTop: theme.spacing.unit * 2, 23 | marginBottom: theme.spacing.unit * 2, 24 | }, 25 | poll: { 26 | margin: theme.spacing.unit * 2, 27 | paddingTop: theme.spacing.unit * 2, 28 | paddingBottom: theme.spacing.unit * 2, 29 | textAlign: 'center', 30 | color: theme.palette.text.secondary, 31 | }, 32 | option: { 33 | display: 'flex', 34 | }, 35 | rightIcon: { 36 | marginLeft: theme.spacing.unit, 37 | }, 38 | footer: { 39 | display: 'flex', 40 | alignItems: 'baseline', 41 | justifyContent: 'center', 42 | }, 43 | submit: { 44 | marginLeft: theme.spacing.unit * 2, 45 | }, 46 | }); 47 | 48 | class Poll extends React.Component { 49 | constructor(props) { 50 | super(props); 51 | this.state = { 52 | wager: '100', 53 | }; 54 | } 55 | 56 | render() { 57 | const {poll, classes} = this.props; 58 | const stillLoading = !poll; 59 | 60 | return ( 61 | 62 | {stillLoading ? 'Loading' : this.renderPoll()} 63 | 64 | ); 65 | } 66 | 67 | renderPoll() { 68 | const {poll, classes} = this.props; 69 | const expired = this.slotsLeft() === 0; 70 | const winner = this.getWinner(); 71 | 72 | return ( 73 | 74 | 75 | {poll.header} 76 | 77 | 78 | {this.renderSubtitle()} 79 | 80 |
81 | 82 | {[poll.optionA, poll.optionB].map((option, index) => ( 83 | this.setState({selectedOption: index + 1})} 91 | /> 92 | ))} 93 | 94 |
95 | {this.renderFooter()} 96 |
97 | ); 98 | } 99 | 100 | renderSubtitle() { 101 | const slotsLeft = this.slotsLeft(); 102 | const expired = slotsLeft === 0; 103 | const claimed = this.alreadyClaimed(); 104 | if (expired) { 105 | const myAWager = this.getWager(0); 106 | const myBWager = this.getWager(1); 107 | const winner = this.getWinner(); 108 | if (myAWager || myBWager) { 109 | if (myAWager > myBWager && winner === 1) { 110 | return !claimed ? 'Claim your reward!' : 'You won!'; 111 | } else if (myBWager > myAWager && winner === 2) { 112 | return !claimed ? 'Claim your reward!' : 'You won!'; 113 | } else if (winner === 0) { 114 | return 'No winners'; 115 | } else { 116 | return 'You lost!'; 117 | } 118 | } else { 119 | return 'Expired!'; 120 | } 121 | } else { 122 | return `${slotsLeft} slots remaining`; 123 | } 124 | } 125 | 126 | renderFooter() { 127 | const {classes} = this.props; 128 | if (this.slotsLeft() <= 0 && this.alreadyClaimed()) return null; 129 | return ( 130 | 131 | 132 |
133 | {this.slotsLeft() > 0 134 | ? this.renderWagerInput() 135 | : this.renderClaimReward()} 136 |
137 |
138 | ); 139 | } 140 | 141 | renderWagerInput() { 142 | const {classes, onSubmit, poll, payerBalance} = this.props; 143 | const wager = parseInt(this.state.wager); 144 | const validWager = Number.isInteger(wager) && wager > 0; 145 | 146 | let optionAQuantity = parseInt(poll.optionA.quantity.toString()); 147 | let optionBQuantity = parseInt(poll.optionB.quantity.toString()); 148 | 149 | let tallyKey; 150 | if (this.state.selectedOption === 1) { 151 | tallyKey = new PublicKey(poll.optionA.tallyKey); 152 | optionAQuantity += wager; 153 | } else if (this.state.selectedOption === 2) { 154 | tallyKey = new PublicKey(poll.optionB.tallyKey); 155 | optionBQuantity += wager; 156 | } 157 | let wagerError = ''; 158 | if (wager > payerBalance - 100) { 159 | wagerError = 'Insufficient Funds'; 160 | } else if ( 161 | optionAQuantity === optionBQuantity && 162 | optionAQuantity + optionBQuantity > 0 163 | ) { 164 | wagerError = 'Cannot make wagers even'; 165 | } 166 | const noSelection = !this.state.selectedOption; 167 | 168 | return ( 169 | 170 | this.setState({wager: e.target.value})} 175 | type="number" 176 | InputLabelProps={{ 177 | shrink: true, 178 | }} 179 | margin="normal" 180 | variant="standard" 181 | helperText={wagerError} 182 | /> 183 | 192 | 193 | ); 194 | } 195 | 196 | renderClaimReward() { 197 | return ( 198 | 199 | 208 | 209 | ); 210 | } 211 | 212 | getWinner() { 213 | const {poll} = this.props; 214 | const expired = this.slotsLeft() === 0; 215 | let winner = 0; 216 | if (expired) { 217 | if (poll.optionA.quantity > poll.optionB.quantity) { 218 | winner = 1; 219 | } else if (poll.optionB.quantity > poll.optionA.quantity) { 220 | winner = 2; 221 | } 222 | } 223 | return winner; 224 | } 225 | 226 | getWager(tallyIndex) { 227 | const {payerKey, tallies} = this.props; 228 | const keys = tallies[tallyIndex].keys; 229 | const wagers = tallies[tallyIndex].wagers; 230 | for (const [i, key] of keys.entries()) { 231 | let tallyKey = new PublicKey(key); 232 | if (tallyKey.equals(payerKey)) { 233 | return parseInt(wagers[i].toString()); 234 | } 235 | } 236 | return null; 237 | } 238 | 239 | slotsLeft() { 240 | const {poll, clock} = this.props; 241 | const lastBlock = parseInt(poll.last_block.toString()); 242 | const slotsLeft = lastBlock - clock; 243 | if (slotsLeft <= 0) { 244 | return 0; 245 | } else { 246 | return slotsLeft; 247 | } 248 | } 249 | 250 | alreadyClaimed() { 251 | return this.props.balance <= 1; 252 | } 253 | } 254 | 255 | Poll.propTypes = { 256 | classes: PropTypes.object.isRequired, 257 | poll: PropTypes.object, 258 | balance: PropTypes.number, 259 | tallies: PropTypes.array, 260 | onSubmit: PropTypes.func.isRequired, 261 | onClaim: PropTypes.func.isRequired, 262 | clock: PropTypes.number.isRequired, 263 | payerKey: PropTypes.object, 264 | payerBalance: PropTypes.number.isRequired, 265 | }; 266 | 267 | export default withStyles(styles)(Poll); 268 | -------------------------------------------------------------------------------- /src/webapp/components/toolbar.js: -------------------------------------------------------------------------------- 1 | import AppBar from '@material-ui/core/AppBar'; 2 | import Badge from '@material-ui/core/Badge'; 3 | import Button from '@material-ui/core/Button'; 4 | import CircularProgress from '@material-ui/core/CircularProgress'; 5 | import ExploreIcon from '@material-ui/icons/Explore'; 6 | import InputBase from '@material-ui/core/InputBase'; 7 | import PauseIcon from '@material-ui/icons/Pause'; 8 | import PropTypes from 'prop-types'; 9 | import React from 'react'; 10 | import MaterialToolbar from '@material-ui/core/Toolbar'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import {fade} from '@material-ui/core/styles/colorManipulator'; 13 | import {withStyles} from '@material-ui/core/styles'; 14 | 15 | const styles = theme => ({ 16 | bar: { 17 | overflow: 'hidden', 18 | }, 19 | funds: { 20 | transition: 'margin-right 100ms linear, width 100ms linear', 21 | position: 'relative', 22 | marginRight: theme.spacing.unit * 5, 23 | whiteSpace: 'nowrap', 24 | }, 25 | fundsHidden: { 26 | [theme.breakpoints.down('sm')]: { 27 | marginRight: 0, 28 | width: 0, 29 | }, 30 | }, 31 | fundsText: { 32 | display: 'flex', 33 | flexDirection: 'column', 34 | }, 35 | grow: { 36 | flexGrow: 1, 37 | }, 38 | hiddenFundsText: { 39 | visibility: 'hidden', 40 | height: 0, 41 | }, 42 | inputRoot: { 43 | color: 'inherit', 44 | width: '100%', 45 | }, 46 | inputInput: { 47 | paddingTop: theme.spacing.unit, 48 | paddingRight: theme.spacing.unit * 2, 49 | paddingBottom: theme.spacing.unit, 50 | paddingLeft: theme.spacing.unit * 2, 51 | transition: theme.transitions.create('width'), 52 | width: '100%', 53 | }, 54 | login: { 55 | position: 'relative', 56 | marginLeft: theme.spacing.unit * 3, 57 | marginRight: theme.spacing.unit * 4, 58 | whiteSpace: 'nowrap', 59 | }, 60 | menuButton: { 61 | marginLeft: -6, 62 | marginRight: 20, 63 | minWidth: 0, 64 | [theme.breakpoints.up('sm')]: { 65 | marginLeft: -12, 66 | }, 67 | }, 68 | newMessage: { 69 | position: 'relative', 70 | borderRadius: theme.shape.borderRadius, 71 | backgroundColor: fade(theme.palette.common.white, 0.15), 72 | '&:hover': { 73 | backgroundColor: fade(theme.palette.common.white, 0.25), 74 | }, 75 | marginRight: theme.spacing.unit * 2, 76 | marginLeft: 0, 77 | flexGrow: 1, 78 | [theme.breakpoints.up('sm')]: { 79 | marginLeft: theme.spacing.unit * 3, 80 | }, 81 | }, 82 | title: { 83 | display: 'none', 84 | [theme.breakpoints.up('sm')]: { 85 | display: 'block', 86 | }, 87 | }, 88 | hideSmall: { 89 | [theme.breakpoints.down('sm')]: { 90 | display: 'none', 91 | }, 92 | }, 93 | hideNotSmall: { 94 | [theme.breakpoints.up('sm')]: { 95 | display: 'none', 96 | }, 97 | }, 98 | }); 99 | 100 | class Toolbar extends React.Component { 101 | constructor(props) { 102 | super(props); 103 | this.state = { 104 | balanceHovered: false, 105 | messageInputFocused: false, 106 | newMessage: '', 107 | }; 108 | } 109 | 110 | onInputKeyDown = async e => { 111 | if (e.keyCode !== 13) { 112 | return; 113 | } 114 | if (await this.props.onPostMessage(this.state.newMessage)) { 115 | this.setState({newMessage: ''}); 116 | } 117 | }; 118 | 119 | onInputChange = e => { 120 | this.setState({newMessage: e.target.value}); 121 | }; 122 | 123 | render() { 124 | const {busy, classes, explorerUrl, messageCount, idle} = this.props; 125 | const explorerIcon = idle ? ( 126 | 127 | ) : busy ? ( 128 | 132 | ) : ( 133 | 134 | ); 135 | 136 | return ( 137 | 138 | 139 | 149 | 150 | 156 | Solana Feed 157 | 158 | 159 | {this.renderMessageInput()} 160 | {this.renderBalanceButton()} 161 | 162 | 163 | ); 164 | } 165 | 166 | renderMessageInput() { 167 | const { 168 | busy, 169 | classes, 170 | loginDisabled, 171 | onLogin, 172 | payerBalance, 173 | userAuthenticated, 174 | showMessageInput, 175 | } = this.props; 176 | 177 | if (userAuthenticated) { 178 | if (!showMessageInput) { 179 | return ( 180 | 181 |
182 | 183 | ); 184 | } 185 | 186 | const zeroBalance = !payerBalance; 187 | return ( 188 |
189 | this.setState({messageInputFocused: true})} 200 | onBlur={() => this.setState({messageInputFocused: false})} 201 | onKeyDown={this.onInputKeyDown} 202 | onChange={this.onInputChange} 203 | /> 204 |
205 | ); 206 | } else { 207 | return ( 208 | 209 |
210 | 219 |
220 |
221 | 222 | ); 223 | } 224 | } 225 | 226 | renderBalanceButton() { 227 | const {classes, payerBalance, onRequestFunds, walletDisabled} = this.props; 228 | const text = [`Balance: ${payerBalance}`, 'Add Funds']; 229 | if (payerBalance === 0 || this.state.balanceHovered) { 230 | text.reverse(); 231 | } 232 | let className = classes.funds; 233 | if (this.state.messageInputFocused) { 234 | className += ` ${classes.fundsHidden}`; 235 | } 236 | return ( 237 |
238 | 252 |
253 | ); 254 | } 255 | } 256 | 257 | Toolbar.propTypes = { 258 | busy: PropTypes.bool.isRequired, 259 | classes: PropTypes.object.isRequired, 260 | explorerUrl: PropTypes.string, 261 | idle: PropTypes.bool.isRequired, 262 | loginDisabled: PropTypes.bool.isRequired, 263 | messageCount: PropTypes.number.isRequired, 264 | onLogin: PropTypes.func.isRequired, 265 | onPostMessage: PropTypes.func.isRequired, 266 | onRequestFunds: PropTypes.func.isRequired, 267 | payerBalance: PropTypes.number.isRequired, 268 | userAuthenticated: PropTypes.bool.isRequired, 269 | walletDisabled: PropTypes.bool.isRequired, 270 | showMessageInput: PropTypes.bool.isRequired, 271 | }; 272 | 273 | export default withStyles(styles)(Toolbar); 274 | -------------------------------------------------------------------------------- /src/webapp/webapp.js: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogContentText from '@material-ui/core/DialogContentText'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import IdleTimer from 'react-idle-timer'; 8 | import Paper from '@material-ui/core/Paper'; 9 | import PropTypes from 'prop-types'; 10 | import React from 'react'; 11 | import ReactDOM from 'react-dom'; 12 | import Snackbar from '@material-ui/core/Snackbar'; 13 | import Tab from '@material-ui/core/Tab'; 14 | import Tabs from '@material-ui/core/Tabs'; 15 | import {withStyles} from '@material-ui/core/styles'; 16 | import {withRouter, HashRouter} from 'react-router-dom'; 17 | 18 | import Api from './api'; 19 | import MessageList from './components/message-list'; 20 | import PollGrid from './components/poll-grid'; 21 | import Toolbar from './components/toolbar'; 22 | 23 | const MESSAGES_TAB = 0; 24 | const POLLS_TAB = 1; 25 | 26 | const styles = theme => ({ 27 | root: { 28 | width: '100%', 29 | overflow: 'hidden', 30 | }, 31 | snackbar: { 32 | [theme.breakpoints.down('sm')]: { 33 | bottom: 88, // FAB is 56px with 16px margin 34 | }, 35 | }, 36 | }); 37 | 38 | class App extends React.Component { 39 | constructor(props) { 40 | super(props); 41 | 42 | this.state = { 43 | banUserAlreadyBanned: false, 44 | banUserDialogOpen: false, 45 | banUserMessage: null, 46 | loadingMessages: true, 47 | loadingPolls: true, 48 | busyLoggingIn: false, 49 | busyPosting: false, 50 | busyCreatingPoll: false, 51 | busyVoting: false, 52 | busyClaiming: false, 53 | idle: true, 54 | loginMethod: 'none', 55 | clock: 0, 56 | messages: [], 57 | polls: [], 58 | snackMessage: '', 59 | transactionSignature: null, 60 | userAccount: null, 61 | payerBalance: 0, 62 | payerKey: null, 63 | programId: null, 64 | walletUrl: '', 65 | }; 66 | 67 | this.api = new Api(); 68 | } 69 | 70 | componentDidMount() { 71 | this.onActive(); 72 | } 73 | 74 | componentWillUnmount() { 75 | this.onIdle(); 76 | } 77 | 78 | onActive() { 79 | this.setState({idle: false}); 80 | 81 | this.api.subscribeConfig(config => { 82 | this.setState(config); 83 | }); 84 | 85 | this.api.subscribeBalance((payerBalance, payerKey) => { 86 | this.setState({payerBalance, payerKey}); 87 | }); 88 | 89 | this.api.subscribeMessages(messages => { 90 | this.setState({ 91 | loadingMessages: false, 92 | messages, 93 | }); 94 | }); 95 | 96 | this.api.subscribePolls(polls => { 97 | this.setState({ 98 | loadingPolls: false, 99 | polls, 100 | }); 101 | }); 102 | 103 | this.api.subscribeClock(clock => { 104 | this.setState({clock}); 105 | }); 106 | } 107 | 108 | onIdle() { 109 | this.setState({idle: true}); 110 | this.api.unsubscribe(); 111 | } 112 | 113 | async onLogin() { 114 | this.setState({busyLoggingIn: true}); 115 | const newState = {busyLoggingIn: false}; 116 | try { 117 | const userAccount = await this.api.login(this.state.loginMethod); 118 | Object.assign(newState, {userAccount}); 119 | } catch (err) { 120 | console.error(err); 121 | } finally { 122 | this.setState(newState); 123 | } 124 | } 125 | 126 | onChangeTab(newTab) { 127 | this.props.history.replace(this.tabToRoute(newTab)); 128 | } 129 | 130 | requestFunds() { 131 | this.api.requestFunds(res => this.setState(res)); 132 | } 133 | 134 | render() { 135 | const {classes} = this.props; 136 | const selectedTab = this.selectedTab(); 137 | 138 | let banUserDialog; 139 | if (this.state.banUserMessage !== null) { 140 | const user = this.state.banUserMessage.name; 141 | 142 | if (this.state.banUserAlreadyBanned) { 143 | banUserDialog = ( 144 | 150 | {`Ban ${user}`} 151 | 152 | 153 | {user} has already been banned 154 | 155 | 156 | 157 | 164 | 165 | 166 | ); 167 | } else { 168 | banUserDialog = ( 169 | 175 | {`Ban ${user}`} 176 | 177 | 178 | Do you want to prohibit {user} from posting new messages? 179 | 180 | 181 | 182 | 185 | 192 | 193 | 194 | ); 195 | } 196 | } 197 | 198 | return ( 199 |
200 | this.onLogin()} 207 | onPostMessage={msg => this.postMessage(msg)} 208 | onRequestFunds={() => this.requestFunds()} 209 | payerBalance={this.state.payerBalance} 210 | userAuthenticated={!!this.state.userAccount} 211 | walletDisabled={!this.state.walletUrl} 212 | showMessageInput={selectedTab === MESSAGES_TAB} 213 | /> 214 | this.onActive()} 217 | onIdle={() => this.onIdle()} 218 | debounce={250} 219 | timeout={1000 * 60 * 15} 220 | /> 221 | 222 | this.onChangeTab(newTab)} 225 | indicatorColor="primary" 226 | textColor="primary" 227 | centered 228 | > 229 | 230 | 231 | 232 | 233 | {this.showTabPage()} 234 | {this.state.snackMessage}} 251 | action={ 252 | this.state.transactionSignature ? ( 253 | 260 | ) : ( 261 | '' 262 | ) 263 | } 264 | /> 265 | {banUserDialog} 266 |
267 | ); 268 | } 269 | 270 | showTabPage() { 271 | switch (this.selectedTab()) { 272 | case MESSAGES_TAB: { 273 | return ( 274 | this.onBanUser(msg)} 277 | payerBalance={this.state.payerBalance} 278 | userAccount={this.state.userAccount} 279 | userAuthenticated={!!this.state.userAccount} 280 | /> 281 | ); 282 | } 283 | case POLLS_TAB: { 284 | return ( 285 | 286 | this.vote(...args)} 293 | onClaim={(...args) => this.claim(...args)} 294 | onCreate={(...args) => this.createPoll(...args)} 295 | /> 296 | 297 | ); 298 | } 299 | default: 300 | return null; 301 | } 302 | } 303 | 304 | busy() { 305 | const { 306 | loadingMessages, 307 | loadingPolls, 308 | busyPosting, 309 | busyLoggingIn, 310 | busyCreatingPoll, 311 | busyVoting, 312 | busyClaiming, 313 | } = this.state; 314 | return ( 315 | loadingMessages || 316 | loadingPolls || 317 | busyPosting || 318 | busyLoggingIn || 319 | busyCreatingPoll || 320 | busyVoting || 321 | busyClaiming 322 | ); 323 | } 324 | 325 | selectedTab() { 326 | const route = this.props.location.pathname; 327 | switch (route) { 328 | case '/polls': 329 | return POLLS_TAB; 330 | default: 331 | return MESSAGES_TAB; 332 | } 333 | } 334 | 335 | tabToRoute(tab) { 336 | switch (tab) { 337 | case POLLS_TAB: 338 | return '/polls'; 339 | case MESSAGES_TAB: 340 | return '/messages'; 341 | default: 342 | return '/'; 343 | } 344 | } 345 | 346 | async postMessage(newMessage: string, userToBan = null) { 347 | if (newMessage.length === 0) { 348 | return false; 349 | } 350 | 351 | if (this.state.busyPosting) { 352 | this.setState({ 353 | snackMessage: 'Unable to post message, please retry when not busy', 354 | transactionSignature: null, 355 | }); 356 | return false; 357 | } 358 | 359 | this.setState({busyPosting: true}); 360 | 361 | const {snackMessage, transactionSignature} = await this.api.postMessage( 362 | newMessage, 363 | userToBan, 364 | ); 365 | 366 | this.setState({ 367 | busyPosting: false, 368 | snackMessage, 369 | transactionSignature, 370 | }); 371 | 372 | return !!transactionSignature; 373 | } 374 | 375 | async vote(pollKey, wager, tally) { 376 | if (this.state.busyVoting) { 377 | this.setState({ 378 | snackMessage: 'Unable to vote, please retry when not busy', 379 | transactionSignature: null, 380 | }); 381 | return false; 382 | } 383 | 384 | this.setState({busyVoting: true}); 385 | const {snackMessage, transactionSignature} = await this.api.vote( 386 | pollKey, 387 | wager, 388 | tally, 389 | ); 390 | 391 | this.setState({ 392 | busyVoting: false, 393 | snackMessage, 394 | transactionSignature, 395 | }); 396 | 397 | return !!transactionSignature; 398 | } 399 | 400 | async claim(poll, pollKey) { 401 | if (this.state.busyClaiming) { 402 | this.setState({ 403 | snackMessage: 'Unable to submit claim, please retry when not busy', 404 | transactionSignature: null, 405 | }); 406 | return false; 407 | } 408 | 409 | this.setState({busyClaiming: true}); 410 | const {snackMessage, transactionSignature} = await this.api.claim( 411 | poll, 412 | pollKey, 413 | ); 414 | 415 | this.setState({ 416 | busyClaiming: false, 417 | snackMessage, 418 | transactionSignature, 419 | }); 420 | 421 | return !!transactionSignature; 422 | } 423 | 424 | async createPoll(...args) { 425 | if (this.state.busyCreatingPoll) { 426 | this.setState({ 427 | snackMessage: 'Unable to create poll, please retry when not busy', 428 | transactionSignature: null, 429 | }); 430 | return false; 431 | } 432 | 433 | this.setState({busyCreatingPoll: true}); 434 | 435 | const {snackMessage, transactionSignature} = await this.api.createPoll( 436 | ...args, 437 | ); 438 | 439 | this.setState({ 440 | busyCreatingPoll: false, 441 | snackMessage, 442 | transactionSignature, 443 | }); 444 | 445 | return !!transactionSignature; 446 | } 447 | 448 | onSnackClose = () => { 449 | this.setState({ 450 | snackMessage: '', 451 | transactionSignature: null, 452 | }); 453 | }; 454 | 455 | onBanUser = async message => { 456 | try { 457 | const banUserAlreadyBanned = await this.api.isUserBanned(message.from); 458 | this.setState({ 459 | banUserDialogOpen: true, 460 | banUserAlreadyBanned, 461 | banUserMessage: message, 462 | }); 463 | } catch (err) { 464 | console.error(err); 465 | } 466 | }; 467 | 468 | onBanUserDialogClose = () => { 469 | this.setState({ 470 | banUserDialogOpen: false, 471 | banUserMessage: null, 472 | }); 473 | }; 474 | 475 | onBanUserDialogConfirm = () => { 476 | this.onBanUserDialogClose(); 477 | this.postMessage( 478 | `${this.state.banUserMessage.name} has been banned`, 479 | this.state.banUserMessage.from, 480 | ); 481 | }; 482 | 483 | blockExplorerTransactionsByProgramUrl = (): string | null => { 484 | if (!this.state.explorerUrlBuilder) return; 485 | if (!this.state.programId) return; 486 | return this.state.explorerUrlBuilder(`account/${this.state.programId}`); 487 | }; 488 | 489 | blockExplorerLatestTransactionUrl = () => { 490 | if (!this.state.explorerUrlBuilder) return; 491 | if (!this.state.transactionSignature) return; 492 | return this.state.explorerUrlBuilder( 493 | `txn/${this.state.transactionSignature}`, 494 | ); 495 | }; 496 | } 497 | App.propTypes = { 498 | classes: PropTypes.object.isRequired, 499 | location: PropTypes.object.isRequired, 500 | history: PropTypes.object.isRequired, 501 | }; 502 | 503 | const StyledApp = withStyles(styles)(withRouter(App)); 504 | ReactDOM.render( 505 | 506 | 507 | , 508 | document.getElementById('app'), 509 | ); 510 | module.hot.accept(); 511 | -------------------------------------------------------------------------------- /static/img/solana-logo-horizontal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Solana Feed 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 16 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /urls.js: -------------------------------------------------------------------------------- 1 | // To connect to a public cluster, set `export LIVE=1` in your 2 | // environment. By default, `LIVE=1` will connect to the devnet cluster. 3 | 4 | import {clusterApiUrl, Cluster} from '@solana/web3.js'; 5 | 6 | function chooseCluster(): Cluster | undefined { 7 | if (!process.env.LIVE) return; 8 | switch (process.env.CLUSTER) { 9 | case 'devnet': 10 | case 'testnet': 11 | case 'mainnet-beta': { 12 | return process.env.CLUSTER; 13 | } 14 | } 15 | return 'devnet'; 16 | } 17 | 18 | export const cluster = chooseCluster(); 19 | 20 | export const url = 21 | process.env.RPC_URL || 22 | (process.env.LIVE ? clusterApiUrl(cluster, false) : 'http://localhost:8899'); 23 | 24 | export const urlTls = 25 | process.env.RPC_URL || 26 | (process.env.LIVE ? clusterApiUrl(cluster, true) : 'http://localhost:8899'); 27 | 28 | export let walletUrl = 29 | process.env.WALLET_URL || 'https://solana-example-webwallet.herokuapp.com/'; 30 | -------------------------------------------------------------------------------- /webpack.cli.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-commonjs:0 */ 2 | const path = require('path'); 3 | const FilterWarningsPlugin = require('webpack-filter-warnings-plugin'); 4 | 5 | module.exports = { 6 | target: 'node', 7 | entry: './src/cli/bootstrap-poll.js', 8 | output: { 9 | path: path.resolve(__dirname, 'dist', 'cli'), 10 | filename: 'prediction-poll.js', 11 | }, 12 | node: { 13 | __dirname: true, 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: [/node_modules/, /wasm/], 20 | use: ['babel-loader', 'eslint-loader'], 21 | }, 22 | ], 23 | }, 24 | plugins: [ 25 | new FilterWarningsPlugin({ 26 | exclude: [ 27 | /Critical dependency: the request of a dependency is an expression/, 28 | /Module not found: Error: Can't resolve/, // can't use web3 websocket api 29 | ], 30 | }), 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-commonjs:0 */ 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const WasmPackPlugin = require('@wasm-tool/wasm-pack-plugin'); 6 | const FilterWarningsPlugin = require('webpack-filter-warnings-plugin'); 7 | 8 | const distStatic = path.resolve(__dirname, 'dist', 'static'); 9 | const clientConfig = { 10 | target: 'web', 11 | entry: './src/webapp/bootstrap.js', 12 | output: { 13 | path: distStatic, 14 | publicPath: '/', 15 | filename: 'bundle.js', 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.js$/, 21 | exclude: [/node_modules/, /wasm/], 22 | use: ['babel-loader', 'eslint-loader'], 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: ['style-loader', 'css-loader'], 27 | }, 28 | ], 29 | }, 30 | resolve: { 31 | extensions: ['*', '.js', '.jsx'], 32 | mainFiles: ['index'], 33 | }, 34 | node: { 35 | fs: 'empty', 36 | }, 37 | plugins: [ 38 | new webpack.HotModuleReplacementPlugin(), 39 | new webpack.DefinePlugin({ 40 | 'process.env': { 41 | LIVE: JSON.stringify(process.env.LIVE), 42 | PORT: JSON.stringify(process.env.PORT), 43 | }, 44 | }), 45 | new CopyPlugin([{from: path.resolve(__dirname, 'static'), to: distStatic}]), 46 | new WasmPackPlugin({ 47 | crateDirectory: path.resolve( 48 | __dirname, 49 | 'bpf-rust-programs', 50 | 'prediction-poll', 51 | 'wasm_bindings', 52 | ), 53 | extraArgs: '--no-typescript', 54 | outDir: path.resolve(__dirname, 'wasm'), 55 | outName: 'index', 56 | }), 57 | ], 58 | devServer: { 59 | disableHostCheck: true, 60 | contentBase: './dist', 61 | hot: true, 62 | host: '0.0.0.0', 63 | historyApiFallback: { 64 | index: 'index.html', 65 | }, 66 | }, 67 | }; 68 | 69 | const serverConfig = { 70 | target: 'node', 71 | entry: './src/server/bootstrap.js', 72 | output: { 73 | path: path.resolve(__dirname, 'dist'), 74 | filename: 'server.js', 75 | }, 76 | node: { 77 | __dirname: true, 78 | }, 79 | module: { 80 | rules: [ 81 | { 82 | test: /\.js$/, 83 | exclude: [/node_modules/, /wasm/], 84 | use: ['babel-loader', 'eslint-loader'], 85 | }, 86 | ], 87 | }, 88 | plugins: [ 89 | new FilterWarningsPlugin({ 90 | exclude: [ 91 | /Critical dependency: the request of a dependency is an expression/, 92 | /Module not found: Error: Can't resolve/, // can't use web3 websocket api 93 | ], 94 | }), 95 | new WasmPackPlugin({ 96 | crateDirectory: path.resolve( 97 | __dirname, 98 | 'bpf-rust-programs', 99 | 'prediction-poll', 100 | 'wasm_bindings', 101 | ), 102 | extraArgs: '--no-typescript', 103 | outDir: path.resolve(__dirname, 'wasm'), 104 | outName: 'index', 105 | }), 106 | ], 107 | }; 108 | 109 | module.exports = [clientConfig, serverConfig]; 110 | --------------------------------------------------------------------------------